Skip to content

Commit 15bcee3

Browse files
authored
Add driver for Atx Led Dali Hat (AL-DALI-HAT). (#135)
* Add driver for Atx Led Dali Hat (AL-DALI-HAT). This uses the Raspberry PI on-board serial port to communicate at 19200 baud to the Dali Hat, the hardware on the hat adapts the UART serial data stream into DALI encoding * Remove path editing on import and __main__ from atxled.py and use read_until in read * Remove __main__ since it serves no real purpose
1 parent 0ebccd7 commit 15bcee3

File tree

3 files changed

+270
-0
lines changed

3 files changed

+270
-0
lines changed

README.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ Library structure
8585

8686
- ``serial`` - asyncio-based driver for Lunatone LUBA RS232 interfaces
8787

88+
- ``atxled`` - Driver for ATX LED SERIAL DALI HAT
89+
8890
- ``exceptions`` - DALI related exceptions
8991

9092
- ``frame`` - Forward and backward frames; stable

dali/driver/atxled.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
from dali.command import Command
2+
from dali.driver.base import SyncDALIDriver, DALIDriver
3+
from dali.frame import BackwardFrame
4+
import logging
5+
import sys
6+
7+
import serial
8+
import threading
9+
import time
10+
11+
DALI_PACKET_SIZE = {"j": 8, "h": 16, "l": 24, "m": 25}
12+
DALI_PACKET_PREFIX = {v: k for k, v in DALI_PACKET_SIZE.items()}
13+
14+
15+
class DaliHatSerialDriver(DALIDriver):
16+
"""Driver for communicating with DALI devices over a serial connection."""
17+
18+
def __init__(self, port="/dev/ttyS0", LOG=None):
19+
"""Initialize the serial connection to the DALI interface."""
20+
self.port = port
21+
self.lock = threading.RLock()
22+
self.buffer = []
23+
if not LOG:
24+
self.LOG = logging.getLogger("AtxLedDaliDriver")
25+
handler = logging.StreamHandler(sys.stdout)
26+
self.LOG.addHandler(handler)
27+
else:
28+
self.LOG = LOG
29+
try:
30+
self.conn = serial.Serial(
31+
port=self.port,
32+
baudrate=19200,
33+
parity=serial.PARITY_NONE,
34+
stopbits=serial.STOPBITS_ONE,
35+
bytesize=serial.EIGHTBITS,
36+
timeout=0.2,
37+
)
38+
except Exception as e:
39+
self.LOG.exception("Could not open serial connection: %s", e)
40+
self.conn = None
41+
42+
def read_line(self):
43+
"""Read the next line from the buffer, refilling the buffer if necessary."""
44+
with self.lock:
45+
while not self.buffer:
46+
line = self.conn.read_until(b"\n").decode("ascii")
47+
if not line:
48+
return ""
49+
self.buffer.append(line)
50+
return self.buffer.pop(0)
51+
52+
def construct(self, command):
53+
"""Construct a DALI command to be sent over the serial connection."""
54+
assert isinstance(command, Command)
55+
f = command.frame
56+
packet_size = len(f)
57+
prefix = DALI_PACKET_PREFIX[packet_size]
58+
if command.sendtwice and packet_size == 16:
59+
prefix = "t"
60+
data = "".join(["{:02X}".format(byte) for byte in f.pack])
61+
command_str = (f"{prefix}{data}\n").encode("ascii")
62+
return command_str
63+
64+
def extract(self, data):
65+
"""Parse the response from the serial device and return the corresponding frame."""
66+
if data.startswith("J"):
67+
try:
68+
data = int(data[1:], 16)
69+
return BackwardFrame(data)
70+
except ValueError as e:
71+
self.LOG.error(f"Failed to parse response '{data}': {e}")
72+
return None
73+
74+
def close(self):
75+
"""Close the serial connection."""
76+
if self.conn:
77+
self.conn.close()
78+
79+
80+
class SyncDaliHatDriver(DaliHatSerialDriver, SyncDALIDriver):
81+
"""Synchronous DALI driver."""
82+
83+
def send(self, command: Command):
84+
"""Send a command to the DALI interface and wait for a response."""
85+
with self.lock:
86+
lines = []
87+
last_resp = None
88+
send_twice = command.sendtwice
89+
cmd = self.construct(command)
90+
self.LOG.debug("command string sent: %r", cmd)
91+
self.conn.write(cmd)
92+
REPS = 5
93+
i = 0
94+
already_resent = False
95+
resent_times = 0
96+
resp = None
97+
while i < REPS:
98+
i += 1
99+
resp = self.read_line()
100+
self.LOG.debug("raw response received: %r", resp)
101+
resend = False
102+
if cmd[:3] not in ["hB1", "hB3", "hB5"]:
103+
if resp and resp[0] in {"N", "J"}:
104+
if send_twice:
105+
if last_resp:
106+
if last_resp == resp:
107+
resp = self.extract(resp)
108+
break
109+
resend = True
110+
last_resp = None
111+
else:
112+
last_resp = resp
113+
else:
114+
resp = self.extract(resp)
115+
break
116+
elif resp and resp[0] in {"X", "Z", ""}:
117+
time.sleep(0.1)
118+
collision_bytes = None
119+
while collision_bytes != "":
120+
collision_bytes = self.read_line()
121+
if resp[0] == "X":
122+
break
123+
self.LOG.info(
124+
"got conflict (%s) sending %r, sending again", resp, cmd
125+
)
126+
last_resp = None
127+
resend = True
128+
elif resp:
129+
lines.append(resp)
130+
131+
resp = None
132+
if resend and not already_resent:
133+
self.conn.write((cmd).encode("ascii"))
134+
REPS += 1 + send_twice
135+
already_resent = True
136+
else:
137+
if resp and resp[0] == "N":
138+
resp = self.extract(resp)
139+
break
140+
elif resp and resp[0] in {"X", "Z", ""}:
141+
time.sleep(0.1)
142+
collision_bytes = None
143+
while collision_bytes != "":
144+
collision_bytes = self.read_line()
145+
elif resp:
146+
last_resp = None
147+
resend = True
148+
if resend and resent_times < 5:
149+
self.conn.write(cmd.encode("ascii"))
150+
REPS += 1 + send_twice
151+
resent_times += 1
152+
if command.is_query:
153+
return command.response(resp)
154+
return resp

examples/sync-dalihat.py

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
#!/usr/bin/env python3
2+
3+
from dali.gear.general import (
4+
DAPC,
5+
QueryControlGearPresent,
6+
QueryGroupsZeroToSeven,
7+
QueryGroupsEightToFifteen,
8+
QueryActualLevel,
9+
Off,
10+
QueryMinLevel,
11+
QueryMaxLevel,
12+
QueryPhysicalMinimum,
13+
)
14+
from dali.driver.base import SyncDALIDriver
15+
from dali.driver.atxled import SyncDaliHatDriver
16+
from dali.address import GearShort
17+
18+
import logging
19+
20+
21+
LOG = logging.getLogger("DaliHatTest")
22+
23+
24+
class DaliHatTest:
25+
def __init__(self, driver: SyncDALIDriver):
26+
self.driver = driver
27+
28+
def scan_devices(self):
29+
present_devices = []
30+
for address in range(0, 64):
31+
try:
32+
response = self.driver.send(QueryControlGearPresent(GearShort(address)))
33+
if response.value is True:
34+
present_devices.append(address)
35+
LOG.info(f"Device found at address: {address}")
36+
else:
37+
LOG.info(f"Response from address {address}: {response.value}")
38+
39+
except Exception as e:
40+
LOG.info(f"Error while querying address {address}: {e}")
41+
42+
return present_devices
43+
44+
def set_device_level(self, address, level, fade_time=0):
45+
try:
46+
self.driver.send(DAPC(GearShort(address), level))
47+
LOG.info(
48+
f"Set device at address {address} to level {level} with fade time {fade_time}"
49+
)
50+
except Exception as e:
51+
LOG.info(f"Error while setting level for address {address}: {e}")
52+
53+
def query_device_info(self, address):
54+
current_command = None
55+
try:
56+
current_command = "QueryGroupsZeroToSeven"
57+
groups_0_7 = self.driver.send(
58+
QueryGroupsZeroToSeven(GearShort(address))
59+
).value
60+
LOG.info(f"Device {address} groups 0-7: {groups_0_7}")
61+
62+
current_command = "QueryGroupsEightToFifteen"
63+
groups_8_15 = self.driver.send(
64+
QueryGroupsEightToFifteen(GearShort(address))
65+
).value
66+
LOG.info(f"Device {address} groups 8-15: {groups_8_15}")
67+
68+
current_command = "QueryMinLevel"
69+
min_level = self.driver.send(QueryMinLevel(GearShort(address))).value
70+
LOG.info(f"Device {address} minimum level: {min_level}")
71+
72+
current_command = "QueryMaxLevel"
73+
max_level = self.driver.send(QueryMaxLevel(GearShort(address))).value
74+
LOG.info(f"Device {address} maximum level: {max_level}")
75+
76+
current_command = "QueryPhysicalMinimum"
77+
physical_minimum = self.driver.send(
78+
QueryPhysicalMinimum(GearShort(address))
79+
).value
80+
LOG.info(f"Device {address} physical minimum: {physical_minimum}")
81+
82+
current_command = "QueryActualLevel"
83+
actual_level = self.driver.send(QueryActualLevel(GearShort(address))).value
84+
LOG.info(f"Device {address} actual level: {actual_level}")
85+
86+
except Exception as e:
87+
LOG.info(
88+
f"Error while querying device {address} with command '{current_command}': {e}"
89+
)
90+
91+
def turn_off_device(self, address):
92+
try:
93+
self.driver.send(Off(GearShort(address)))
94+
LOG.info(f"Turned off device at address {address}")
95+
except Exception as e:
96+
LOG.info(f"Error while turning off device {address}: {e}")
97+
98+
99+
if __name__ == "__main__":
100+
logging.basicConfig(level=logging.INFO)
101+
dali_driver = SyncDaliHatDriver()
102+
103+
dali_test = DaliHatTest(dali_driver)
104+
found_devices = []
105+
106+
found_devices = dali_test.scan_devices()
107+
LOG.info(f"Scanned and found {len(found_devices)} devices.")
108+
109+
for device in found_devices:
110+
dali_test.query_device_info(device)
111+
dali_test.set_device_level(device, 128)
112+
dali_test.turn_off_device(device)
113+
114+
dali_driver.close()

0 commit comments

Comments
 (0)