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/.github/workflows/mega-linter.yml b/.github/workflows/mega-linter.yml index d42ee17..b6c26e2 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.4.0 id: ml env: VALIDATE_ALL_CODEBASE: true GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} MEGALINTER_CONFIG: .github/megalinter/mega-linter.yml - diff --git a/99-lunatone-dali.rules b/99-lunatone-dali.rules new file mode 100644 index 0000000..222a3df --- /dev/null +++ b/99-lunatone-dali.rules @@ -0,0 +1,28 @@ +# 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 +# +# +# 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` +# +# 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. +# 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="0660", GROUP="plugdev" diff --git a/README.md b/README.md index 8377a42..cf7f4d2 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 @@ -82,19 +82,43 @@ 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 +``` + +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 +``` + +You will have to log out and then back in for the group change to take effect. diff --git a/dali/DALI/device/device_action.py b/dali/DALI/device/device_action.py index b61c48a..8af725c 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""" + """Set control device data transfer registers 2 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..01b8e4b 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/device/device_query.py b/dali/DALI/device/device_query.py index 68f51ec..d1357f2 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"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") + 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,77 +73,44 @@ def status(dali: DaliInterface, adr): click.echo("timeout - NO") -@click.command(name="version", help="Control device version number.") -@click.pass_obj -@device_address_option -def version(dali: DaliInterface, adr): - result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_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") +# NOT IMPLEMENTED: IEC62386-103-2022 +# 11.6.4 QUERY APPLICATION CONTROLLER ERROR +# 11.6.5 QUERY INPUT DEVICE ERROR -@click.command(name="extended", help="Control device extended version number for 30X.") +@click.command(name="missing", help="missing short address.") @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") +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: - raise click.BadParameter(f"needs to be between 0 and {DaliMax.VALUE - 1}.", param_hint="X") + click.echo(f"missing: {result} = 0x{result:02X} = {result:08b}b = undefined") -@click.command( - name="capabilities", - help="Control device capabilities. The answer shall be a combination of control device capabilities.", -) +@click.command(name="version", help="Control device version number.") @click.pass_obj @device_address_option -def capabilities(dali: DaliInterface, adr): - result = query_device_value(dali, adr, DeviceQueryCommandOpcode.QUERY_DEVICE_CAPABILITIES) +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: - 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") + 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") -@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 +118,15 @@ 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 +137,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 +146,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 +161,32 @@ def random(dali: DaliInterface, adr): click.echo(f"random address: 0x{random_address:06X} = " f"{random_address:024b}b = " f"{random_address}") +# NOT IMPLEMENTED: 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 +197,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,36 +221,64 @@ 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.") + click.echo(f"power cycle notification: {result} = 0x{result:02X} = {result:08b}b") + else: + 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: 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: + 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("Invalid event scheme.") + 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") @@ -252,8 +290,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") @@ -265,8 +303,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") @@ -278,8 +316,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") @@ -294,8 +332,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") @@ -309,10 +347,84 @@ 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") 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/gear/gear_clear.py b/dali/DALI/gear/gear_clear.py index 9f743c0..597a8bb 100644 --- a/dali/DALI/gear/gear_clear.py +++ b/dali/DALI/gear/gear_clear.py @@ -3,13 +3,9 @@ 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 +14,11 @@ 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), send_twice=True + ) + write_gear_frame(dali, GearSpecialCommandOpcode.TERMINATE) 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() 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/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/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: diff --git a/dali/dali_cli.py b/dali/dali_cli.py index a9097c0..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", @@ -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) @@ -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) diff --git a/pyproject.toml b/pyproject.toml index b779e1f..12bcc85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,13 +4,13 @@ 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"}, ] 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 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..a4f5383 --- /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_gear_query_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"