Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support for CodeMercs LED-Warrior modules? #147

Open
SlySven opened this issue Oct 22, 2024 · 36 comments
Open

Support for CodeMercs LED-Warrior modules? #147

SlySven opened this issue Oct 22, 2024 · 36 comments

Comments

@SlySven
Copy link

SlySven commented Oct 22, 2024

I have obtained a LED-Warrior14(-01) Module which is a (nice and cheap) I2C-DALI interface and successfully connected and operated it from a Raspberry Pi 2B to send DAPC commands to dim all the lamps in my test setup. However I haven't found much in the way of software to work with it - particularly to assign individual short addresses (the luminaires I have are actually fitted with old Tridonic emergency LED ones with a pair of 10 position mini rotary switches labelled "TENS" and "ONES" - they carry the DALI rather than the DALI-2 marking - but it has been impossible to find out anything about how those might act - if they were to set a short address I would have thought they would have been 8 way ones!)

The only thing I found and have used is https://github.com/davideloba/daliMaster_rpi which seems to have been devised to work with the "DALI Master" HAT for Raspberry Pis but that does not seem to be available anymore (and with the Wayback machine being off-line it has been hard to find anything about it) - however a single mention of a LW14 datasheet on the front page which led to the CodeMercs site suggested and was confirmed that my unit is compatible with the originally targeted unit. As it happens the Raspberry Pi version is derived from an Arduino one by the same person and both mention that the chip used in "DALI Master" units is the CodeMercs one. Unfortunately this software is too simple to do the initialisation process - and I am a C++ coder not a Python one - so trying to extend the code there to do that is proving to be a challenge!

tl;dr; Is it feasible for this project to support this I2C-DALI interface - I'm guessing with the addition of a further driver...

@sde1000
Copy link
Owner

sde1000 commented Oct 22, 2024

I've read through the datasheet for the module. Thank you for bringing it to my attention — it does look nice and cheap! I'll try to get hold of a couple to experiment with.

A driver to support this interface should be fairly straightfoward. The daliMaster_rpi code you linked to looks like a good example for finding the device on the I2C bus and getting to the point where you can send and receive frames.

@sde1000
Copy link
Owner

sde1000 commented Oct 28, 2024

Modules have arrived — I can't look at them straight away, I'm busy on other things, but I'll put together a test setup as soon as I can.

PXL_20241028_111144990

@SlySven
Copy link
Author

SlySven commented Nov 9, 2024

Here's my test setup - all running off a 240-to-240Vac isolation transformer for (my) safety!
DSC06334

I've a number of these "Zumatubel" DALI1 dimmable emergency luminaires (Circ. 2017) - by connecting just one of them to the controller I was able to set its (short) address and then address it individually - but working out how those two 10-way switches to set the short address (if that is what they are for) function has not been possible so far. The manufactures datasheet archive fails to mention this (obsolete) ballast model at all! :rolling_eyes:
DSC06335

One advisory I would give you for the CodeMercs module - although the PWB is a double-sided through-hole plated one the pads for the power/I2C/address setting are rather small and skimpy - I broke the ground pad away from the surrounding copper very quickly and had to reconnect it with a bridge to another point in the same circuit (to a pad used for the +5V regulator for the -02 module that draws it's power from the {then no-longer isolated from the} DALI power supply lines {and thus not the "Mains" wiring either})! So take care with the wiring up - and maybe consider through hole "pins" if one is going to connect/disconnect from those connections more than once...

@sde1000
Copy link
Owner

sde1000 commented Nov 10, 2024

I've soldered a header onto mine. Very fiddly — tiny pads!

Here is a first attempt at driving the module. If you run this on a Pi with the module at its default address of 0x23 (i.e. both address pins floating) it should attempt to assign a new short address to all connected control gear.

This expects python3-smbus to be installed (same as the daliMaster_rpi project), and python-dali to be on the current python path (so clone the python-dali repo, or install it from pypi).

It works for me but I haven't tested all the edge cases (eg. when multiple devices respond to a query).

# Sample code for CodeMercs LW14 with python-dali on Raspberry Pi I2C

from dali.command import Command
from dali.gear.general import EnableDeviceType, Off
from dali.address import GearBroadcast
from dali.sequences import sleep as seq_sleep
from dali.sequences import progress as seq_progress
from dali.exceptions import CommunicationError, UnsupportedFrameTypeError
from dali.sequences import Commissioning
import dali.frame
import time
import logging
import struct

struct_signature = struct.Struct(">HHH")


class I2C_RPI:
    def __init__(self, address):
        import smbus
        self.bus = smbus.SMBus(1)
        self.address = address

    def ping(self):
        self.bus.write_quick(self.address)

    def write_byte(self, register, data):
        self.bus.write_byte_data(self.address, register, data)

    def read_byte(self, register):
        return self.bus.read_byte_data(self.address, register)

    def write_bytes(self, register, data):
        self.bus.write_i2c_block_data(self.address, register, data)

    def read_bytes(self, register, length):
        return bytes(self.bus.read_i2c_block_data(
            self.address, register, length))


class LW14:
    def __init__(self, i2c, logger=None):
        self.bus = i2c
        self.log = logger or logging.getLogger(__name__)

        # Simple presence check — raises IOError if device absent
        self.bus.ping()

        # Read and check signature
        vendor, product, version = struct_signature.unpack(
            self.bus.read_bytes(0xf0, 6))

        self.log.debug("LW14 vendor: %d", vendor)
        self.log.debug("LW14 product: %d", product)
        self.log.debug("LW14 version: %x", version)

        if product != 14:
            raise Exception(f"Unsupported product ID {product}; expected 14")

        if version < 0x2000:
            raise Exception(f"Unsupported LW14 version {version:02x}; minimum "
                            f"version required is 2000")

    @staticmethod
    def _wrap(command):
        response = yield command
        return response

    def _send_frame(self, frame, wait_for_response=False):
        if len(frame) not in (16, 24):
            raise UnsupportedFrameTypeError

        # Wait for device to be ready
        status = 0x40
        while status & 0x40:
            status = self._read_status()
            if status & 0x80:
                # Bus fault — we can't continue
                raise CommunicationError(
                    "Bus fault while waiting to transmit")
            if status & 0x08:
                # "Valid reply" bit is still set — read command
                # register to clear
                self.log.debug("Reading command reg to clear old reply")
                self.bus.read_byte(0x01)
            if status & 0x40:
                # Still busy — wait a bit before polling again
                time.sleep(0.002)

        log.debug("Status before sending frame is %x", status)

        # Write frame to command register; the LW14 will transmit as
        # soon as the bus is free, according to the priority in the
        # config register
        self.bus.write_bytes(0x01, frame.as_byte_sequence)
        
        if wait_for_response:
            log.debug("Waiting for response")
            # Poll the status register. We are waiting for bit 0x04
            # ("Reply timeframe") and bit 0x40 ("Busy") to become unset.
            status = 0x44
            while status & 0x44:
                status = self._read_status()
                if status & 0x80:
                    # Bus fault — we can't continue
                    raise CommunicationError(
                        "Bus fault while waiting for response")
                if status & 0x44:
                    # Still waiting
                    time.sleep(0.0002)

            log.debug("Finished waiting, status is %x", status)

            # If "frame error" is set, multiple devices transmitted
            # replies.
            if status & 0x10:
                return dali.frame.BackwardFrameError(0)

            # If "valid reply" is set, check that it was a single
            # byte.  If so, it's a response. Otherwise it's an error.
            if status & 0x08:
                while status & 0x03 == 0:
                    # Poll until the reply is fully received and its length
                    # is available
                    time.sleep(0.001)
                    status = self._read_status()
                if (status & 0x03) == 1:
                    return dali.frame.BackwardFrame(self.bus.read_byte(0x01))
                raise CommunicationError(
                    "Forward frame received while waiting for backward frame")
            
    def _read_status(self):
        return self.bus.read_byte(0x00)

    def _set_priority(self, priority):
        self.log.debug("Setting priority %d", priority)
        self.bus.write_byte(0x02, priority)

    def send(self, command, priority=2, progress=None):
        # If "command" is not a sequence, wrap it in a trivial sequence
        seq = self._wrap(command) if isinstance(command, Command) else command

        self._set_priority(priority)

        response = None
        while True:
            try:
                cmd = seq.send(response)
            except StopIteration as r:
                return r.value

            if isinstance(cmd, seq_sleep):
                log.debug("Waiting for %f seconds", cmd.delay)
                time.sleep(cmd.delay)
            elif isinstance(cmd, seq_progress):
                if progress:
                    progress(cmd)
            else:
                self.log.debug("Sending %s", cmd)
                if cmd.devicetype != 0:
                    self._send_frame(EnableDeviceType(cmd.devicetype).frame)
                    self._set_priority(1)
                if cmd.sendtwice:
                    self._send_frame(cmd.frame)
                    self._set_priority(1)
                response_frame = self._send_frame(
                    cmd.frame, cmd.response is not None)
                self._set_priority(1)
                response = None
                if cmd.response:
                    response = cmd.response(response_frame)
                    self.log.debug("Response is %s", response)


if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    log = logging.getLogger()
    d = LW14(I2C_RPI(0x23))
    # Turn everything off while we work...
    d.send(Off(GearBroadcast()))
    # Assign addresses to all attached control gear
    d.send(Commissioning(readdress=True), priority=3, progress=log.info)

@sde1000
Copy link
Owner

sde1000 commented Nov 10, 2024

On further reflection, I'm pretty sure this isn't doing the right thing when there's a frame error on backwards frames (i.e. when multiple devices respond to a command). The "Frame Error" bit is reset on every read of the status register, so will almost certainly be clear by the time we check it.

More experimentation is needed — should it be read in the loop where we are waiting for any of bits 0 and 1 to be set in the status register? Or before then?

I need to add more devices to my test setup!

@SlySven
Copy link
Author

SlySven commented Nov 11, 2024

Looking at https://electronics.stackexchange.com/a/529867/124900 suggests that the "Compare" stage is where you are going to get the collisions if the search address is greater than the random address chosen by more than one unit (otherwise you'd get a valid "yes" if only one unit does or no response if none does). As such you only need two devices to start with I guess...

Am trying to get the above working on my system - not sure quite what I need to frob at the moment running into awkwardness because I am ssh -Y-ing into the Raspberry Pi but because the operation has to be done with superuser permissions things are falling over when trying to communicate back via X subsystems given that root cannot access my normal user X-display - perhaps. 🤷‍♂️

On a more positive note there are good noises coming from the CodeMercs person running their forum if not more: https://forum.codemercs.com/viewtopic.php?p=13218#p13218

@sde1000
Copy link
Owner

sde1000 commented Nov 11, 2024

I didn't need to run the script as root on my Pi. What happens when you try i2cdetect -y 1 as non-root?

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

The command isn't available - it is /usr/sbin/i2cdetect - so that is not surprising... Don't forget that access to actual I/O hardware is normally a protected operation in a POSIX environment. 🤷‍♂️

TBH I am probably somewhat handicapped here as I am not familiar with Python at all (I know it is indentation/whitespace sensitive - which as a C/C++ coder is really strange to me! 🥴)

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

On my Pi, with the default operating system installation, my user is a member of the "i2c" group and so has access to /dev/i2c-1. I didn't do anything special to enable this, it's just how it was configured out of the box.

steve@raspberrypi:~ $ cat /etc/os-release 
PRETTY_NAME="Debian GNU/Linux 12 (bookworm)"
NAME="Debian GNU/Linux"
VERSION_ID="12"
VERSION="12 (bookworm)"
VERSION_CODENAME=bookworm
ID=debian
HOME_URL="https://www.debian.org/"
SUPPORT_URL="https://www.debian.org/support"
BUG_REPORT_URL="https://bugs.debian.org/"
steve@raspberrypi:~ $ ls -l /dev/i2c-1
crw-rw---- 1 root i2c 89, 1 Nov 10 20:33 /dev/i2c-1
steve@raspberrypi:~ $ id
uid=1000(steve) gid=1000(steve) groups=1000(steve),4(adm),20(dialout),24(cdrom),27(sudo),29(audio),44(video),46(plugdev),60(games),100(users),102(input),105(render),106(netdev),115(lpadmin),993(gpio),994(i2c),995(spi)
steve@raspberrypi:~ $ i2cdetect -y 1
     0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f
00:                         -- -- -- -- -- -- -- -- 
10: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
20: -- -- -- 23 -- -- -- -- -- -- -- -- -- -- -- -- 
30: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
40: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
50: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
60: -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- -- 
70: -- -- -- -- -- -- -- --                         

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

Ah, I haven't given myself access to the i2c group:

ssh stephen@spock:~$ cat /etc/os-release 
PRETTY_NAME="Devuan GNU/Linux 5 (daedalus)"
NAME="Devuan GNU/Linux"
VERSION_ID="5"
VERSION="5 (daedalus)"
VERSION_CODENAME="daedalus"
ID=devuan
ID_LIKE=debian
HOME_URL="https://www.devuan.org/"
SUPPORT_URL="https://devuan.org/os/community"
BUG_REPORT_URL="https://bugs.devuan.org/"
ssh stephen@spock:~$ id
uid=1003(stephen) gid=1003(stephen) groups=1003(stephen),27(sudo),50(staff),100(users)

What PATH do you have?

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

steve@raspberrypi:~ $ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/local/games:/usr/games

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

Apart from that, I'm curious about the issue you have running scripts as root. The script doesn't interact with X at all, it just runs in a terminal session — so how is accessing the Pi using ssh -Y causing problems?

What happens if you just run sudo bash?

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

Whilst I am using elevated permissions on the remote (RPi) device I cannot run any GUI applications as they cannot connect to the XServer running on my local (GNU/Linux) PC.

ssh stephen@spock:~/src/python-dali$ sudo bash
root@spock:/home/stephen/src/python-dali# mousepad
X11 connection rejected because of wrong authentication.
X11 connection rejected because of wrong authentication.
X11 connection rejected because of wrong authentication.

(mousepad:28405): Gtk-WARNING **: 18:53:54.574: cannot open display: localhost:10.0
root@spock:/home/stephen/src/python-dali# 

OTOH I might be able to sort out the access to the remote hardware parts by adding membership to some additional groups.

FTR my current path is:

ssh stephen@spock:~/src/python-dali$ echo $PATH
/usr/local/bin:/usr/bin:/bin:/usr/local/games:/usr/games:/opt/vc/bin

so some tweaking to include some other "privileged" ones looks to be needed...

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

Running X applications as root doesn't work because they are looking in the wrong place for the .Xauthority file that contains the cookie that allows access to the display. (They will be looking in /root/.Xauthority)

You can either add the cookie to root's .Xauthority file by using the xauth command to export it from your own .Xauthority file and import it into root's, or just get X clients run as root to look in your home directory instead:

sudo touch /root/.Xauthority  # Make sure file exists, otherwise xauth add will fail
sudo xauth add $(xauth list $DISPLAY)

or

sudo bash
export XAUTHORITY=/home/stephen/.Xauthority

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

sudo bash
export XAUTHORITY=/home/stephen/.Xauthority

Do I want to do that on my local (PC) or remote (RPi) machine?

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

On the Pi, as root, immediately before running the X client — it is setting an environment variable that will be accessed by the X client running as root on the Pi. If you exit your privileged shell and restart (eg. with another "sudo") the environment variable will be lost and you'll have to set it again.

The xauth method adds the cookie to root's .Xauthority file, and it should remain valid for the lifetime of your ssh session.

I've updated my DALI test environment; I needed to add a different type of DALI gear before I could observe the Frame Error bit being set after Compare. (Presumably with two of the same, the timing in the firmware is similar enough that they don't clash when both sending "Yes".)

Here's an updated version of the script that works for me with two different types of DALI gear on the bus:

# Sample code for CodeMercs LW14 with python-dali on Raspberry Pi I2C

from dali.command import Command
from dali.gear.general import EnableDeviceType, Off
from dali.address import GearBroadcast
from dali.sequences import sleep as seq_sleep
from dali.sequences import progress as seq_progress
from dali.exceptions import CommunicationError, UnsupportedFrameTypeError
from dali.sequences import Commissioning
import dali.frame
import time
import logging
import struct

struct_signature = struct.Struct(">HHH")


class I2C_RPI:
    def __init__(self, address):
        import smbus
        self.bus = smbus.SMBus(1)
        self.address = address

    def ping(self):
        self.bus.write_quick(self.address)

    def write_byte(self, register, data):
        self.bus.write_byte_data(self.address, register, data)

    def read_byte(self, register):
        return self.bus.read_byte_data(self.address, register)

    def write_bytes(self, register, data):
        self.bus.write_i2c_block_data(self.address, register, data)

    def read_bytes(self, register, length):
        return bytes(self.bus.read_i2c_block_data(
            self.address, register, length))


class LW14:
    def __init__(self, i2c, logger=None):
        self.bus = i2c
        self.log = logger or logging.getLogger(__name__)

        # Simple presence check — raises IOError if device absent
        self.bus.ping()

        # Read and check signature
        vendor, product, version = struct_signature.unpack(
            self.bus.read_bytes(0xf0, 6))

        self.log.debug("LW14 vendor: %d", vendor)
        self.log.debug("LW14 product: %d", product)
        self.log.debug("LW14 version: %x", version)

        if product != 14:
            raise Exception(f"Unsupported product ID {product}; expected 14")

        if version < 0x2000:
            raise Exception(f"Unsupported LW14 version {version:02x}; minimum "
                            f"version required is 2000")

        # Clear "telegram received", "overrun", "valid reply" bits
        self.bus.read_byte(0x01)

        # Read status register to clear the "frame error" bit
        self._read_status()
        
        # Check that no unexpected status bits are set
        status = self._read_status()
        if status & 0x3b:
            raise Exception("Unexpected bits set in status register")

    @staticmethod
    def _wrap(command):
        response = yield command
        return response

    def _send_frame(self, frame, wait_for_response=False):
        if len(frame) not in (16, 24):
            raise UnsupportedFrameTypeError

        # Wait for device to be ready
        status = 0x40
        while status & 0x40:
            status = self._read_status()
            if status & 0x80:
                # Bus fault — we can't continue
                raise CommunicationError(
                    "Bus fault while waiting to transmit")
            if status & 0x08:
                # "Valid reply" bit is still set — read command
                # register to clear
                self.log.debug("Reading command reg to clear old reply")
                self.bus.read_byte(0x01)
            if status & 0x40:
                # Still busy — wait a bit before polling again
                time.sleep(0.002)

        log.debug("Status before sending frame is %x", status)

        # Write frame to command register; the LW14 will transmit as
        # soon as the bus is free, according to the priority in the
        # config register
        self.bus.write_bytes(0x01, frame.as_byte_sequence)
        
        if wait_for_response:
            frame_error = False

            log.debug("Waiting for response")
            # Poll the status register. We are waiting for bit 0x04
            # ("Reply timeframe") and bit 0x40 ("Busy") to become unset.
            status = 0x44
            while status & 0x44:
                status = self._read_status()
                if status & 0x80:
                    # Bus fault — we can't continue
                    raise CommunicationError(
                        "Bus fault while waiting for response")
                if status & 0x10:
                    # Frame error
                    log.debug("Frame error bit set while waiting for response")
                    frame_error = True
                if status & 0x44:
                    # Still waiting
                    time.sleep(0.0002)

            log.debug("Finished waiting, status is %x", status)

            if frame_error:
                return dali.frame.BackwardFrameError(0)

            # If "valid reply" is set, check that it was a single
            # byte.  If so, it's a response. Otherwise it's an error.
            if status & 0x08:
                while status & 0x03 == 0:
                    # Poll until the reply is fully received and its length
                    # is available
                    time.sleep(0.001)
                    status = self._read_status()
                if status & 0x10:
                    # Frame error
                    log.debug("Frame error bit set while reading response")
                    frame_error = True
                if (status & 0x03) == 1:
                    response = self.bus.read_byte(0x01)
                    if frame_error:
                        log.debug("Frame error bit is set")
                        return dali.frame.BackwardFrameError(response)
                    return dali.frame.BackwardFrame(response)
                raise CommunicationError(
                    "Forward frame received while waiting for backward frame")
            
    def _read_status(self):
        return self.bus.read_byte(0x00)

    def _set_priority(self, priority):
        self.log.debug("Setting priority %d", priority)
        self.bus.write_byte(0x02, priority)

    def send(self, command, priority=2, progress=None):
        # If "command" is not a sequence, wrap it in a trivial sequence
        seq = self._wrap(command) if isinstance(command, Command) else command

        self._set_priority(priority)

        response = None
        while True:
            try:
                cmd = seq.send(response)
            except StopIteration as r:
                return r.value

            if isinstance(cmd, seq_sleep):
                log.debug("Waiting for %f seconds", cmd.delay)
                time.sleep(cmd.delay)
            elif isinstance(cmd, seq_progress):
                if progress:
                    progress(cmd)
            else:
                self.log.debug("Sending %s", cmd)
                if cmd.devicetype != 0:
                    self._send_frame(EnableDeviceType(cmd.devicetype).frame)
                    self._set_priority(1)
                if cmd.sendtwice:
                    self._send_frame(cmd.frame)
                    self._set_priority(1)
                response_frame = self._send_frame(
                    cmd.frame, cmd.response is not None)
                self._set_priority(1)
                response = None
                if cmd.response:
                    response = cmd.response(response_frame)
                    self.log.debug("Response is %s", response)


if __name__ == "__main__":
    logging.basicConfig(level=logging.DEBUG)
    log = logging.getLogger()
    d = LW14(I2C_RPI(0x23))
    # Turn everything off while we work...
    d.send(Off(GearBroadcast()))
    # Assign addresses to all attached control gear
    d.send(Commissioning(readdress=True), priority=3, progress=log.info)

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

If there's too much debug output to read, change logging.DEBUG to logging.INFO in the call to logging.basicConfig() near the end of the file.

If you put this in a file called codemercs.py, you could run it from the command line like this:

sudo python3 codemercs.py

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

🎉🎉🎉

By placing your most recent script into a commission.py script in the examples sub-directory and which I made "executable" and addded a hashbang of #!/usr/bin/env python3 to the top of the file, I was able to run your script successfully to assign a short address to all three of the units in my test setup. With the membership of the i2c group I didn't need to do it via sudo. It removed the existing short address I had set previously to one unit and set it to a new one.

Might I suggest that you turn all the units on at the start of the commissioning process and turn each one off as it is assigned a short address - so as to get a visual progress indication? 😀

I repeated the process more than once and as expected each unit got a new, random, one of the three used each time - indicating that the randomisation does work. However, there was one run out of the four that I've done that did seem to hang (which is why the visible indication might be useful during testing). 😟

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

You are welcome to write your own variation of Commissioning that does this, but I don't think it should be a standard part of the library. (Consider what it would look like when commissioning an installation in a whole building, for example.)

I guess you will now want to investigate the commands in the emergency module to see whether your units respond meaningfully.

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

I have another couple of projects that I use at work: licon for lighting control and emcon for emergency lighting control. These currently communicate with the DALI bus over a TCP connection to "daliserver", but it should be quite simple to remove the module that does that and replace it with the driver code from this thread — the interfaces are compatible.

I also have some code that connects DALI lights to Home Assistant over MQTT — the same comment regarding the driver applies. This code works but really could do with cleaning up and turning into a proper Home Assistant integration.

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

You are welcome to write your own variation of Commissioning that does this, but I don't think it should be a standard part of the library. (Consider what it would look like when commissioning an installation in a whole building, for example.)

As I understand it (and from what I have experienced whilst a 👷‍♂️) a DALI controlled device powers up for the first time (or rather before it receives any commands over a working DALI network) at full power - as that helps the electrician installing everything to verify that they have done their end of the job correctly before the "DALI wizard" comes along to commission the system.

Further testing has indicated a lot more of those hang-ups (at least 50% of the time) which is a bit worrying.

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

I will see if I can get it to do it here. What's the output up to the point where it hangs?

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

This is one sample:

DEBUG:__main__:Sending SearchaddrL(230)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending Compare()
DEBUG:root:Status before sending frame is 4
DEBUG:root:Waiting for response
DEBUG:root:Finished waiting, status is 0
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Response is False
DEBUG:__main__:Sending SearchaddrH(57)
DEBUG:root:Status before sending frame is 0
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending SearchaddrM(2)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending SearchaddrL(231)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending Compare()
DEBUG:root:Status before sending frame is 4
DEBUG:root:Waiting for response
DEBUG:root:Finished waiting, status is 8
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Response is True
INFO:root:Ballast found at address 0x3902e7
INFO:root:Programming short address 0
DEBUG:__main__:Sending ProgramShortAddress(0)
DEBUG:root:Status before sending frame is 0
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending VerifyShortAddress(0)
DEBUG:root:Status before sending frame is 4
DEBUG:root:Waiting for response
DEBUG:root:Finished waiting, status is 8
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Response is True
DEBUG:__main__:Sending Withdraw()
DEBUG:root:Status before sending frame is 0
DEBUG:__main__:Setting priority 1
INFO:root:Progress: 3736296/16777215
DEBUG:__main__:Sending SearchaddrH(255)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending SearchaddrM(255)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending SearchaddrL(255)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending Compare()
DEBUG:root:Status before sending frame is 4
DEBUG:root:Waiting for response
DEBUG:root:Finished waiting, status is 8

After a <Ctrl>+C this gives:

^CTraceback (most recent call last):
  File "/home/stephen/src/python-dali/examples/./commission.py", line 206, in <module>
    d.send(Commissioning(readdress=True), priority=3, progress=log.info)
  File "/home/stephen/src/python-dali/examples/./commission.py", line 190, in send
    response_frame = self._send_frame(
                     ^^^^^^^^^^^^^^^^^
  File "/home/stephen/src/python-dali/examples/./commission.py", line 142, in _send_frame
    status = self._read_status()
             ^^^^^^^^^^^^^^^^^^^
  File "/home/stephen/src/python-dali/examples/./commission.py", line 157, in _read_status
    return self.bus.read_byte(0x00)
           ^^^^^^^^^^^^^^^^^^^^^^^^
  File "/home/stephen/src/python-dali/examples/./commission.py", line 32, in read_byte
    return self.bus.read_byte_data(self.address, register)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
KeyboardInterrupt

ssh stephen@spock:~/src/python-dali/examples$ 

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

A quick thought - after sending the Withdraw() is there anything missing after that before starting the search for the next unit?

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

Right. I've managed to get it to hang there once, as well — I had to run the program in a loop, it took a while before it hung.

It looks like the device is setting the "Valid Reply" bit, but then never setting "byte count for telegram received".

It's possible it's setting the Frame Error bit — I need to adjust the code to check for that in the loop.

No, there's nothing missing after the Withdraw(). The Commissioning sequence is well tested, I would be surprised to find a problem in it at this point.

Replacement code; you should be able to see where to put it:

            # If "valid reply" is set, check that it was a single
            # byte.  If so, it's a response. Otherwise it's an error.
            if status & 0x08:
                while status & 0x03 == 0:
                    # Poll until the reply is fully received and its length
                    # is available
                    time.sleep(0.001)
                    status = self._read_status()
                    if status & 0x10:
                        # Frame error
                        log.debug("Frame error bit set while reading response")
                        return dali.frame.BackwardFrameError(0)
                if (status & 0x03) == 1:
                    return dali.frame.BackwardFrame(self.bus.read_byte(0x01))
                raise CommunicationError(
                    "Forward frame received while waiting for backward frame")

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

Here is another failure - and this is so quick it is the entirety of the log:

ssh stephen@spock:~/src/python-dali/examples$ ./commission.py 
DEBUG:__main__:LW14 vendor: 0
DEBUG:__main__:LW14 product: 14
DEBUG:__main__:LW14 version: 2001
DEBUG:__main__:Setting priority 2
DEBUG:__main__:Sending Off(<broadcast (control gear)>)
DEBUG:root:Status before sending frame is 0
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Setting priority 3
DEBUG:__main__:Sending DTR0(255)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending SetShortAddress(<broadcast (control gear)>)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending Terminate()
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending Initialise(broadcast=True)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending Randomise()
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:root:Waiting for 0.100000 seconds
INFO:root:Progress: 0/16777215
DEBUG:__main__:Sending SearchaddrH(255)
DEBUG:root:Status before sending frame is 0
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending SearchaddrM(255)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending SearchaddrL(255)
DEBUG:root:Status before sending frame is 4
DEBUG:__main__:Setting priority 1
DEBUG:__main__:Sending Compare()
DEBUG:root:Status before sending frame is 4
DEBUG:root:Waiting for response
DEBUG:root:Finished waiting, status is 8

and after a <Ctrl>+C:

  File "/home/stephen/src/python-dali/examples/./commission.py", line 206, in <module>
    d.send(Commissioning(readdress=True), priority=3, progress=log.info)
  File "/home/stephen/src/python-dali/examples/./commission.py", line 190, in send
    response_frame = self._send_frame(
                     ^^^^^^^^^^^^^^^^^
  File "/home/stephen/src/python-dali/examples/./commission.py", line 141, in _send_frame
    time.sleep(0.001)
KeyboardInterrupt

ssh stephen@spock:~/src/python-dali/examples$

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

Also worth making the following change (0x08 to 0x0b) — if "byte count for telegram received" is set when we are wanting to transmit, this will reset it first:

            if status & 0x0b:
                # "Valid reply" bit is still set — read command
                # register to clear
                self.log.debug("Reading command reg to clear old reply")
                self.bus.read_byte(0x01)

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

Ok, that's been running continuously for 15 minutes without hanging here. Any luck?

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

After #147 (comment) the code seems to work all the time. 😁

So how well does this mean the Raspberry Pi + LEDWarrior14 combination is integrated in this project?

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

It's the bare bones of a driver. There's currently no "standard" form for drivers in the project; I could remove the bit at the end and add the code to dali/drivers and people would probably be reasonably happy. The driver supports 16 and 24 bit forward frames, and uses priorities correctly, which is more than I can say about some of the other drivers!

The hardware is capable of monitoring the DALI bus. It should be possible to extend the driver to support this — although since it's an I2C device there's no concept of interrupts and the device will have to be polled frequently (about every 4ms) to avoid missing any frames.

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

if status & 0x0b:
# "Valid reply" bit is still set — read command
# register to clear
self.log.debug("Reading command reg to clear old reply")
self.bus.read_byte(0x01)

Should that comment be revised to say:

# "Valid reply" or one of the "telegram byte(s) received" bits
# is still set — read command register to clear

?

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

Yes, probably.

@SlySven
Copy link
Author

SlySven commented Nov 12, 2024

The hardware is capable of monitoring the DALI bus. It should be possible to extend the driver to support this — although since it's an I2C device there's no concept of interrupts and the device will have to be polled frequently (about every 4ms) to avoid missing any frames.

That would apparently still be useful for some users over in the CodeMercs fora - and having an acknowledge piece of software to work the RPi+Module will definitely be of interest to more people there who might have noticed CodeMercs own response of "We don't have complete sample code for this." - but who provide a Windows application to use with their own (more expensive) USB-to-DALI interface LEDWarrior14U-DR unit...

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

It should be possible to support their USB-to-DALI interface with this code, too — it's just a USB to I2C interface and the I2C to DALI interface in a single box. The reason I put the I2C access code in a separate class was to allow it to be swapped out for a different class that drives the USB to I2C interface.

Monitoring a DALI bus is actually quite complicated, if you want to decode the traffic into named commands. There's code in dali/drivers/hid.py to do it for the Tridonic DALI-USB interface, which might be a good starting point — see async def _bus_watch.

@sde1000
Copy link
Owner

sde1000 commented Nov 12, 2024

Oh, I saw you asked "what if there is a collision in the 24-bit random addresses chosen by the gear during commissioning" on the CodeMercs forum.

If this results in a frame error when the commissioning code sends Compare and has narrowed the range down to a single address, it will send Randomise again and re-start the search. Devices that have already received short addresses are unaffected by this and won't be readdressed.

If it sends Compare and multiple devices respond without causing a frame error, it can't tell the difference between this and a single device responding, so multiple devices will end up with the same short address. Bad luck! If you encounter this, you can send a command to delete that short address and then re-run commissioning with "readdress=False" so it only assigns short addresses to bus units that lack them.

The test suite for the Commissioning sequence tests precisely this case — see test_commissioning_clash in dali/tests/test_sequences.py

@SlySven
Copy link
Author

SlySven commented Nov 13, 2024

It should be possible to support their USB-to-DALI interface with this code, too — it's just a USB to I2C interface and the I2C to DALI interface in a single box. The reason I put the I2C access code in a separate class was to allow it to be swapped out for a different class that drives the USB to I2C interface.

TBH that isn't something I'm worried about as I don't have that USB unit though I agree with you that it sounds like a USB to I2C and the LEDWarrior14 IC in a single box,

I have started to experiment with doing more with other "functions" (If that is the pythonic word for them) in that "main" bit at the end - which, since I am at a newbie level for that language, is proving interesting - this works only because I know that I only have three devices - I've go to look into how to scan the DALI system to find valid short addresses but I suspect I will be able to get a hint from the Commission(...) procedure, e.g. this to flash each device 10 times in address assignment order:

if __name__ == "__main__":
    logging.basicConfig(level=logging.INFO)
    log = logging.getLogger()
    d = LW14(I2C_RPI(0x23))
    # Turn everything on while we work...
    d.send(RecallMaxLevel(GearBroadcast()))
    # Assign addresses to all attached control gear
    d.send(Commissioning(readdress=True), priority=3, progress=log.info)
    for i in range(3):
        for j in range(10):
            d.send(Off(i))
            time.sleep(0.5)
            d.send(RecallMinLevel(i))
            time.sleep(0.5)
    d.send(Off(GearBroadcast()))

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants