From 36d380a913869b85fbc8217caaf8231a07ab3f4e Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Wed, 3 Jun 2026 23:56:42 +0300 Subject: [PATCH 1/2] peripherals: Add support for display_name --- qtoggleserver/frontend/js/api/peripherals.js | 5 +- .../js/peripherals/add-peripheral-form.js | 8 ++- .../js/peripherals/peripheral-form.js | 13 +++- .../js/peripherals/peripherals-list.js | 7 +- qtoggleserver/peripherals/__init__.py | 3 +- qtoggleserver/peripherals/api/funcs.py | 1 + qtoggleserver/peripherals/api/schema.py | 3 + qtoggleserver/peripherals/peripheral.py | 9 +++ .../peripherals/api/test_funcs.py | 34 ++++++++++ .../qtoggleserver/peripherals/test_init.py | 68 +++++++++++++++++++ .../peripherals/test_peripheral.py | 20 ++++++ 11 files changed, 164 insertions(+), 7 deletions(-) create mode 100644 tests/unit/qtoggleserver/peripherals/test_init.py diff --git a/qtoggleserver/frontend/js/api/peripherals.js b/qtoggleserver/frontend/js/api/peripherals.js index 3f1a62ba..1338d9e2 100644 --- a/qtoggleserver/frontend/js/api/peripherals.js +++ b/qtoggleserver/frontend/js/api/peripherals.js @@ -20,10 +20,11 @@ export function getPeripherals() { * @param {String} driver the driver to use * @param {Object} params peripheral parameters * @param {String} [name] an optional peripheral name + * @param {String} [displayName] an optional peripheral display name * @returns {Promise} */ -export function postPeripherals(driver, params, name = null) { - let data = {driver, name, params} +export function postPeripherals(driver, params, name = null, displayName = '') { + let data = {driver, name, display_name: displayName, params} return BaseAPI.apiCall({method: 'POST', path: '/peripherals', data: data}) } diff --git a/qtoggleserver/frontend/js/peripherals/add-peripheral-form.js b/qtoggleserver/frontend/js/peripherals/add-peripheral-form.js index d43e38e5..cbe36b2d 100644 --- a/qtoggleserver/frontend/js/peripherals/add-peripheral-form.js +++ b/qtoggleserver/frontend/js/peripherals/add-peripheral-form.js @@ -47,6 +47,12 @@ class AddPeripheralForm extends PageForm { } } }), + new TextField({ + name: 'display_name', + label: gettext('Display Name'), + required: false, + maxLength: 64 + }), new TextField({ name: 'driver', label: gettext('Driver'), @@ -82,7 +88,7 @@ class AddPeripheralForm extends PageForm { logger.debug(`adding peripheral of driver "${data.driver}"`) return PeripheralsAPI.postPeripherals( - data.driver, JSON.parse(data.params || '{}'), data.name || null + data.driver, JSON.parse(data.params || '{}'), data.name || null, data.display_name || '' ).then(function (peripheral) { logger.debug(`peripheral "${peripheral.id}" successfully added`) }).catch(function (error) { diff --git a/qtoggleserver/frontend/js/peripherals/peripheral-form.js b/qtoggleserver/frontend/js/peripherals/peripheral-form.js index ba953928..317efe43 100644 --- a/qtoggleserver/frontend/js/peripherals/peripheral-form.js +++ b/qtoggleserver/frontend/js/peripherals/peripheral-form.js @@ -45,6 +45,13 @@ class PeripheralForm extends PageForm { required: false, readonly: true }), + new TextField({ + name: 'display_name', + label: gettext('Display Name'), + required: false, + readonly: true, + maxLength: 64 + }), new TextField({ name: 'driver', label: gettext('Driver'), @@ -103,13 +110,15 @@ class PeripheralForm extends PageForm { this.setIcon(Peripherals.makePeripheralIcon(peripheral)) this.setData({ name: peripheral.name, + display_name: peripheral.display_name || '', driver: peripheral.driver, params: JSON.stringify(peripheral.params || {}, null, 4), force_enabled: peripheral.force_enabled }) - this.setTitle(peripheral.id) + this.setTitle(peripheral.display_name || peripheral.id) if (!peripheral.static) { this.getField('name').setReadonly(false) + this.getField('display_name').setReadonly(false) this.getField('driver').setReadonly(false) this.getField('params').setReadonly(false) this.getField('force_enabled').setReadonly(false) @@ -128,6 +137,7 @@ class PeripheralForm extends PageForm { let payload = { driver: data.driver, name: data.name || null, + display_name: data.display_name || '', force_enabled: data.force_enabled, params: params } @@ -138,6 +148,7 @@ class PeripheralForm extends PageForm { logger.debug(`peripheral "${this._peripheralId}" successfully updated`) this._peripheralId = peripheral.id + this.setTitle(peripheral.display_name || peripheral.id) }.bind(this)).catch(function (error) { diff --git a/qtoggleserver/frontend/js/peripherals/peripherals-list.js b/qtoggleserver/frontend/js/peripherals/peripherals-list.js index 9b047dc1..f7f40ec5 100644 --- a/qtoggleserver/frontend/js/peripherals/peripherals-list.js +++ b/qtoggleserver/frontend/js/peripherals/peripherals-list.js @@ -84,9 +84,12 @@ class PeripheralsList extends PageList { * @returns {qui.lists.ListItem} */ peripheralToItem(peripheral) { + let label = peripheral.display_name || peripheral.id + let subLabel = peripheral.driver.split('.').slice(-1)[0] + return new IconLabelListItem({ - label: peripheral.id, - subLabel: peripheral.driver.split('.').slice(-1)[0], + label: label, + subLabel: subLabel, icon: Peripherals.makePeripheralIcon(peripheral), data: peripheral.id }) diff --git a/qtoggleserver/peripherals/__init__.py b/qtoggleserver/peripherals/__init__.py index 7518c110..9d715ab0 100644 --- a/qtoggleserver/peripherals/__init__.py +++ b/qtoggleserver/peripherals/__init__.py @@ -39,7 +39,7 @@ async def add(peripheral_params: dict[str, Any], static: bool = False) -> Periph params = input_params.pop("params", None) if params is None: # Backward compatibility with older persisted payloads where params were flattened. - params = {k: v for k, v in input_params.items() if k not in {"name", "id", "force_enabled"}} + params = {k: v for k, v in input_params.items() if k not in {"name", "id", "display_name", "force_enabled"}} elif not isinstance(params, dict): raise TypeError("params must be a dictionary") @@ -65,6 +65,7 @@ async def add(peripheral_params: dict[str, Any], static: bool = False) -> Periph "driver": p.get_driver(), "name": p.get_name(), "id": p.get_id(), + "display_name": p.get_display_name(), "force_enabled": p.get_force_enabled(), "params": p.get_params(), } diff --git a/qtoggleserver/peripherals/api/funcs.py b/qtoggleserver/peripherals/api/funcs.py index 384c8055..b295920e 100644 --- a/qtoggleserver/peripherals/api/funcs.py +++ b/qtoggleserver/peripherals/api/funcs.py @@ -102,6 +102,7 @@ async def patch_peripheral( "driver": p.get_driver(), "name": p.get_name(), "id": p.get_id(), + "display_name": p.get_display_name(), "force_enabled": p.get_force_enabled(), "params": p.get_params().copy(), } diff --git a/qtoggleserver/peripherals/api/schema.py b/qtoggleserver/peripherals/api/schema.py index 384dd475..5f608fb8 100644 --- a/qtoggleserver/peripherals/api/schema.py +++ b/qtoggleserver/peripherals/api/schema.py @@ -5,6 +5,7 @@ "type": "string", }, "name": {"oneOf": [{"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$"}, {"type": "null"}]}, + "display_name": {"type": "string", "maxLength": 64}, "id": {"oneOf": [{"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$"}, {"type": "null"}]}, "force_enabled": {"oneOf": [{"type": "boolean"}, {"type": "null"}]}, "params": {"type": "object"}, @@ -22,6 +23,7 @@ "type": "string", }, "name": {"oneOf": [{"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$"}, {"type": "null"}]}, + "display_name": {"type": "string", "maxLength": 64}, "force_enabled": {"oneOf": [{"type": "boolean"}, {"type": "null"}]}, "params": {"type": "object"}, }, @@ -40,6 +42,7 @@ "type": "string", }, "name": {"oneOf": [{"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$"}, {"type": "null"}]}, + "display_name": {"type": "string", "maxLength": 64}, "id": {"oneOf": [{"type": "string", "pattern": "^[a-zA-Z_][a-zA-Z0-9_.-]{0,63}$"}, {"type": "null"}]}, "params": {"type": "object"}, "force_enabled": {"oneOf": [{"type": "boolean"}, {"type": "null"}]}, diff --git a/qtoggleserver/peripherals/peripheral.py b/qtoggleserver/peripherals/peripheral.py index 4962cca2..c58518bd 100644 --- a/qtoggleserver/peripherals/peripheral.py +++ b/qtoggleserver/peripherals/peripheral.py @@ -33,6 +33,7 @@ def __init__( driver: str | None = None, name: str | None = None, id: str | None = None, + display_name: str = "", force_enabled: bool | None = None, static: bool = False, **kwargs, @@ -45,6 +46,7 @@ def __init__( self._driver: str = driver or f"{self.__class__.__module__}.{self.__class__.__name__}" self._name: str | None = name self._id: str = name or id or auto_id # name will always be used as id, if supplied + self._display_name: str = display_name or "" self._force_enabled: bool | None = force_enabled self._static: bool = static @@ -75,6 +77,12 @@ def get_name(self) -> str | None: def get_driver(self) -> str: return self._driver + def get_display_name(self) -> str: + return self._display_name + + def set_display_name(self, display_name: str) -> None: + self._display_name = display_name + def get_params(self) -> dict[str, Any]: return self._params @@ -93,6 +101,7 @@ def to_json(self) -> GenericJSONDict: id=self.get_id(), static=self.is_static(), name=self.get_name(), + display_name=self.get_display_name(), enabled=self.is_enabled(), force_enabled=self.get_force_enabled(), params=self.get_params(), diff --git a/tests/unit/qtoggleserver/peripherals/api/test_funcs.py b/tests/unit/qtoggleserver/peripherals/api/test_funcs.py index ccc34a4d..f3f4945d 100644 --- a/tests/unit/qtoggleserver/peripherals/api/test_funcs.py +++ b/tests/unit/qtoggleserver/peripherals/api/test_funcs.py @@ -11,6 +11,7 @@ "driver": "tests.unit.qtoggleserver.mock.peripherals.MockPeripheral", "params": {"dummy_param": "dummy_value1"}, "name": "peripheral1", + "display_name": "", "id": "peripheral1", "static": False, "enabled": False, @@ -22,6 +23,7 @@ "driver": "tests.unit.qtoggleserver.mock.peripherals.MockPeripheral", "params": {"dummy_param": "dummy_value2"}, "name": "peripheral2", + "display_name": "", "id": "peripheral2", "static": False, "enabled": False, @@ -33,6 +35,7 @@ "driver": "tests.unit.qtoggleserver.mock.peripherals.MockPeripheral", "params": {"dummy_param": "dummy_value3"}, "name": "peripheral3", + "display_name": "", "id": "peripheral3", "static": False, "enabled": False, @@ -166,6 +169,16 @@ async def test_duplicate_peripheral(self, mock_api_request_maker, mock_periphera await peripherals_api_funcs.post_peripherals(request, payload) assert e.value.status == 400 + async def test_display_name_must_be_string(self, mock_api_request_maker, mock_peripheral1): + payload = MOCK_PERIPHERAL2_DATA.copy() + payload.pop("static") + payload["display_name"] = None + request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_ADMIN) + + with pytest.raises(core_api.APIError, match="invalid-field") as e: + await peripherals_api_funcs.post_peripherals(request, payload) + assert e.value.status == 400 + async def test_init_ports_failure_removes_peripheral_and_triggers_remove_event( self, mock_api_request_maker, mock_peripheral1, mocker ): @@ -191,6 +204,27 @@ async def test_init_ports_failure_removes_peripheral_and_triggers_remove_event( spy_remove.assert_called_once_with(mock_peripheral2.get_id()) spy_trigger_remove.assert_called_once_with() + async def test_ok_with_display_name(self, mock_api_request_maker, mock_peripheral1, mocker): + mock_peripheral2 = MockPeripheral( + name=MOCK_PERIPHERAL2_DATA["name"], + display_name="Peripheral Two", + dummy_param=MOCK_PERIPHERAL2_DATA["params"]["dummy_param"], + ) + payload = { + **MOCK_PERIPHERAL2_DATA, + "display_name": "Peripheral Two", + } + payload.pop("static") + request = mock_api_request_maker("POST", "/api/peripherals", access_level=core_api.ACCESS_LEVEL_ADMIN) + + spy_add = mocker.patch("qtoggleserver.peripherals.add", return_value=mock_peripheral2) + spy_init_ports = mocker.patch.object(mock_peripheral2, "init_ports") + result = await peripherals_api_funcs.post_peripherals(request, payload) + + spy_add.assert_called_once_with(payload) + spy_init_ports.assert_called_once_with() + assert result["display_name"] == "Peripheral Two" + async def test_normal_user_permissions(self, mock_api_request_maker, mock_peripheral1): payload = MOCK_PERIPHERAL2_DATA.copy() payload.pop("static") diff --git a/tests/unit/qtoggleserver/peripherals/test_init.py b/tests/unit/qtoggleserver/peripherals/test_init.py new file mode 100644 index 00000000..8ef1e527 --- /dev/null +++ b/tests/unit/qtoggleserver/peripherals/test_init.py @@ -0,0 +1,68 @@ +from qtoggleserver import peripherals, persist + + +class TestAdd: + async def test_persists_display_name(self, mock_persist_driver): + peripheral = await peripherals.add( + { + "driver": "tests.unit.qtoggleserver.mock.peripherals.MockPeripheral", + "dummy_param": "dummy_value", + "name": "peripheral_with_display_name", + "display_name": "Peripheral Display Name", + } + ) + + try: + persisted = await persist.get("peripherals", peripheral.get_id()) + assert persisted is not None + assert persisted["display_name"] == "Peripheral Display Name" + finally: + await peripherals.remove(peripheral.get_id(), persisted_data=True) + + async def test_defaults_display_name_to_empty_string(self, mock_persist_driver): + peripheral = await peripherals.add( + { + "driver": "tests.unit.qtoggleserver.mock.peripherals.MockPeripheral", + "dummy_param": "dummy_value", + "name": "peripheral_without_display_name", + } + ) + + try: + assert peripheral.get_display_name() == "" + persisted = await persist.get("peripherals", peripheral.get_id()) + assert persisted is not None + assert persisted["display_name"] == "" + finally: + await peripherals.remove(peripheral.get_id(), persisted_data=True) + + async def test_legacy_flat_payload_does_not_treat_display_name_as_driver_param(self, mock_persist_driver): + peripheral = await peripherals.add( + { + "driver": "tests.unit.qtoggleserver.mock.peripherals.MockPeripheral", + "dummy_param": "dummy_value", + "name": "legacy_peripheral_with_display_name", + "display_name": "Legacy Display Name", + } + ) + + try: + assert peripheral.get_params() == {"dummy_param": "dummy_value"} + finally: + await peripherals.remove(peripheral.get_id(), persisted_data=True) + + async def test_normalizes_legacy_null_display_name_to_empty_string(self, mock_persist_driver): + peripheral = await peripherals.add( + { + "driver": "tests.unit.qtoggleserver.mock.peripherals.MockPeripheral", + "dummy_param": "dummy_value", + "name": "legacy_null_display_name", + "display_name": None, + } + ) + + try: + assert peripheral.get_display_name() == "" + assert peripheral.to_json()["display_name"] == "" + finally: + await peripherals.remove(peripheral.get_id(), persisted_data=True) diff --git a/tests/unit/qtoggleserver/peripherals/test_peripheral.py b/tests/unit/qtoggleserver/peripherals/test_peripheral.py index b8866cbf..dafc9b43 100644 --- a/tests/unit/qtoggleserver/peripherals/test_peripheral.py +++ b/tests/unit/qtoggleserver/peripherals/test_peripheral.py @@ -68,6 +68,7 @@ def test_to_json_includes_online_flag(self, mocker): assert p.to_json()["enabled"] is False assert p.to_json()["online"] is False assert p.to_json()["force_enabled"] is None + assert p.to_json()["display_name"] == "" p._enabled = True p._online = True @@ -118,6 +119,25 @@ async def test_trigger_update(self, mocker): assert event.get_peripheral() is p +class TestDisplayName: + def test_defaults_to_empty_string(self): + p = MockPeripheral(name="test", dummy_param="v") + + assert p.get_display_name() == "" + assert p.to_json()["display_name"] == "" + + def test_set_display_name_updates_value(self): + p = MockPeripheral(name="test", dummy_param="v") + + p.set_display_name("Test Peripheral") + assert p.get_display_name() == "Test Peripheral" + assert p.to_json()["display_name"] == "Test Peripheral" + + p.set_display_name("") + assert p.get_display_name() == "" + assert p.to_json()["display_name"] == "" + + class TestForceEnabled: def test_defaults_to_none(self): p = MockPeripheral(name="test", dummy_param="v") From a9eb9aabbd066c121e0eed38780dc2723022d599 Mon Sep 17 00:00:00 2001 From: Calin Crisan Date: Thu, 4 Jun 2026 00:28:36 +0300 Subject: [PATCH 2/2] qtoggleserver.conf.sample: Remove peripherals section --- extra/qtoggleserver.conf.sample | 7 ------- 1 file changed, 7 deletions(-) diff --git a/extra/qtoggleserver.conf.sample b/extra/qtoggleserver.conf.sample index f2bb1a77..93ed677c 100644 --- a/extra/qtoggleserver.conf.sample +++ b/extra/qtoggleserver.conf.sample @@ -87,13 +87,6 @@ event_handlers = [ # } ] -peripherals = [ -# { -# driver = "qtoggleserver.my.peripheral.Driver" -# param1 = "my_peripheral" -# } -] - ports = [ # { # driver = "qtoggleserver.my.port.Driver"