From 062707650cd21a1dbcebdd875e5e2f374736edb4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Wed, 4 Feb 2026 19:27:53 +0100 Subject: [PATCH 01/12] Use predefined methods for CLEAR command --- dali/DALI/gear/gear_clear.py | 32 +++++++++----------------------- 1 file changed, 9 insertions(+), 23 deletions(-) diff --git a/dali/DALI/gear/gear_clear.py b/dali/DALI/gear/gear_clear.py index 9f743c0..b7baa38 100644 --- a/dali/DALI/gear/gear_clear.py +++ b/dali/DALI/gear/gear_clear.py @@ -1,15 +1,9 @@ """Command sequence to clear short addresses and group settings.""" import click -from dali_interface import DaliInterface - -from ..system.constants import DaliMax, DaliTimeout -from .gear_action import ( - gear_send_forward_frame, - set_gear_dtr0, - write_gear_frame, - write_gear_frame_and_wait, -) +from ..system.constants import DaliMax +from .gear_action import set_gear_dtr0, write_gear_frame, write_gear_frame_and_wait +from .gear_address import GearAddress from .gear_opcode import GearConfigureCommandOpcode, GearSpecialCommandOpcode @@ -18,17 +12,9 @@ def clear(dali: DaliInterface): write_gear_frame(dali, GearSpecialCommandOpcode.INITIALISE, send_twice=True) set_gear_dtr0(dali, 0xFF) - write_gear_frame_and_wait(dali, GearSpecialCommandOpcode.DTR0, opcode_byte=0xFF) - address = "BC" - gear_send_forward_frame(dali, address, GearConfigureCommandOpcode.SET_SHORT_ADDRESS, True) - dali.get(DaliTimeout.DEFAULT.value) - dali.get(DaliTimeout.DEFAULT.value) - if not dali.data == dali.last_transmit: - click.echo("transmit SET SHORT ADDRESS failed.") - for group in range(DaliMax.GROUP): - gear_send_forward_frame(dali, address, (GearConfigureCommandOpcode.REMOVE_GROUP + group), True) - dali.get(DaliTimeout.DEFAULT.value) - dali.get(DaliTimeout.DEFAULT.value) - if not dali.data == dali.last_transmit: - click.echo(f"transmit REMOVE GROUP {group} failed.") - write_gear_frame(dali, GearSpecialCommandOpcode.TERMINATE) + address = GearAddress() + address.arg("BC") + write_gear_frame_and_wait(dali, address.byte, GearConfigureCommandOpcode.SET_SHORT_ADDRESS, send_twice=True) + for group in range(DaliMax.GEAR_GROUP): + write_gear_frame_and_wait(dali, address.byte, (GearConfigureCommandOpcode.REMOVE_GROUP + group), True) + write_gear_frame(dali, GearSpecialCommandOpcode.TERMINATE) From 7b869c46a68617e0a73767704a95b1ecdc891965 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Wed, 4 Feb 2026 19:28:46 +0100 Subject: [PATCH 02/12] Add device command for QUERY INPUT --- dali/DALI/device/device_query.py | 93 ++++++++++++++++++++++++++++++++ dali/dali_cli.py | 4 ++ 2 files changed, 97 insertions(+) diff --git a/dali/DALI/device/device_query.py b/dali/DALI/device/device_query.py index 68f51ec..f1369ba 100644 --- a/dali/DALI/device/device_query.py +++ b/dali/DALI/device/device_query.py @@ -235,6 +235,23 @@ def scheme(dali: DaliInterface, adr: str, instance: str): click.echo("timeout - NO") +@click.command(name="input", help="Input value. Read the immediate value and iterate the latched values. Present a single value.") +@click.pass_obj +@device_address_option +@instance_address_option +def input(dali: DaliInterface, adr: str, instance: str): + result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE) + value = result + while result is not None: + result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE_LATCH) + if result is not None: + value = (value << 8) | result + if value is not None: + click.echo(f"input value {value} = 0x{value:X} = {value:b}b") + else: + click.echo("timeout - NO") + + @click.command(name="type", help="Instance type.") @click.pass_obj @device_address_option @@ -316,3 +333,79 @@ def primary(dali: DaliInterface, adr: str, instance: str): click.echo(f"primary group {result} = 0x{result:02X} = {result:08b}b") else: click.echo("timeout - NO") + + +"""NOT IMPLEMENTED: IEC62386-103-2022 +11.9.8 QUERY INSTANCE GROUP 1 +11.9.9 QUERY INSTANCE GROUP 2 +""" + + +@click.command(name="scheme", help="Event scheme setting.") +@click.pass_obj +@device_address_option +@instance_address_option +def scheme(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.10 QUERY EVENT SCHEME""" + result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_EVENT_SCHEME) + if result is not None: + click.echo(f"event scheme {result} = 0x{result:02X} = {result:08b}b") + if result == 0: + click.echo("Instance addressing, using instance type and number.") + elif result == 1: + click.echo("Device addressing, using short address and instance type.") + elif result == 2: + click.echo("Device and instance addressing, using short address and instance number.") + elif result == 3: + click.echo("Device group addressing, using device group and instance type.") + elif result == 4: + click.echo("Instance group addressing, using instance group and type.") + else: + click.echo("Invalid event scheme.") + else: + click.echo("timeout - NO") + + +@click.command( + name="input", help="Input value. Read the immediate value and iterate the latched values. Present a single value." +) +@click.pass_obj +@device_address_option +@instance_address_option +def input_value(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.11 QUERY INPUT VALUE, 11.9.12 QUERY INPUT VALUE LATCH""" + result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE) + if result is None: + click.echo("timeout - NO") + else: + value = result + while result is not None: + result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE_LATCH) + if result is not None: + value = (value << 8) | result + click.echo(f"input value {value} = 0x{value:X} = {value:b}b") + + +"""NOT IMPLEMENTED: IEC62386-103-2022 +11.9.13 QUERY EVENT PRIORITY +11.9.14 QUERY FEATURE TYPE +11.9.15 QUERY NEXT FEATURE TYPE +11.9.16 QUERY EVENT FILTER 0-7 +11.9.17 QUERY EVENT FILTER 8-15 +11.9.18 QUERY EVENT FILTER 16-23 +11.9.19 QUERY INSTANCE CONFIGURATION (DTR0) +11.9.20 QUERY AVAILABLE INSTANCE TYPES +""" + + +@click.command(name="short", help="shortAddress.") +@click.pass_obj +def short(dali: DaliInterface) -> None: + """IEC62386-103-2022 11.10.12 QUERY SHORT ADDRESS""" + address = DeviceAddress("SPECIAL") + data = (address.byte << 16) | (DeviceSpecialCommandOpcode.QUERY_SHORT_ADDRESS << 8) + reply = dali.query_reply(DaliFrame(length=DaliFrameLength.DEVICE, data=data)) + if reply.length == DaliFrameLength.BACKWARD: + click.echo(f"short address: {reply.data} = 0x{reply.data:02X} = {reply.data:08b}b") + else: + click.echo("timeout - NO") diff --git a/dali/dali_cli.py b/dali/dali_cli.py index a9097c0..8888b15 100644 --- a/dali/dali_cli.py +++ b/dali/dali_cli.py @@ -233,3 +233,7 @@ def device_query(): device_query.add_command(device_query_cmd.istatus) device_query.add_command(device_query_cmd.enabled) device_query.add_command(device_query_cmd.primary) +device_query.add_command(device_query_cmd.input_value) +device_query.add_command(device_query_cmd.application) +device_query.add_command(device_query_cmd.cycle) +device_query.add_command(device_query_cmd.missing) From 48ce14a435554f7606e93a694ccc408fd488f8bc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Wed, 4 Feb 2026 22:04:34 +0100 Subject: [PATCH 03/12] Fix help text for `gear list` --- dali/DALI/gear/gear_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dali/DALI/gear/gear_list.py b/dali/DALI/gear/gear_list.py index 96a093b..4317f75 100644 --- a/dali/DALI/gear/gear_list.py +++ b/dali/DALI/gear/gear_list.py @@ -8,7 +8,7 @@ from .gear_opcode import GearQueryCommandOpcode -@click.command(name="list", help="List available short addresses.") +@click.command(name="list", help="List used short addresses.") @click.pass_obj def gear_list(context: DaliInterface) -> None: address = GearAddress() From 31da57426d21529a5a6988cf42bc48ce14f8b4ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Wed, 4 Feb 2026 22:12:45 +0100 Subject: [PATCH 04/12] Update year to 2026 --- dali/dali_cli.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dali/dali_cli.py b/dali/dali_cli.py index 8888b15..b8cbddb 100644 --- a/dali/dali_cli.py +++ b/dali/dali_cli.py @@ -60,7 +60,7 @@ def cli( ): # pylint: disable=locally-disabled, too-many-arguments, too-many-positional-arguments """ Command line interface for DALI systems. - SevenLab 2025 + SevenLab 2026 """ if debug: logging.basicConfig(level=logging.DEBUG) From ef1d85f9463acfb22b03a3eaddfbeba550f389b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Thu, 5 Feb 2026 09:54:55 +0100 Subject: [PATCH 05/12] Add QUERY for CYCLE and APPLICATION Add the commands, sort functions according to DALI standard. Documented missing implementation. Added all references to the DALI standard. --- dali/DALI/device/device_query.py | 255 ++++++++++++++++--------------- 1 file changed, 133 insertions(+), 122 deletions(-) diff --git a/dali/DALI/device/device_query.py b/dali/DALI/device/device_query.py index f1369ba..8c8882d 100644 --- a/dali/DALI/device/device_query.py +++ b/dali/DALI/device/device_query.py @@ -25,13 +25,38 @@ ) +@click.command( + name="capabilities", + help="Control device capabilities. The answer shall be a combination of control device capabilities.", +) +@click.pass_obj +@device_address_option +def capabilities(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.2 QUERY DEVICE CAPABILITIES""" + result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_DEVICE_CAPABILITIES) + if result is not None: + click.echo(f"status: {result} = 0x{result:02X} = {result:08b}b") + click.echo("bit : description") + click.echo(f" {(result >> 0 & 0x01)} : applicationControllerPresent") + click.echo(f" {(result >> 1 & 0x01)} : numberOfInstances > 0") + click.echo(f" {(result >> 2 & 0x01)} : applicationControllerAlwaysActive") + click.echo(f" {(result >> 3 & 0x01)} : reserved for IEC 62386-104") + click.echo(f" {(result >> 4 & 0x01)} : reserved for IEC 62386-104") + click.echo(f" {(result >> 5 & 0x01)} : At least one instance supports instanceType configuration") + click.echo(f" {(result >> 6 & 0x01)} : unused") + click.echo(f" {(result >> 7 & 0x01)} : unused") + else: + click.echo("timeout - NO") + + @click.command( name="status", help="Control device status byte. The answer shall be the status, which is formed by a combination of control device properties.", ) @click.pass_obj @device_address_option -def status(dali: DaliInterface, adr): +def status(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.3 QUERY DEVICE STATUS""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_STATUS) if result is not None: click.echo(f"status: {result} = 0x{result:02X} = {result:08b}b") @@ -48,10 +73,18 @@ def status(dali: DaliInterface, adr): click.echo("timeout - NO") +"""NOT IMPLEMENTED: IEC62386-103-2022 +11.6.4 QUERY APPLICATION CONTROLLER ERROR +11.6.5 QUERY INPUT DEVICE ERROR +11.6.6 QUERY MISSING SHORT ADDRESS +""" + + @click.command(name="version", help="Control device version number.") @click.pass_obj @device_address_option -def version(dali: DaliInterface, adr): +def version(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.7 QUERY VERSION NUMBER""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_VERSION_NUMBER) if result is not None: major_version = result >> 2 @@ -61,64 +94,11 @@ def version(dali: DaliInterface, adr): click.echo("timeout - NO") -@click.command(name="extended", help="Control device extended version number for 30X.") -@click.pass_obj -@click.argument("x", type=click.INT) -@device_address_option -def extended(dali: DaliInterface, x, adr): - if 0 <= x <= DaliMax.VALUE: - set_device_dtr0(dali, x) - result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_EXTENDED_VERSION_NUMBER) - if result is not None: - major_version = result >> 2 - minor_version = result & 7 - click.echo(f"version: {result} = 0x{result:02X} = {result:08b}b = {major_version}.{minor_version}") - else: - click.echo("timeout - NO") - else: - raise click.BadParameter(f"needs to be between 0 and {DaliMax.VALUE - 1}.", param_hint="X") - - -@click.command( - name="capabilities", - help="Control device capabilities. The answer shall be a combination of control device capabilities.", -) -@click.pass_obj -@device_address_option -def capabilities(dali: DaliInterface, adr): - result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_DEVICE_CAPABILITIES) - if result is not None: - click.echo(f"status: {result} = 0x{result:02X} = {result:08b}b") - click.echo("bit : description") - click.echo(f" {(result >> 0 & 0x01)} : applicationControllerPresent") - click.echo(f" {(result >> 1 & 0x01)} : numberOfInstances > 0") - click.echo(f" {(result >> 2 & 0x01)} : applicationControllerAlwaysActive") - click.echo(f" {(result >> 3 & 0x01)} : reserved for IEC 62386-104") - click.echo(f" {(result >> 4 & 0x01)} : reserved for IEC 62386-104") - click.echo(f" {(result >> 5 & 0x01)} : At least one instance supports instanceType configuration") - click.echo(f" {(result >> 6 & 0x01)} : unused") - click.echo(f" {(result >> 7 & 0x01)} : unused") - else: - click.echo("timeout - NO") - - -@click.command(name="reset", help="Reset state of all variables.") -@click.pass_obj -@device_address_option -def reset(dali: DaliInterface, adr): - result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_RESET_STATE) - if result is None: - click.echo("timeout - NO") - elif result == DaliMax.MASK: - click.echo("YES") - else: - click.echo(f"{result} = 0x{result:02X} = {result:08b}b") - - @click.command(name="dtr0", help="Content of DTR0.") @click.pass_obj @device_address_option -def dtr0(dali: DaliInterface, adr): +def dtr0(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.8 QUERY CONTENT DTR0""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_CONTENT_DTR0) if result is not None: click.echo(f"DTR0: {result} = 0x{result:02X} = {result:08b}b") @@ -126,10 +106,16 @@ def dtr0(dali: DaliInterface, adr): click.echo("timeout - NO") +"""NOT IMPLEMENTED: IEC62386-103-2022 +11.6.9 QUERY NUMBER OF INSTANCES +""" + + @click.command(name="dtr1", help="Content of DTR1.") @click.pass_obj @device_address_option -def dtr1(dali: DaliInterface, adr): +def dtr1(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.10 QUERY CONTENT DTR1""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_CONTENT_DTR1) if result is not None: click.echo(f"DTR1: {result} = 0x{result:02X} = {result:08b}b") @@ -140,7 +126,8 @@ def dtr1(dali: DaliInterface, adr): @click.command(name="dtr2", help="Content of DTR2.") @click.pass_obj @device_address_option -def dtr2(dali: DaliInterface, adr): +def dtr2(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.11 QUERY CONTENT DTR2""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_CONTENT_DTR2) if result is not None: click.echo(f"DTR2: {result} = 0x{result:02X} = {result:08b}b") @@ -148,22 +135,11 @@ def dtr2(dali: DaliInterface, adr): click.echo("timeout - NO") -@click.command(name="short", help="shortAddress.") -@click.pass_obj -def short(dali: DaliInterface): - address = DeviceAddress("SPECIAL") - data = (address.byte << 16) | (DeviceSpecialCommandOpcode.QUERY_SHORT_ADDRESS << 8) - reply = dali.query_reply(DaliFrame(length=DaliFrameLength.DEVICE, data=data)) - if reply.length == DaliFrameLength.BACKWARD: - click.echo(f"short address: {reply.data} = 0x{reply.data:02X} = {reply.data:08b}b") - else: - click.echo("timeout - NO") - - @click.command(name="random", help="randomAddress.") @click.pass_obj @device_address_option -def random(dali: DaliInterface, adr): +def random(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.12 QUERY RANDOM ADDRESS (H), 11.6.13 QUERY RANDOM ADDRESS (M), 11.6.14 QUERY RANDOM ADDRESS (L)""" random_h = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_RANDOM_ADDRESS_H) random_m = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_RANDOM_ADDRESS_M) random_l = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_RANDOM_ADDRESS_L) @@ -174,10 +150,32 @@ def random(dali: DaliInterface, adr): click.echo(f"random address: 0x{random_address:06X} = " f"{random_address:024b}b = " f"{random_address}") +"""IEC62386-103-2022 11.6.15 READ MEMORY LOCATION (DTR1, DTR0)""" + + +@click.command(name="application", help="Application controller enabled status.") +@click.pass_obj +@device_address_option +def application(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.16 QUERY APPLICATION CONTROLLER ENABLED""" + result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_APPLICATION_CONTROLLER_ENABLED) + if result is not None: + click.echo(f"application: {result} = 0x{result:02X} = {result:08b}b") + else: + click.echo("timeout - NO") + + +"""NOT IMPLEMENTED: IEC62386-103-2022 +11.6.17 QUERY OPERATING MODE +11.6.18 QUERY MANUFACTURER SPECIFIC MODE +""" + + @click.command(name="quiescent", help="Quiescent mode status.") @click.pass_obj @device_address_option -def quiescent(dali: DaliInterface, adr): +def quiescent(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.19 QUERY QUIESCENT MODE""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_QUIESCENT_MODE) if result is not None: click.echo(f"quiescent: {result} = 0x{result:02X} = {result:08b}b") @@ -188,7 +186,8 @@ def quiescent(dali: DaliInterface, adr): @click.command(name="groups", help="Device group settings.") @click.pass_obj @device_address_option -def groups(dali: DaliInterface, adr): +def groups(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.20 QUERY DEVICE GROUPS 0-7, 11.6.21 QUERY DEVICE GROUPS 8-15, 11.6.22 QUERY DEVICE GROUPS 16-23, 11.6.23 QUERY DEVICE GROUPS 24-31""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_DEVICE_GROUPS_0_7) if result is None: click.echo("timeout - NO") @@ -211,53 +210,65 @@ def groups(dali: DaliInterface, adr): click.echo(f"groups 24-31: {result:3} = 0x{result:02X} = {result:08b}b") -@click.command(name="scheme", help="Event scheme setting.") +@click.command(name="cycle", help="Power cycle notification enabled status.") @click.pass_obj @device_address_option -@instance_address_option -def scheme(dali: DaliInterface, adr: str, instance: str): - result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_EVENT_SCHEME) +def cycle(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.24 QUERY POWER CYCLE NOTIFICATION""" + result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_POWER_CYCLE_NOTIFICATION) if result is not None: - click.echo(f"event scheme {result} = 0x{result:02X} = {result:08b}b") - if result == 0: - click.echo("Instance addressing, using instance type and number.") - elif result == 1: - click.echo("Device addressing, using short address and instance type.") - elif result == 2: - click.echo("Device and instance addressing, using short address and instance number.") - elif result == 3: - click.echo("Device group addressing, using device group and instance type.") - elif result == 4: - click.echo("Instance group addressing, using instance group and type.") - else: - click.echo("Invalid event scheme.") + click.echo(f"power cycle notification: {result} = 0x{result:02X} = {result:08b}b") else: click.echo("timeout - NO") -@click.command(name="input", help="Input value. Read the immediate value and iterate the latched values. Present a single value.") +@click.command(name="extended", help="Control device extended version number for 30X.") @click.pass_obj +@click.argument("x", type=click.INT) @device_address_option -@instance_address_option -def input(dali: DaliInterface, adr: str, instance: str): - result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE) - value = result - while result is not None: - result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE_LATCH) +def extended(dali: DaliInterface, x: int, adr: str) -> None: + """IEC62386-103-2022 11.6.25 QUERY EXTENDED VERSION NUMBER(DTR0)""" + if 0 <= x <= DaliMax.VALUE: + set_device_dtr0(dali, x) + result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_EXTENDED_VERSION_NUMBER) if result is not None: - value = (value << 8) | result - if value is not None: - click.echo(f"input value {value} = 0x{value:X} = {value:b}b") + major_version = result >> 2 + minor_version = result & 7 + click.echo(f"version: {result} = 0x{result:02X} = {result:08b}b = {major_version}.{minor_version}") + else: + click.echo("timeout - NO") else: + raise click.BadParameter(f"needs to be between 0 and {DaliMax.VALUE - 1}.", param_hint="X") + + +@click.command(name="reset", help="Reset state of all variables.") +@click.pass_obj +@device_address_option +def reset(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.26 QUERY RESET STATE""" + result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_RESET_STATE) + if result is None: click.echo("timeout - NO") + elif result == DaliMax.MASK: + click.echo("YES") + else: + click.echo(f"{result} = 0x{result:02X} = {result:08b}b") + + +"""NOT IMPLEMENTED: IEC62386-103-2022 +11.6.27 QUERY APPLICATION CONTROLLER ALWAYS ACTIVE +11.6.28 QUERY FEATURE TYPE +11.6.29 QUERY NEXT FEATURE TYPE +11.6.30 QUERY EVENT PRIORITY +""" @click.command(name="type", help="Instance type.") @click.pass_obj @device_address_option @instance_address_option -def itype(dali: DaliInterface, adr: str, instance: str): - """11.9.2 QUERY INSTANCE TYPE""" +def itype(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.2 QUERY INSTANCE TYPE""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INSTANCE_TYPE) if result is not None: click.echo(f"type {result} = 0x{result:02X} = {result:08b}b") @@ -269,8 +280,8 @@ def itype(dali: DaliInterface, adr: str, instance: str): @click.pass_obj @device_address_option @instance_address_option -def resolution(dali: DaliInterface, adr: str, instance: str): - """11.9.3 QUERY RESOLUTION""" +def resolution(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.3 QUERY RESOLUTION""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_RESOLUTION) if result is not None: click.echo(f"resolution {result} = 0x{result:02X} = {result:08b}b") @@ -282,8 +293,8 @@ def resolution(dali: DaliInterface, adr: str, instance: str): @click.pass_obj @device_address_option @instance_address_option -def error(dali: DaliInterface, adr: str, instance: str): - """11.9.4 QUERY INSTANCE ERROR""" +def error(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.4 QUERY INSTANCE ERROR""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INSTANCE_ERROR) if result is not None: click.echo(f"error {result} = 0x{result:02X} = {result:08b}b") @@ -295,8 +306,8 @@ def error(dali: DaliInterface, adr: str, instance: str): @click.pass_obj @device_address_option @instance_address_option -def istatus(dali: DaliInterface, adr: str, instance: str): - """11.9.5 QUERY INSTANCE STATUS""" +def istatus(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.5 QUERY INSTANCE STATUS""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INSTANCE_STATUS) if result is not None: click.echo(f"status: {result} = 0x{result:02X} = {result:08b}b") @@ -311,8 +322,8 @@ def istatus(dali: DaliInterface, adr: str, instance: str): @click.pass_obj @device_address_option @instance_address_option -def enabled(dali: DaliInterface, adr: str, instance: str): - """11.9.6 QUERY INSTANCE ENABLED""" +def enabled(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.6 QUERY INSTANCE ENABLED""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INSTANCE_ENABLED) if result is None: click.echo("timeout - NO") @@ -326,8 +337,8 @@ def enabled(dali: DaliInterface, adr: str, instance: str): @click.pass_obj @device_address_option @instance_address_option -def primary(dali: DaliInterface, adr: str, instance: str): - """11.9.7 QUERY PRIMARY INSTANCE GROUP""" +def primary(dali: DaliInterface, adr: str, instance: str) -> None: + """IEC62386-103-2022 11.9.7 QUERY PRIMARY INSTANCE GROUP""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_PRIMARY_INSTANCE_GROUP) if result is not None: click.echo(f"primary group {result} = 0x{result:02X} = {result:08b}b") @@ -372,18 +383,18 @@ def scheme(dali: DaliInterface, adr: str, instance: str) -> None: @click.pass_obj @device_address_option @instance_address_option -def input_value(dali: DaliInterface, adr: str, instance: str) -> None: +def input(dali: DaliInterface, adr: str, instance: str) -> None: """IEC62386-103-2022 11.9.11 QUERY INPUT VALUE, 11.9.12 QUERY INPUT VALUE LATCH""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE) - if result is None: - click.echo("timeout - NO") - else: - value = result - while result is not None: - result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE_LATCH) - if result is not None: - value = (value << 8) | result + value = result + while result is not None: + result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE_LATCH) + if result is not None: + value = (value << 8) | result + if value is not None: click.echo(f"input value {value} = 0x{value:X} = {value:b}b") + else: + click.echo("timeout - NO") """NOT IMPLEMENTED: IEC62386-103-2022 From 09b35c42215ee6ffe3cc85dd0eb9867858715651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Sat, 7 Feb 2026 12:03:19 +0100 Subject: [PATCH 06/12] Add instructions for USB HID support --- .github/megalinter/cspell.json | 5 ++++- 99-lunatone-dali.rules | 26 ++++++++++++++++++++++++++ README.md | 26 ++++++++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 99-lunatone-dali.rules diff --git a/.github/megalinter/cspell.json b/.github/megalinter/cspell.json index 37d9467..6101590 100644 --- a/.github/megalinter/cspell.json +++ b/.github/megalinter/cspell.json @@ -29,6 +29,7 @@ "Lunatone", "membank", "overvoltage", + "plugdev", "portname", "pytest", "randomise", @@ -39,13 +40,15 @@ "setuptools", "Sven", "testframe", + "udevadm", "undervoltage", "ungroup", "usec", + "usermod", "venv", "Vrms" ], "language": "en", "noConfigSearch": true, "version": "0.2" -} +} \ No newline at end of file diff --git a/99-lunatone-dali.rules b/99-lunatone-dali.rules new file mode 100644 index 0000000..1c0fbe3 --- /dev/null +++ b/99-lunatone-dali.rules @@ -0,0 +1,26 @@ +# Lunatone DALI USB udev rules +# +# Instructions: +# Copy this file to the udev rules +# sudo cp 99-lunatone-dali.rules /etc/udev/rules.d/ +# Reload the udev rules +# sudo udevadm control --reload-rules +# +# +# This file grants everyone read/write access. If you want to restrict access, +# you should modify MODE to "0660". You can then grant access to specific user +# accounts by adding them to the plugdev group. To grant yourself permission: +# +# sudo usermod -a -G plugdev `whoami` +# +# You will have to log out and then back in for the group change to take effect. +# To grant permission to a specific {user}: +# +# sudo usermod -a -G plugdev {user} +# +# replacing {user} with the actual username. +# +# + +# Lunatone DALI USB +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTRS{idVendor}=="17b5", ATTRS{idProduct}=="0020", MODE="0666", GROUP="plugdev" diff --git a/README.md b/README.md index 8377a42..89a979c 100644 --- a/README.md +++ b/README.md @@ -82,19 +82,41 @@ bit : description ## Tests This script: + ```bash ./test_dali_cli.sh ``` + prepares a virtual environment, and then runs the tests. Optionally you can add `--log-level=debug` for more detailed logging. - ## Install from github ```shell -git clone git@github.com:SvenHaedrich/dali_cli.git +git clone https://github.com/SvenHaedrich/dali_cli.git cd dali_cli python3 -m venv --prompt dali venv source venv/bin/activate python3 -m pip install -e . ``` + +## HID-USB Support + +For the Lunatone USB adapter you need to copy the file `99-lunatone-dali.rules` into +the `udev` folder and reload the `udev` rules. + +```shell + sudo cp 99-lunatone-dali.rules /etc/udev/rules.d/ + sudo udevadm control --reload-rules +``` + +This file grants everyone read/write access. If you want to restrict access, +you should modify `MODE` to `0660`. You can then grant access to specific user +accounts by adding them to the plugdev group. Note that some Linux distributions always +require a per user permission. To grant permission to user named ``: + +```shell + sudo usermod -a -G plugdev +``` + +You will have to log out and then back in for the group change to take effect. From e428b8dfb20e75a87b326bed8dab69461691ac65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Sat, 7 Feb 2026 12:24:23 +0100 Subject: [PATCH 07/12] Fix mypy findings And some refactorings. --- dali/DALI/device/device_action.py | 7 ------- dali/DALI/device/device_address.py | 8 ++------ dali/DALI/device/device_dump.py | 9 ++++++++- dali/DALI/system/membank_annotation.py | 2 +- 4 files changed, 11 insertions(+), 15 deletions(-) diff --git a/dali/DALI/device/device_action.py b/dali/DALI/device/device_action.py index b61c48a..2e99952 100644 --- a/dali/DALI/device/device_action.py +++ b/dali/DALI/device/device_action.py @@ -4,7 +4,6 @@ import click from dali_interface import DaliFrame, DaliInterface -from typeguard import typechecked from ..system.constants import DaliFrameLength from .device_address import DeviceAddress, InstanceAddress @@ -13,7 +12,6 @@ logger = logging.getLogger(__name__) -@typechecked def query_instance_value(dali: DaliInterface, adr_parameter: str, instance_parameter: str, opcode: int) -> int | None: """Query a value from a control device instance""" address = DeviceAddress(adr_parameter) @@ -29,13 +27,11 @@ def query_instance_value(dali: DaliInterface, adr_parameter: str, instance_param return None -@typechecked def query_device_value(dali: DaliInterface, adr_parameter: str, opcode: int) -> int | None: """Query a value from a control device""" return query_instance_value(dali, adr_parameter, "DEVICE", opcode) -@typechecked def set_device_dtr0(dali: DaliInterface, value: int) -> None: """Set control device data transfer register 0""" logger.debug("set_device_dtr0") @@ -44,7 +40,6 @@ def set_device_dtr0(dali: DaliInterface, value: int) -> None: dali.transmit(DaliFrame(length=DaliFrameLength.DEVICE, data=command), block=True) -@typechecked def set_device_dtr1(dali: DaliInterface, value: int) -> None: """Set control device data transfer register 1""" logger.debug("set_device_dtr1") @@ -53,7 +48,6 @@ def set_device_dtr1(dali: DaliInterface, value: int) -> None: dali.transmit(DaliFrame(length=DaliFrameLength.DEVICE, data=command), block=True) -@typechecked def write_device_frame( dali: DaliInterface, address_byte: int = 0, @@ -70,7 +64,6 @@ def write_device_frame( ) -@typechecked def set_device_dtr2_dtr1(dali: DaliInterface, dtr2: int, dtr1: int) -> None: """Set control device data transfer registers 0 and 1 simultaneously""" write_device_frame(dali, DeviceSpecialCommandOpcode.DTR2_DTR1, dtr2, dtr1) diff --git a/dali/DALI/device/device_address.py b/dali/DALI/device/device_address.py index 39d8d1a..ce9c7fe 100644 --- a/dali/DALI/device/device_address.py +++ b/dali/DALI/device/device_address.py @@ -2,8 +2,6 @@ from enum import Enum, unique -from typeguard import typechecked - from ..system.constants import DaliMax @@ -19,11 +17,10 @@ class DeviceAddressing(Enum): SPECIAL = 5 -@typechecked class DeviceAddress: """Interfaces between DALI address mode and frame codes""" - def __init__(self, mode="BC") -> None: + def __init__(self, mode: str ="BC") -> None: self.mode = DeviceAddressing.INVALID self.byte = 0 if mode == "SPECIAL": @@ -118,11 +115,10 @@ class InstanceAddressing(Enum): DEVICE = 11 -@typechecked class InstanceAddress: """Interfaces between DALI addressing representation and command addressing format""" - def __init__(self, mode="DEVICE") -> None: + def __init__(self, mode: str="DEVICE") -> None: self.mode = InstanceAddressing.INVALID self.byte = 0 if self.arg(mode): diff --git a/dali/DALI/device/device_dump.py b/dali/DALI/device/device_dump.py index 09d75e3..3521b5f 100644 --- a/dali/DALI/device/device_dump.py +++ b/dali/DALI/device/device_dump.py @@ -16,7 +16,14 @@ default="BC", help="Address, can be a short address (0..63) or group address (G0..G15).", ) -def dump(dali: DaliInterface, adr, bank): +def dump(dali: DaliInterface, adr: str, bank: int) -> None: + """ + Dump the contents of a memory bank + + :param dali: DALI interface to use + :param adr: Address information + :param bank: Memory bank number + """ set_device_dtr1(dali, bank) set_device_dtr0(dali, 0) last_accessible_location = query_device_value(dali, adr, DeviceQueryCommandOpcode.READ_MEMORY) diff --git a/dali/DALI/system/membank_annotation.py b/dali/DALI/system/membank_annotation.py index 7986682..68a468a 100644 --- a/dali/DALI/system/membank_annotation.py +++ b/dali/DALI/system/membank_annotation.py @@ -148,7 +148,7 @@ class MemoryBankItemWithAnnotation: } @staticmethod - def show(bank: int, location: int, value: int | None): + def show(bank: int, location: int, value: int | None) -> None: if bank != 0 and location == 0: annotation = MemoryBankItemWithAnnotation.annotations[(0, 0)] elif bank != 0 and location == 1: From dd68f9e82704f3c821736f1b924ebe7b0e8c818d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Wed, 4 Mar 2026 14:18:30 +0100 Subject: [PATCH 08/12] Update to megalinter v9 Fix stuff to satisfy the requirements. --- .github/workflows/mega-linter.yml | 3 +- README.md | 2 +- dali/DALI/device/device_address.py | 4 +- dali/DALI/device/device_query.py | 92 ++++++++++++++++-------------- dali/DALI/gear/gear_address.py | 2 +- dali/DALI/gear/gear_clear.py | 2 + dali/DALI/system/connection.py | 8 +-- pyproject.toml | 8 ++- tests/cli/test_device_configure.py | 1 - 9 files changed, 65 insertions(+), 57 deletions(-) diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index d42ee17..76d489e 100644 --- a/.github/workflows/mega-linter.yml +++ b/.github/workflows/mega-linter.yml @@ -37,10 +37,9 @@ jobs: # MegaLinter - name: MegaLinter - uses: oxsecurity/megalinter@v8.4.2 + uses: oxsecurity/megalinter@v9 id: ml env: VALIDATE_ALL_CODEBASE: true GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MEGALINTER_CONFIG: .github/megalinter/mega-linter.yml - diff --git a/README.md b/README.md index 89a979c..c1307b8 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ Command line interface to control a DALI system. > [!IMPORTANT] > This application is still in development. Use with caution. Give ample feedback. -DALI is the digital addressable lighting interface as described [here](https://www.dali-alliance.org). +DALI is the digital addressable lighting interface as described by the [DALI-Alliance](https://www.dali-alliance.org). ## Usage diff --git a/dali/DALI/device/device_address.py b/dali/DALI/device/device_address.py index ce9c7fe..01b8e4b 100644 --- a/dali/DALI/device/device_address.py +++ b/dali/DALI/device/device_address.py @@ -20,7 +20,7 @@ class DeviceAddressing(Enum): class DeviceAddress: """Interfaces between DALI address mode and frame codes""" - def __init__(self, mode: str ="BC") -> None: + def __init__(self, mode: str = "BC") -> None: self.mode = DeviceAddressing.INVALID self.byte = 0 if mode == "SPECIAL": @@ -118,7 +118,7 @@ class InstanceAddressing(Enum): class InstanceAddress: """Interfaces between DALI addressing representation and command addressing format""" - def __init__(self, mode: str="DEVICE") -> None: + def __init__(self, mode: str = "DEVICE") -> None: self.mode = InstanceAddressing.INVALID self.byte = 0 if self.arg(mode): diff --git a/dali/DALI/device/device_query.py b/dali/DALI/device/device_query.py index 8c8882d..5a1a72f 100644 --- a/dali/DALI/device/device_query.py +++ b/dali/DALI/device/device_query.py @@ -73,11 +73,23 @@ def status(dali: DaliInterface, adr: str) -> None: click.echo("timeout - NO") -"""NOT IMPLEMENTED: IEC62386-103-2022 -11.6.4 QUERY APPLICATION CONTROLLER ERROR -11.6.5 QUERY INPUT DEVICE ERROR -11.6.6 QUERY MISSING SHORT ADDRESS -""" +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.6.4 QUERY APPLICATION CONTROLLER ERROR +# 11.6.5 QUERY INPUT DEVICE ERROR + + +@click.command(name="missing", help="missing short address.") +@click.pass_obj +@device_address_option +def missing(dali: DaliInterface, adr: str) -> None: + """IEC62386-103-2022 11.6.6 QUERY MISSING SHORT ADDRESS""" + result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_MISSING_SHORT_ADDRESS) + if result == DaliMax.MASK: + click.echo(f"missing: {result} = 0x{result:02X} = {result:08b}b = YES") + elif result is None: + click.echo("timeout - NO") + else: + click.echo(f"missing: {result} = 0x{result:02X} = {result:08b}b = undefined") @click.command(name="version", help="Control device version number.") @@ -106,9 +118,8 @@ def dtr0(dali: DaliInterface, adr: str) -> None: click.echo("timeout - NO") -"""NOT IMPLEMENTED: IEC62386-103-2022 -11.6.9 QUERY NUMBER OF INSTANCES -""" +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.6.9 QUERY NUMBER OF INSTANCES @click.command(name="dtr1", help="Content of DTR1.") @@ -150,7 +161,8 @@ def random(dali: DaliInterface, adr: str) -> None: click.echo(f"random address: 0x{random_address:06X} = " f"{random_address:024b}b = " f"{random_address}") -"""IEC62386-103-2022 11.6.15 READ MEMORY LOCATION (DTR1, DTR0)""" +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.6.15 READ MEMORY LOCATION (DTR1, DTR0) @click.command(name="application", help="Application controller enabled status.") @@ -165,10 +177,9 @@ def application(dali: DaliInterface, adr: str) -> None: click.echo("timeout - NO") -"""NOT IMPLEMENTED: IEC62386-103-2022 -11.6.17 QUERY OPERATING MODE -11.6.18 QUERY MANUFACTURER SPECIFIC MODE -""" +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.6.17 QUERY OPERATING MODE +# 11.6.18 QUERY MANUFACTURER SPECIFIC MODE @click.command(name="quiescent", help="Quiescent mode status.") @@ -255,12 +266,11 @@ def reset(dali: DaliInterface, adr: str) -> None: click.echo(f"{result} = 0x{result:02X} = {result:08b}b") -"""NOT IMPLEMENTED: IEC62386-103-2022 -11.6.27 QUERY APPLICATION CONTROLLER ALWAYS ACTIVE -11.6.28 QUERY FEATURE TYPE -11.6.29 QUERY NEXT FEATURE TYPE -11.6.30 QUERY EVENT PRIORITY -""" +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.6.27 QUERY APPLICATION CONTROLLER ALWAYS ACTIVE +# 11.6.28 QUERY FEATURE TYPE +# 11.6.29 QUERY NEXT FEATURE TYPE +# 11.6.30 QUERY EVENT PRIORITY @click.command(name="type", help="Instance type.") @@ -346,10 +356,9 @@ def primary(dali: DaliInterface, adr: str, instance: str) -> None: click.echo("timeout - NO") -"""NOT IMPLEMENTED: IEC62386-103-2022 -11.9.8 QUERY INSTANCE GROUP 1 -11.9.9 QUERY INSTANCE GROUP 2 -""" +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.9.8 QUERY INSTANCE GROUP 1 +# 11.9.9 QUERY INSTANCE GROUP 2 @click.command(name="scheme", help="Event scheme setting.") @@ -383,30 +392,29 @@ def scheme(dali: DaliInterface, adr: str, instance: str) -> None: @click.pass_obj @device_address_option @instance_address_option -def input(dali: DaliInterface, adr: str, instance: str) -> None: +def input_value(dali: DaliInterface, adr: str, instance: str) -> None: """IEC62386-103-2022 11.9.11 QUERY INPUT VALUE, 11.9.12 QUERY INPUT VALUE LATCH""" result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE) - value = result - while result is not None: - result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE_LATCH) - if result is not None: - value = (value << 8) | result - if value is not None: - click.echo(f"input value {value} = 0x{value:X} = {value:b}b") - else: + if result is None: click.echo("timeout - NO") + else: + value = result + while result is not None: + result = query_instance_value(dali, adr, instance, DeviceInstanceQueryOpcode.QUERY_INPUT_VALUE_LATCH) + if result is not None: + value = (value << 8) | result + click.echo(f"input value {value} = 0x{value:X} = {value:b}b") -"""NOT IMPLEMENTED: IEC62386-103-2022 -11.9.13 QUERY EVENT PRIORITY -11.9.14 QUERY FEATURE TYPE -11.9.15 QUERY NEXT FEATURE TYPE -11.9.16 QUERY EVENT FILTER 0-7 -11.9.17 QUERY EVENT FILTER 8-15 -11.9.18 QUERY EVENT FILTER 16-23 -11.9.19 QUERY INSTANCE CONFIGURATION (DTR0) -11.9.20 QUERY AVAILABLE INSTANCE TYPES -""" +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.9.13 QUERY EVENT PRIORITY +# 11.9.14 QUERY FEATURE TYPE +# 11.9.15 QUERY NEXT FEATURE TYPE +# 11.9.16 QUERY EVENT FILTER 0-7 +# 11.9.17 QUERY EVENT FILTER 8-15 +# 11.9.18 QUERY EVENT FILTER 16-23 +# 11.9.19 QUERY INSTANCE CONFIGURATION (DTR0) +# 11.9.20 QUERY AVAILABLE INSTANCE TYPES @click.command(name="short", help="shortAddress.") diff --git a/dali/DALI/gear/gear_address.py b/dali/DALI/gear/gear_address.py index 4675ac9..309f186 100644 --- a/dali/DALI/gear/gear_address.py +++ b/dali/DALI/gear/gear_address.py @@ -71,7 +71,7 @@ def arg(self, text: str = "") -> bool: return True return False - def __str__(self) -> str: + def __str__(self): # -> str if self.mode == DaliAddressingMode.SHORT: short_address = (self.byte >> 1) & 0x3F return f"G{short_address:02}" diff --git a/dali/DALI/gear/gear_clear.py b/dali/DALI/gear/gear_clear.py index b7baa38..b45513b 100644 --- a/dali/DALI/gear/gear_clear.py +++ b/dali/DALI/gear/gear_clear.py @@ -1,6 +1,8 @@ """Command sequence to clear short addresses and group settings.""" import click +from dali_interface import DaliInterface + from ..system.constants import DaliMax from .gear_action import set_gear_dtr0, write_gear_frame, write_gear_frame_and_wait from .gear_address import GearAddress diff --git a/dali/DALI/system/connection.py b/dali/DALI/system/connection.py index f650fc8..f0a4dd2 100644 --- a/dali/DALI/system/connection.py +++ b/dali/DALI/system/connection.py @@ -3,13 +3,7 @@ from contextlib import contextmanager import click -from dali_interface import ( - DaliFrame, - DaliInterface, - DaliMock, - DaliSerial, - DaliUsb, -) +from dali_interface import DaliFrame, DaliInterface, DaliMock, DaliSerial, DaliUsb class DaliNone(DaliInterface): diff --git a/pyproject.toml b/pyproject.toml index b779e1f..cd3b78c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ authors = [ {name = "Sven Hädrich", email="sven.haedrich@sevenlab.de"}, ] dependencies = [ - "click", "dali-interface @ git+https://github.com/sevenlab-de/dali_interface.git", "pyserial", "pyusb", "termcolor", "typeguard", + "click", "dali-interface", "pyserial", "pyusb", "termcolor", "typeguard", ] [project.scripts] @@ -26,3 +26,9 @@ line-length = 120 [tool.isort] profile = "black" no_sections = true + +[tool.pyright] +include = ["dali"] +typeCheckingMode = "strict" + +pythonVersion = "3.12" diff --git a/tests/cli/test_device_configure.py b/tests/cli/test_device_configure.py index 8787454..17bd14a 100644 --- a/tests/cli/test_device_configure.py +++ b/tests/cli/test_device_configure.py @@ -2,7 +2,6 @@ import pytest from click.testing import CliRunner - from dali.DALI.system.constants import DaliMax from dali.dali_cli import cli From aafd1128e2dac412c8364c290fed7f6f13a19755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Sun, 22 Mar 2026 18:08:00 +0100 Subject: [PATCH 09/12] Fix review findings --- .github/workflows/mega-linter.yml | 2 +- 99-lunatone-dali.rules | 12 ++++++---- dali/DALI/device/device_query.py | 4 ++-- dali/DALI/gear/gear_address.py | 2 +- dali/DALI/gear/gear_clear.py | 4 +++- dali/DALI/gear/gear_query.py | 12 ++++++++-- tests/cli/test_device_query.py | 10 +++++--- tests/cli/test_gear_query.py | 39 +++++++++++++++++++++++++++++++ 8 files changed, 70 insertions(+), 15 deletions(-) create mode 100644 tests/cli/test_gear_query.py diff --git a/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index 76d489e..b6c26e2 100644 --- a/.github/workflows/mega-linter.yml +++ b/.github/workflows/mega-linter.yml @@ -37,7 +37,7 @@ jobs: # MegaLinter - name: MegaLinter - uses: oxsecurity/megalinter@v9 + uses: oxsecurity/megalinter@v9.4.0 id: ml env: VALIDATE_ALL_CODEBASE: true diff --git a/99-lunatone-dali.rules b/99-lunatone-dali.rules index 1c0fbe3..222a3df 100644 --- a/99-lunatone-dali.rules +++ b/99-lunatone-dali.rules @@ -7,9 +7,10 @@ # sudo udevadm control --reload-rules # # -# This file grants everyone read/write access. If you want to restrict access, -# you should modify MODE to "0660". You can then grant access to specific user -# accounts by adding them to the plugdev group. To grant yourself permission: +# By default, this file restricts the access to root and members of the `plugdev` +# group via MODE="0660". You can grant access to specific user accounts by +# adding them to the `plugdev` group. +# To grant yourself permission: # # sudo usermod -a -G plugdev `whoami` # @@ -19,8 +20,9 @@ # sudo usermod -a -G plugdev {user} # # replacing {user} with the actual username. -# +# If you really want everyone to have read/write access you can change MODE +# below to "0666". This is less secure. # # Lunatone DALI USB -SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTRS{idVendor}=="17b5", ATTRS{idProduct}=="0020", MODE="0666", GROUP="plugdev" +SUBSYSTEM=="usb", ENV{DEVTYPE}=="usb_device", ATTRS{idVendor}=="17b5", ATTRS{idProduct}=="0020", MODE="0660", GROUP="plugdev" diff --git a/dali/DALI/device/device_query.py b/dali/DALI/device/device_query.py index 5a1a72f..d1357f2 100644 --- a/dali/DALI/device/device_query.py +++ b/dali/DALI/device/device_query.py @@ -35,7 +35,7 @@ def capabilities(dali: DaliInterface, adr: str) -> None: """IEC62386-103-2022 11.6.2 QUERY DEVICE CAPABILITIES""" result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_DEVICE_CAPABILITIES) if result is not None: - click.echo(f"status: {result} = 0x{result:02X} = {result:08b}b") + click.echo(f"capabilities: {result} = 0x{result:02X} = {result:08b}b") click.echo("bit : description") click.echo(f" {(result >> 0 & 0x01)} : applicationControllerPresent") click.echo(f" {(result >> 1 & 0x01)} : numberOfInstances > 0") @@ -239,7 +239,7 @@ def cycle(dali: DaliInterface, adr: str) -> None: @device_address_option def extended(dali: DaliInterface, x: int, adr: str) -> None: """IEC62386-103-2022 11.6.25 QUERY EXTENDED VERSION NUMBER(DTR0)""" - if 0 <= x <= DaliMax.VALUE: + if 0 <= x < DaliMax.VALUE: set_device_dtr0(dali, x) result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_EXTENDED_VERSION_NUMBER) if result is not None: diff --git a/dali/DALI/gear/gear_address.py b/dali/DALI/gear/gear_address.py index 309f186..4675ac9 100644 --- a/dali/DALI/gear/gear_address.py +++ b/dali/DALI/gear/gear_address.py @@ -71,7 +71,7 @@ def arg(self, text: str = "") -> bool: return True return False - def __str__(self): # -> str + def __str__(self) -> str: if self.mode == DaliAddressingMode.SHORT: short_address = (self.byte >> 1) & 0x3F return f"G{short_address:02}" diff --git a/dali/DALI/gear/gear_clear.py b/dali/DALI/gear/gear_clear.py index b45513b..597a8bb 100644 --- a/dali/DALI/gear/gear_clear.py +++ b/dali/DALI/gear/gear_clear.py @@ -18,5 +18,7 @@ def clear(dali: DaliInterface): address.arg("BC") write_gear_frame_and_wait(dali, address.byte, GearConfigureCommandOpcode.SET_SHORT_ADDRESS, send_twice=True) for group in range(DaliMax.GEAR_GROUP): - write_gear_frame_and_wait(dali, address.byte, (GearConfigureCommandOpcode.REMOVE_GROUP + group), True) + write_gear_frame_and_wait( + dali, address.byte, (GearConfigureCommandOpcode.REMOVE_GROUP + group), send_twice=True + ) write_gear_frame(dali, GearSpecialCommandOpcode.TERMINATE) diff --git a/dali/DALI/gear/gear_query.py b/dali/DALI/gear/gear_query.py index 7f6bc81..a8b2c7a 100644 --- a/dali/DALI/gear/gear_query.py +++ b/dali/DALI/gear/gear_query.py @@ -4,6 +4,7 @@ from dali.DALI.device.device_query import device_address_option from dali_interface import DaliInterface +from ..system.constants import DaliMax from .gear_action import query_gear_and_display_reply, query_gear_value from .gear_opcode import GearQueryCommandOpcode @@ -72,8 +73,15 @@ def reset(dali: DaliInterface, adr): @click.command(name="missing", help="Missing short address.") @click.pass_obj @gear_address_option -def missing(dali: DaliInterface, adr): - query_gear_and_display_reply(dali, adr, GearQueryCommandOpcode.MISSING_SHORT_ADDRESS) +def missing(dali: DaliInterface, adr: str) -> None: + """IEC62386-102-2022 11.5.4 QUERY MISSING SHORT ADDRESS""" + result = query_gear_value(dali, adr, GearQueryCommandOpcode.MISSING_SHORT_ADDRESS) + if result == DaliMax.MASK: + click.echo(f"missing: {result} = 0x{result:02X} = {result:08b}b = YES") + elif result is None: + click.echo("timeout - NO") + else: + click.echo(f"missing: {result} = 0x{result:02X} = {result:08b}b = undefined") @click.command(name="version", help="Version number.") diff --git a/tests/cli/test_device_query.py b/tests/cli/test_device_query.py index f7f24ca..664777c 100644 --- a/tests/cli/test_device_query.py +++ b/tests/cli/test_device_query.py @@ -9,13 +9,16 @@ @pytest.mark.parametrize( "command, opcode", [ - ("capabilities", 0x46), + ("status", 0x30), + ("missing", 0x33), + ("version", 0x34), ("dtr0", 0x36), ("dtr1", 0x37), ("dtr2", 0x38), + ("application", 0x3D), ("quiescent", 0x40), - ("status", 0x30), - ("version", 0x34), + ("cycle", 0x45), + ("capabilities", 0x46), ], ) def test_query_device_command(command, opcode): @@ -53,6 +56,7 @@ def test_query_device_command(command, opcode): ("primary", 0x88), ("resolution", 0x81), ("scheme", 0x8B), + ("input", 0x8C), ], ) def test_query_device_instance_command(command, opcode): diff --git a/tests/cli/test_gear_query.py b/tests/cli/test_gear_query.py new file mode 100644 index 0000000..8db3466 --- /dev/null +++ b/tests/cli/test_gear_query.py @@ -0,0 +1,39 @@ +"""Test control gear query commands.""" + +import pytest +from click.testing import CliRunner +from dali.DALI.system.constants import DaliMax +from dali.dali_cli import cli + + +@pytest.mark.parametrize( + "command, opcode", + [ + ("status", 0x90), + ("missing", 0x96), + ], +) +def test_query_device_command(command, opcode): + runner = CliRunner() + # test broadcast + result = runner.invoke(cli, ["--mock", "gear", "query", command]) + expect = 0xFF00 + opcode + assert result.exit_code == 0 + assert result.output == f"S2 10 {expect:X}\ntimeout - NO\n" + # test broadcast unaddressed + result = runner.invoke(cli, ["--mock", "gear", "query", command, "--adr", "BCU"]) + expect = 0xFD00 + opcode + assert result.exit_code == 0 + assert result.output == f"S2 10 {expect:X}\ntimeout - NO\n" + # test short address + for short in range(DaliMax.ADR): + result = runner.invoke(cli, ["--mock", "gear", "query", command, "--adr", str(short)]) + expect = 0x0100 + (short * 0x200) + opcode + assert result.exit_code == 0 + assert result.output == f"S2 10 {expect:X}\ntimeout - NO\n" + # test group address + for group in range(DaliMax.GEAR_GROUP): + result = runner.invoke(cli, ["--mock", "gear", "query", command, "--adr", f"G{group}"]) + expect = 0x8100 + (group * 0x200) + opcode + assert result.exit_code == 0 + assert result.output == f"S2 10 {expect:X}\ntimeout - NO\n" From 2ad98b9e10e426c23f29a41227a9f60a9d84656e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= <51741389+SvenHaedrich@users.noreply.github.com> Date: Sun, 22 Mar 2026 20:20:02 +0100 Subject: [PATCH 10/12] Update README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- README.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index c1307b8..cf7f4d2 100644 --- a/README.md +++ b/README.md @@ -110,10 +110,12 @@ the `udev` folder and reload the `udev` rules. sudo udevadm control --reload-rules ``` -This file grants everyone read/write access. If you want to restrict access, -you should modify `MODE` to `0660`. You can then grant access to specific user -accounts by adding them to the plugdev group. Note that some Linux distributions always -require a per user permission. To grant permission to user named ``: +By default, this rule uses `MODE="0660"`, which grants read/write access only to root +and members of the appropriate group (for example `plugdev`). If you really want to +allow all users to access the device, you can change `MODE` to `0666`. You can grant +access to specific user accounts by adding them to the `plugdev` group. Note that some +Linux distributions always require per-user permission. To grant permission to a user +named ``: ```shell sudo usermod -a -G plugdev From e94b3b2dfff2c0530985fb4556729b6a765b1d66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Sun, 22 Mar 2026 20:23:17 +0100 Subject: [PATCH 11/12] Fix findings from copilot --- dali/DALI/device/device_action.py | 2 +- tests/cli/test_gear_query.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dali/DALI/device/device_action.py b/dali/DALI/device/device_action.py index 2e99952..8af725c 100644 --- a/dali/DALI/device/device_action.py +++ b/dali/DALI/device/device_action.py @@ -65,5 +65,5 @@ def write_device_frame( def set_device_dtr2_dtr1(dali: DaliInterface, dtr2: int, dtr1: int) -> None: - """Set control device data transfer registers 0 and 1 simultaneously""" + """Set control device data transfer registers 2 and 1 simultaneously""" write_device_frame(dali, DeviceSpecialCommandOpcode.DTR2_DTR1, dtr2, dtr1) diff --git a/tests/cli/test_gear_query.py b/tests/cli/test_gear_query.py index 8db3466..a4f5383 100644 --- a/tests/cli/test_gear_query.py +++ b/tests/cli/test_gear_query.py @@ -13,7 +13,7 @@ ("missing", 0x96), ], ) -def test_query_device_command(command, opcode): +def test_gear_query_command(command, opcode): runner = CliRunner() # test broadcast result = runner.invoke(cli, ["--mock", "gear", "query", command]) From 192c075535bf3b180621059faaf18824944ef614 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sven=20H=C3=A4drich?= Date: Sun, 22 Mar 2026 20:42:48 +0100 Subject: [PATCH 12/12] Bump version to 0.2.8 --- dali/dali_cli.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/dali/dali_cli.py b/dali/dali_cli.py index b8cbddb..b49852c 100644 --- a/dali/dali_cli.py +++ b/dali/dali_cli.py @@ -22,7 +22,7 @@ @click.group(name="dali") -@click.version_option("0.2.7") +@click.version_option("0.2.8") @click.option( "--serial-port", envvar="DALI_SERIAL_PORT", diff --git a/pyproject.toml b/pyproject.toml index cd3b78c..12bcc85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "dali" -version = "0.2.7" +version = "0.2.8" description = "Command line interface to control a DALI system" authors = [ {name = "Sven Hädrich", email="sven.haedrich@sevenlab.de"},