Merge pull request #60 from Dark-Athena/main

Add baud rate selection and settings reset feature
This commit is contained in:
wenjie
2025-12-04 11:44:09 +08:00
committed by GitHub
14 changed files with 404 additions and 69 deletions

View File

@@ -15,12 +15,17 @@ export class SerialPort {
): Promise<void> {
try {
if (this.port?.isOpen) {
console.log('Closing existing serial port before opening new one')
await this.close()
await new Promise(resolve => setTimeout(resolve, 100))
}
console.log(`Opening serial port: ${path} at ${baudRate} baud`)
this.port = new SP({ path, baudRate }, (err) => {
if (err) {
console.error('Error opening port: ', err.message)
} else {
console.log(`Serial port ${path} opened successfully at ${baudRate} baud`)
}
onOpen(err)
})
@@ -72,10 +77,24 @@ export class SerialPort {
async close(): Promise<void> {
if (this.port?.isOpen) {
try {
this.port.close()
console.log('Closing serial port...')
await new Promise<void>((resolve, reject) => {
this.port!.close((err) => {
if (err) {
console.error('close-serial-port error', err)
reject(err)
} else {
console.log('Serial port closed successfully')
resolve()
}
})
})
} catch (error) {
console.error('close-serial-port error', error)
throw error
}
} else {
console.log('Serial port is already closed or not initialized')
}
}
}

View File

@@ -26,7 +26,7 @@ async function getSerialPorts(): Promise<string[]> {
async function openSerialPort(
e: IpcMainInvokeEvent,
path: string,
baudRate = 57600
baudRate: number = 57600
): Promise<boolean> {
try {
await device.serialPort.init(path, baudRate, (err) => {

View File

@@ -1,10 +1,10 @@
import { ReactElement, useEffect, useState } from 'react'
import { Select } from 'antd'
import { Select, Space } from 'antd'
import { useAtom } from 'jotai'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
import { serialPortAtom, serialPortStateAtom } from '@renderer/jotai/device'
import { serialPortAtom, serialPortStateAtom, baudRateAtom } from '@renderer/jotai/device'
import * as storage from '@renderer/libs/storage'
type Option = {
@@ -21,11 +21,27 @@ export const SerialPort = ({ setMsg }: SerialPortProps): ReactElement => {
const [serialPort, setSerialPort] = useAtom(serialPortAtom)
const [serialPortState, setSerialPortState] = useAtom(serialPortStateAtom)
const [baudRate, setBaudRate] = useAtom(baudRateAtom)
const [options, setOptions] = useState<Option[]>([])
const [isFailed, setIsFailed] = useState(false)
const baudRateOptions = [
{ value: 1200, label: '1200' },
{ value: 2400, label: '2400' },
{ value: 4800, label: '4800' },
{ value: 9600, label: '9600' },
{ value: 14400, label: '14400' },
{ value: 19200, label: '19200' },
{ value: 38400, label: '38400' },
{ value: 57600, label: '57600' },
{ value: 115200, label: '115200' }
]
useEffect(() => {
const savedBaudRate = storage.getBaudRate()
setBaudRate(savedBaudRate)
getSerialPorts(true)
const rmListener = window.electron.ipcRenderer.on(IpcEvents.OPEN_SERIAL_PORT_RSP, (_, err) => {
@@ -63,7 +79,7 @@ export const SerialPort = ({ setMsg }: SerialPortProps): ReactElement => {
setIsFailed(false)
setMsg('')
const success = await window.electron.ipcRenderer.invoke(IpcEvents.OPEN_SERIAL_PORT, port)
const success = await window.electron.ipcRenderer.invoke(IpcEvents.OPEN_SERIAL_PORT, port, baudRate)
if (success) {
setSerialPort(port)
@@ -73,16 +89,45 @@ export const SerialPort = ({ setMsg }: SerialPortProps): ReactElement => {
}
}
async function closeSerialPort(): Promise<void> {
await window.electron.ipcRenderer.invoke(IpcEvents.CLOSE_SERIAL_PORT)
setSerialPort('')
setSerialPortState('disconnected')
storage.setSerialPort('')
}
async function handleBaudRateChange(newBaudRate: number): Promise<void> {
setBaudRate(newBaudRate)
storage.setBaudRate(newBaudRate)
if (serialPort && serialPortState === 'connected') {
const currentPort = serialPort
await closeSerialPort()
setTimeout(() => {
selectSerialPort(currentPort)
}, 200)
}
}
return (
<Select
value={serialPort || undefined}
style={{ width: 280 }}
options={options}
loading={serialPortState === 'connecting'}
status={isFailed ? 'error' : undefined}
placeholder={t('modal.selectSerial')}
onChange={selectSerialPort}
onClick={() => getSerialPorts(false)}
/>
<Space direction="vertical" size="small" style={{ width: '100%', alignItems: 'center' }}>
<Select
value={serialPort || undefined}
style={{ width: 280 }}
options={options}
loading={serialPortState === 'connecting'}
status={isFailed ? 'error' : undefined}
placeholder={t('modal.selectSerial')}
onChange={selectSerialPort}
onClick={() => getSerialPorts(false)}
/>
<Select
value={baudRate}
style={{ width: 280 }}
options={baudRateOptions}
placeholder={t('modal.selectBaudRate')}
onChange={handleBaudRateChange}
/>
</Space>
)
}

View File

@@ -1,19 +1,38 @@
import { ReactElement, useEffect, useState } from 'react'
import { Popover } from 'antd'
import { Popover, Select, Space, Divider } from 'antd'
import clsx from 'clsx'
import { useAtom } from 'jotai'
import { CpuIcon, LoaderCircleIcon, RadioIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
import { serialPortAtom } from '@renderer/jotai/device'
import { serialPortAtom, baudRateAtom } from '@renderer/jotai/device'
import * as storage from '@renderer/libs/storage'
export const SerialPort = (): ReactElement => {
const { t } = useTranslation()
const [serialPort, setSerialPort] = useAtom(serialPortAtom)
const [baudRate, setBaudRate] = useAtom(baudRateAtom)
const [connectingPort, setConnectingPort] = useState('')
const [serialPorts, setSerialPorts] = useState<string[]>([])
const baudRateOptions = [
{ value: 1200, label: '1200' },
{ value: 2400, label: '2400' },
{ value: 4800, label: '4800' },
{ value: 9600, label: '9600' },
{ value: 14400, label: '14400' },
{ value: 19200, label: '19200' },
{ value: 38400, label: '38400' },
{ value: 57600, label: '57600' },
{ value: 115200, label: '115200' }
]
useEffect(() => {
const savedBaudRate = storage.getBaudRate()
setBaudRate(savedBaudRate)
getSerialPorts()
const rmListener = window.electron.ipcRenderer.on(IpcEvents.OPEN_SERIAL_PORT_RSP, () => {
@@ -34,37 +53,88 @@ export const SerialPort = (): ReactElement => {
if (connectingPort) return
setConnectingPort(port)
const success = await window.electron.ipcRenderer.invoke(IpcEvents.OPEN_SERIAL_PORT, port)
const success = await window.electron.ipcRenderer.invoke(IpcEvents.OPEN_SERIAL_PORT, port, baudRate)
if (success) {
setSerialPort(port)
storage.setSerialPort(port)
}
}
async function closeSerialPort(): Promise<void> {
await window.electron.ipcRenderer.invoke(IpcEvents.CLOSE_SERIAL_PORT)
setSerialPort('')
storage.setSerialPort('')
}
async function handleBaudRateChange(newBaudRate: number): Promise<void> {
setBaudRate(newBaudRate)
storage.setBaudRate(newBaudRate)
if (serialPort) {
const currentPort = serialPort
await closeSerialPort()
setTimeout(() => {
openSerialPort(currentPort)
}, 200)
}
}
const content = (
<div className="max-h-[350px] overflow-y-auto">
{serialPorts.map((port: string) => (
<div
key={port}
className={clsx(
'flex cursor-pointer items-center space-x-2 rounded px-3 py-2 hover:bg-neutral-700/60',
port === serialPort ? 'text-blue-500' : 'text-white'
)}
onClick={() => openSerialPort(port)}
>
{port === connectingPort ? (
<LoaderCircleIcon className="animate-spin" size={16} />
) : (
<RadioIcon size={16} />
)}
<span>{port}</span>
<div className="w-[280px] p-3">
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
<div>
<div className="mb-2 text-sm text-neutral-300">{t('menu.serialPort.device')}</div>
<div className="max-h-[200px] overflow-y-auto">
{serialPorts.length === 0 ? (
<div className="text-neutral-500 text-sm py-2">{t('menu.serialPort.noDeviceFound')}</div>
) : (
serialPorts.map((port: string) => (
<div
key={port}
className={clsx(
'flex cursor-pointer items-center space-x-2 rounded px-3 py-2 hover:bg-neutral-700/60',
port === serialPort ? 'text-blue-500' : 'text-white'
)}
onClick={() => openSerialPort(port)}
>
{port === connectingPort ? (
<LoaderCircleIcon className="animate-spin" size={16} />
) : (
<RadioIcon size={16} />
)}
<span>{port}</span>
</div>
))
)}
</div>
</div>
))}
<Divider style={{ margin: 0, borderColor: '#404040' }} />
<div>
<div className="mb-2 text-sm text-neutral-300">{t('menu.serialPort.baudRate')}</div>
<Select
value={baudRate}
style={{ width: '100%' }}
options={baudRateOptions}
onChange={handleBaudRateChange}
size="small"
/>
</div>
</Space>
</div>
)
return (
<Popover content={content} placement="bottomLeft" trigger="click" arrow={false}>
<div className="flex h-[28px] cursor-pointer items-center justify-center rounded px-2 text-white hover:bg-neutral-700/70">
<div
className={clsx(
"flex h-[28px] cursor-pointer items-center justify-center rounded px-2 hover:bg-neutral-700/70",
serialPort ? 'text-blue-400' : 'text-white'
)}
title={serialPort ? `${serialPort} @ ${baudRate}` : t('menu.serialPort.clickToSelect')}
>
<CpuIcon size={18} />
</div>
</Popover>

View File

@@ -1,7 +1,7 @@
import { ReactElement, useEffect, useState } from 'react'
import { Badge, Modal } from 'antd'
import clsx from 'clsx'
import { BadgeInfoIcon, CircleArrowUpIcon, PaletteIcon, SettingsIcon } from 'lucide-react'
import { BadgeInfoIcon, CircleArrowUpIcon, PaletteIcon, SettingsIcon, RotateCcwIcon } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { IpcEvents } from '@common/ipc-events'
@@ -10,6 +10,7 @@ import * as storage from '@renderer/libs/storage'
import { About } from './about'
import { Appearance } from './appearance'
import { Update } from './update'
import { Reset } from './reset'
export const Settings = (): ReactElement => {
const { t } = useTranslation()
@@ -32,6 +33,7 @@ export const Settings = (): ReactElement => {
const tabs = [
{ id: 'appearance', icon: <PaletteIcon size={16} />, component: <Appearance /> },
{ id: 'update', icon: <CircleArrowUpIcon size={16} />, component: <Update /> },
{ id: 'reset', icon: <RotateCcwIcon size={16} />, component: <Reset /> },
{ id: 'about', icon: <BadgeInfoIcon size={16} />, component: <About /> }
]

View File

@@ -0,0 +1,64 @@
import { ReactElement, useState } from 'react'
import { Button, Modal } from 'antd'
import { useTranslation } from 'react-i18next'
import * as storage from '@renderer/libs/storage'
export const Reset = (): ReactElement => {
const { t } = useTranslation()
const [isConfirmModalOpen, setIsConfirmModalOpen] = useState(false)
function handleReset(): void {
setIsConfirmModalOpen(true)
}
function confirmReset(): void {
storage.clearAllSettings()
setIsConfirmModalOpen(false)
// 重新加载应用以应用重置
window.location.reload()
}
function cancelReset(): void {
setIsConfirmModalOpen(false)
}
return (
<>
<div className="space-y-6">
<div>
<div className="text-xl font-bold">{t('settings.reset.title')}</div>
<div className="pt-2 text-sm text-neutral-400">{t('settings.reset.description')}</div>
</div>
<div className="space-y-4">
<div className="rounded-lg border border-red-600/30 bg-red-600/10 p-4">
<div className="text-sm font-medium text-red-400">{t('settings.reset.warning')}</div>
<div className="mt-2 text-xs text-red-300">{t('settings.reset.warningDescription')}</div>
</div>
<Button
type="primary"
danger
onClick={handleReset}
className="w-full"
>
{t('settings.reset.button')}
</Button>
</div>
</div>
<Modal
title={t('settings.reset.confirmTitle')}
open={isConfirmModalOpen}
onOk={confirmReset}
onCancel={cancelReset}
okText={t('settings.reset.confirm')}
cancelText={t('settings.reset.cancel')}
okButtonProps={{ danger: true }}
>
<p>{t('settings.reset.confirmMessage')}</p>
</Modal>
</>
)
}

View File

@@ -8,15 +8,22 @@ const be = {
failed: 'Kan geen verbinding maken met de camera. Probeer opnieuw.'
},
modal: {
title: 'Kies USB-apparaat',
selectVideo: 'Kies een video-invoerapparaat',
selectSerial: 'Kies serieel apparaat'
},
title: 'Kies USB-apparaat',
selectVideo: 'Selecteer een video-invoerapparaat',
selectSerial: 'Kies serieel apparaat',
selectBaudRate: 'Selecteer baudrate'
},
menu: {
serial: 'Serieel',
keyboard: 'Klavier',
mouse: 'Muis'
},
serial: 'Serieel',
keyboard: 'Toetsenbord',
mouse: 'Muis',
serialPort: {
device: 'Serieel apparaat',
baudRate: 'Baudrate',
noDeviceFound: 'Geen seriële apparaten gevonden',
clickToSelect: 'Klik om seriële poort te selecteren'
}
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
@@ -73,6 +80,17 @@ const be = {
title: 'Over',
version: 'Versie',
community: 'Community'
},
reset: {
title: 'Instellingen resetten',
description: 'Alle applicatie-instellingen resetten naar standaardwaarden',
warning: 'Waarschuwing',
warningDescription: 'Deze actie kan niet ongedaan worden gemaakt. Alle aangepaste instellingen gaan verloren.',
button: 'Alle instellingen resetten',
confirmTitle: 'Reset bevestigen',
confirmMessage: 'Weet u zeker dat u alle instellingen wilt resetten? Deze actie kan niet ongedaan worden gemaakt.',
confirm: 'Reset',
cancel: 'Annuleren'
}
}
}

View File

@@ -8,15 +8,22 @@ const de = {
failed: 'Kamera konnte nicht verbunden werden. Bitte versuche es erneut.'
},
modal: {
title: 'USB-Gerät auswählen',
selectVideo: 'Bitte wähle ein Video-Eingabegerät aus',
selectSerial: 'Serielles Gerät auswählen'
},
title: 'USB-Gerät auswählen',
selectVideo: 'Bitte wählen Sie ein Video-Eingabegerät',
selectSerial: 'Serielles Gerät auswählen',
selectBaudRate: 'Bitte wählen Sie die Baudrate'
},
menu: {
serial: 'Seriell',
keyboard: 'Tastatur',
mouse: 'Maus'
},
serial: 'Seriell',
keyboard: 'Tastatur',
mouse: 'Maus',
serialPort: {
device: 'Serielles Gerät',
baudRate: 'Baudrate',
noDeviceFound: 'Keine seriellen Geräte gefunden',
clickToSelect: 'Klicken Sie, um seriellen Port auszuwählen'
}
},
video: {
resolution: 'Auflösung',
scale: 'Skalierung',
@@ -76,6 +83,17 @@ const de = {
title: 'Über',
version: 'Version',
community: 'Community'
},
reset: {
title: 'Einstellungen zurücksetzen',
description: 'Alle Anwendungseinstellungen auf Standardwerte zurücksetzen',
warning: 'Warnung',
warningDescription: 'Diese Aktion kann nicht rückgängig gemacht werden. Alle benutzerdefinierten Einstellungen gehen verloren.',
button: 'Alle Einstellungen zurücksetzen',
confirmTitle: 'Zurücksetzen bestätigen',
confirmMessage: 'Sind Sie sicher, dass Sie alle Einstellungen zurücksetzen möchten? Diese Aktion kann nicht rückgängig gemacht werden.',
confirm: 'Zurücksetzen',
cancel: 'Abbrechen'
}
}
}

View File

@@ -10,12 +10,19 @@ const en = {
modal: {
title: 'Select USB Device',
selectVideo: 'Please select a video input device',
selectSerial: 'Please select serial device'
selectSerial: 'Please select serial device',
selectBaudRate: 'Please select baud rate'
},
menu: {
serial: 'Serial',
keyboard: 'Keyboard',
mouse: 'Mouse'
mouse: 'Mouse',
serialPort: {
device: 'Serial Device',
baudRate: 'Baud Rate',
noDeviceFound: 'No serial devices found',
clickToSelect: 'Click to select serial port'
}
},
video: {
resolution: 'Resolution',
@@ -81,6 +88,17 @@ const en = {
title: 'About',
version: 'Version',
community: 'Community'
},
reset: {
title: 'Reset Settings',
description: 'Reset all application settings to default values',
warning: 'Warning',
warningDescription: 'This action cannot be undone. All your custom settings will be lost.',
button: 'Reset All Settings',
confirmTitle: 'Confirm Reset',
confirmMessage: 'Are you sure you want to reset all settings? This action cannot be undone.',
confirm: 'Reset',
cancel: 'Cancel'
}
}
}

View File

@@ -8,15 +8,22 @@ const nl = {
failed: 'Kan geen verbinding maken met de camera. Probeer het opnieuw.'
},
modal: {
title: 'Selecteer USB-apparaat',
selectVideo: 'Selecteer een video-invoerapparaat',
selectSerial: 'Selecteer serieel apparaat'
},
title: 'Selecteer USB-apparaat',
selectVideo: 'Selecteer een video-invoerapparaat',
selectSerial: 'Selecteer serieel apparaat',
selectBaudRate: 'Selecteer baudrate'
},
menu: {
serial: 'Serieel',
keyboard: 'Toetsenbord',
mouse: 'Muis'
},
serial: 'Serieel',
keyboard: 'Toetsenbord',
mouse: 'Muis',
serialPort: {
device: 'Serieel apparaat',
baudRate: 'Baudrate',
noDeviceFound: 'Geen seriële apparaten gevonden',
clickToSelect: 'Klik om seriële poort te selecteren'
}
},
video: {
resolution: 'Resolutie',
scale: 'Schaal',
@@ -73,6 +80,17 @@ const nl = {
title: 'Over',
version: 'Versie',
community: 'Community'
},
reset: {
title: 'Instellingen resetten',
description: 'Alle applicatie-instellingen resetten naar standaardwaarden',
warning: 'Waarschuwing',
warningDescription: 'Deze actie kan niet ongedaan worden gemaakt. Alle aangepaste instellingen gaan verloren.',
button: 'Alle instellingen resetten',
confirmTitle: 'Reset bevestigen',
confirmMessage: 'Weet je zeker dat je alle instellingen wilt resetten? Deze actie kan niet ongedaan worden gemaakt.',
confirm: 'Reset',
cancel: 'Annuleren'
}
}
}

View File

@@ -8,14 +8,21 @@ const ru = {
failed: 'Не удалось подключить камеру. Пожалуйста, попробуйте еще раз.'
},
modal: {
title: 'Выберите устройство USB',
selectVideo: 'Выбрать источник видео',
selectSerial: 'Выбрать последовательный порт'
title: 'Выбрать USB устройство',
selectVideo: 'Пожалуйста, выберите устройство видеовхода',
selectSerial: 'Выбрать последовательный порт',
selectBaudRate: 'Пожалуйста, выберите скорость передачи данных'
},
menu: {
serial: 'Последовательный порт',
keyboard: 'Клавиатура',
mouse: 'Мышь'
mouse: 'Мышь',
serialPort: {
device: 'Последовательное устройство',
baudRate: 'Скорость передачи',
noDeviceFound: 'Последовательные устройства не найдены',
clickToSelect: 'Нажмите, чтобы выбрать последовательный порт'
}
},
video: {
resolution: 'Разрешение',
@@ -77,6 +84,17 @@ const ru = {
title: 'О системе NanoKVM-USB',
version: 'Версия',
community: 'Сообщество'
},
reset: {
title: 'Сброс настроек',
description: 'Сбросить все настройки приложения к значениям по умолчанию',
warning: 'Предупреждение',
warningDescription: 'Это действие нельзя отменить. Все пользовательские настройки будут потеряны.',
button: 'Сбросить все настройки',
confirmTitle: 'Подтвердить сброс',
confirmMessage: 'Вы уверены, что хотите сбросить все настройки? Это действие нельзя отменить.',
confirm: 'Сбросить',
cancel: 'Отмена'
}
}
}

View File

@@ -9,12 +9,19 @@ const zh = {
modal: {
title: '选择 USB 设备',
selectVideo: '请选择视频输入设备',
selectSerial: '请选择串口设备'
selectSerial: '请选择串口设备',
selectBaudRate: '请选择波特率'
},
menu: {
serial: '串口',
keyboard: '键盘',
mouse: '鼠标'
mouse: '鼠标',
serialPort: {
device: '串口设备',
baudRate: '波特率',
noDeviceFound: '未找到串口设备',
clickToSelect: '点击选择串口'
}
},
video: {
resolution: '分辨率',
@@ -79,6 +86,17 @@ const zh = {
title: '关于',
version: '版本',
community: '社区'
},
reset: {
title: '重置设置',
description: '将所有应用程序设置重置为默认值',
warning: '警告',
warningDescription: '此操作无法撤销。所有自定义设置将丢失。',
button: '重置所有设置',
confirmTitle: '确认重置',
confirmMessage: '您确定要重置所有设置吗?此操作无法撤销。',
confirm: '重置',
cancel: '取消'
}
}
}

View File

@@ -17,3 +17,4 @@ export const videoStateAtom = atom<VideoState>('disconnected')
export const serialPortAtom = atom('')
export const serialPortStateAtom = atom<SerialState>('disconnected')
export const baudRateAtom = atom(57600)

View File

@@ -14,6 +14,7 @@ const MOUSE_MODE_KEY = 'nanokvm-usb-mouse-mode'
const MOUSE_SCROLL_DIRECTION_KEY = 'nanokvm-usb-mouse-scroll-direction'
const SKIP_UPDATE_KEY = 'nano-kvm-check-update'
const MOUSE_SCROLL_INTERVAL_KEY = 'nanokvm-usb-mouse-scroll-interval'
const BAUD_RATE_KEY = 'nanokvm-usb-baud-rate'
const MOUSE_JIGGLER_MODE_KEY = 'nanokvm-usb-mouse-jiggler-mode'
export function getLanguage(): string | null {
@@ -85,6 +86,18 @@ export function setSerialPort(port: string): void {
localStorage.setItem(SERIAL_PORT_KEY, port)
}
export function getBaudRate(): number {
const baudRate = localStorage.getItem(BAUD_RATE_KEY)
if (!baudRate) {
return 57600
}
return parseInt(baudRate, 10)
}
export function setBaudRate(baudRate: number): void {
localStorage.setItem(BAUD_RATE_KEY, baudRate.toString())
}
export function getIsMenuOpen(): boolean {
const state = localStorage.getItem(IS_MENU_OPEN_KEY)
if (!state) {
@@ -147,6 +160,19 @@ export function setSkipUpdate(skip: boolean): void {
setWithExpiry(SKIP_UPDATE_KEY, String(skip), expiry)
}
export function clearAllSettings(): void {
localStorage.removeItem(LANGUAGE_KEY)
localStorage.removeItem(VIDEO_DEVICE_ID_KEY)
localStorage.removeItem(VIDEO_RESOLUTION_KEY)
localStorage.removeItem(CUSTOM_RESOLUTION_KEY)
localStorage.removeItem(SERIAL_PORT_KEY)
localStorage.removeItem(IS_MENU_OPEN_KEY)
localStorage.removeItem(MOUSE_STYLE_KEY)
localStorage.removeItem(MOUSE_MODE_KEY)
localStorage.removeItem(MOUSE_SCROLL_DIRECTION_KEY)
localStorage.removeItem(SKIP_UPDATE_KEY)
localStorage.removeItem(MOUSE_SCROLL_INTERVAL_KEY)
localStorage.removeItem(BAUD_RATE_KEY)
export function getMouseJigglerMode(): 'enable' | 'disable' {
const jiggler = localStorage.getItem(MOUSE_JIGGLER_MODE_KEY)
return jiggler && jiggler === 'enable' ? 'enable' : 'disable'