dalicontrol-e64 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """Unofficial tools for the IPAS DaliControl e64 KNX/DALI gateway."""
2
+ from . import commands
3
+ from .http import CommandRejected, HttpClient
4
+ from .writer import Macro, Writer
5
+
6
+ __all__ = ["commands", "CommandRejected", "HttpClient", "Macro", "Writer"]
7
+ __version__ = "0.2.0"
@@ -0,0 +1,114 @@
1
+ """Three-byte command encoding for the IPAS DaliControl e64 (firmware 1.3_06).
2
+
3
+ Every command is three bytes, written as six hex characters. The same bytes
4
+ travel over both transports (HTTP CGI and KNX property write).
5
+
6
+ Encoders here cover only *operational* commands: things a resident could also
7
+ trigger from a wall switch. Programming commands are listed in ``DANGEROUS``
8
+ so callers can refuse them; nothing in this package sends them by accident.
9
+ """
10
+ from __future__ import annotations
11
+
12
+ MAX_CHANNELS = 64
13
+ MAX_GROUPS = 16
14
+ MAX_SCENES = 16
15
+
16
+ ALL_OFF = "0A0000"
17
+ ALL_ON = "0BFFFF" # level broadcast at 100 %. "0B0000" is acknowledged but does nothing.
18
+
19
+ # First command byte -> (name, effect). Never send these to "test the connection".
20
+ DANGEROUS = {
21
+ 0x01: ("NEWINST", "DALI new installation: re-addresses every ballast, wipes groups"),
22
+ 0x02: ("CONFIG", "post-installation: assigns addresses to unaddressed ballasts"),
23
+ 0x05: ("SETGROUP", "writes a group assignment into the device"),
24
+ 0x06: ("PSCENE", "stores the current live levels as a scene"),
25
+ 0x0D: ("TUNNEL", "raw DALI passthrough, behaviour unknown"),
26
+ 0x0E: ("SWAP", "swaps the short addresses of two ballasts"),
27
+ 0x10: ("RHRESET", "resets operating-hours counters"),
28
+ 0x11: ("BURNIN", "burn-in mode, lamps at full load"),
29
+ 0x12: ("FXUPDATE", "writes effect configuration"),
30
+ 0x13: ("REPLACE", "single ballast replacement, touches the address table"),
31
+ 0x14: ("CFT", "converter function test"),
32
+ 0x15: ("SDT", "converter duration test"),
33
+ 0x16: ("LDT", "converter long duration test"),
34
+ 0x17: ("BRT", "converter battery test"),
35
+ 0x1B: ("TPLSYNC", "template sync"),
36
+ 0x20: ("ECGMODE", "changes ballast mode flags"),
37
+ 0x80: ("TPLRUN", "runs a configured template"),
38
+ }
39
+
40
+
41
+ def _check(name: str, value: int, low: int, high: int) -> int:
42
+ if type(value) is not int or not low <= value <= high:
43
+ raise ValueError(f"{name} must be an integer {low}..{high}, got {value!r}")
44
+ return value
45
+
46
+
47
+ def level(channel: int, value: int) -> str:
48
+ """Set one channel (1..64) to a level 0..255. 0 is off, 255 is 100 %."""
49
+ _check("channel", channel, 1, MAX_CHANNELS)
50
+ _check("value", value, 0, 255)
51
+ return f"0B{channel - 1:02X}{value:02X}"
52
+
53
+
54
+ def level_all(value: int) -> str:
55
+ """Set every ballast on the bus to one level with a single broadcast."""
56
+ return f"0BFF{_check('value', value, 0, 255):02X}"
57
+
58
+
59
+ def group_level(group: int, value: int) -> str:
60
+ """Set one DALI group (1..16) to a level. Derived from the web UI, not field-tested."""
61
+ _check("group", group, 1, MAX_GROUPS)
62
+ _check("value", value, 0, 255)
63
+ return f"08{group - 1:02X}{value:02X}"
64
+
65
+
66
+ def scene_recall(scene: int) -> str:
67
+ """Recall scene slot 0..15. A bus broadcast: every member ballast moves."""
68
+ return f"07{_check('scene', scene, 0, MAX_SCENES - 1):02X}00"
69
+
70
+
71
+ def blink(channel: int) -> str:
72
+ """Blink one channel (1..64) a few times, to find out which lamp it is."""
73
+ return f"04{0x40 + _check('channel', channel, 1, MAX_CHANNELS) - 1:02X}00"
74
+
75
+
76
+ def parse(command: str) -> bytes:
77
+ """Return the three command bytes or raise ValueError."""
78
+ if not isinstance(command, str) or len(command) != 6:
79
+ raise ValueError("a command is exactly six hex characters")
80
+ try:
81
+ return bytes.fromhex(command)
82
+ except ValueError:
83
+ raise ValueError("a command is exactly six hex characters") from None
84
+
85
+
86
+ def is_operational(command: str) -> bool:
87
+ """True for level writes, scene recalls and all-off; nothing else.
88
+
89
+ This is an allowlist on purpose. The encoding gives no hint which values
90
+ are harmless: ``010000`` looks like a no-op and starts a new installation.
91
+ """
92
+ try:
93
+ b = parse(command)
94
+ except ValueError:
95
+ return False
96
+ return (
97
+ b == b"\x0a\x00\x00"
98
+ or (b[0] == 0x0B and (b[1] < MAX_CHANNELS or b[1] == 0xFF))
99
+ or (b[0] == 0x07 and b[1] < MAX_SCENES and b[2] == 0)
100
+ )
101
+
102
+
103
+ def require_operational(command: str) -> bytes:
104
+ if not is_operational(command):
105
+ b = None
106
+ try:
107
+ b = parse(command)
108
+ except ValueError:
109
+ pass
110
+ if b is not None and b[0] in DANGEROUS:
111
+ name, effect = DANGEROUS[b[0]]
112
+ raise ValueError(f"{command} is {name}: {effect}")
113
+ raise ValueError(f"{command!r} is not a level, scene-recall or all-off command")
114
+ return parse(command)
@@ -0,0 +1,78 @@
1
+ """HTTP transport: the CGI endpoint behind the device's own web page.
2
+
3
+ Standard library only. Blocking; run it in a thread if you need async.
4
+ See docs/http-api.md for the wire format and docs/lessons-learned.md for why
5
+ a timeout must never be answered with a blind retry of a write.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ import urllib.request
11
+
12
+ from .commands import require_operational
13
+
14
+ WRITE_COMMAND = "F6C35101DA00010001" # write, DALI object, property DA, count 1, start 1
15
+ READ = "FCC35101"
16
+
17
+
18
+ class CommandRejected(RuntimeError):
19
+ """The device answered, but not with ``00``."""
20
+
21
+
22
+ class HttpClient:
23
+ def __init__(self, host: str, *, timeout: float = 20, opener=urllib.request.urlopen):
24
+ self._base = f"http://{host}/?x="
25
+ self._timeout = timeout
26
+ self._open = opener
27
+
28
+ def _get(self, payload: str) -> str:
29
+ # z is only a cache buster, exactly as the device's own page sends it.
30
+ url = f"{self._base}{payload}&z={int(time.time())}"
31
+ request = urllib.request.Request(url, headers={"Connection": "close"})
32
+ with self._open(request, timeout=self._timeout) as response:
33
+ if response.status != 200:
34
+ raise CommandRejected(f"HTTP {response.status}")
35
+ return response.read().decode("ascii", "replace")
36
+
37
+ def send(self, command: str) -> None:
38
+ """Send one operational command. HTTP 200 alone is not success: the body must be ``00``."""
39
+ require_operational(command)
40
+ body = self._get(WRITE_COMMAND + command.upper()).strip()
41
+ if body != "00":
42
+ raise CommandRejected(f"device answered {body[:80]!r} to {command}")
43
+
44
+ def read_property(self, pid: str, count: int, start: int = 1) -> list[str]:
45
+ """Read ``count`` items of a property; one hex string per item."""
46
+ if len(pid) != 2:
47
+ raise ValueError("property id is two hex characters, e.g. 'CD'")
48
+ int(pid, 16)
49
+ body = self._get(f"{READ}{pid.upper()}{count:04X}{start:04X}")
50
+ return [line.strip() for line in body.split("\n") if line.strip()]
51
+
52
+ def reachable(self) -> bool:
53
+ """Cheap read-only liveness check (general failure flags, served from cache).
54
+
55
+ Use this, never a write, when you only want to know whether the device answers.
56
+ """
57
+ try:
58
+ return bool(self.read_property("D2", 1))
59
+ except (OSError, CommandRejected):
60
+ return False
61
+
62
+ def cached_levels(self) -> list[int | None]:
63
+ """Per-channel level from the device's cache; ``None`` where the cache is empty.
64
+
65
+ The cache is not the lamp. After programming writes it reads all-zero for
66
+ lamps that are visibly on. Do not use it to skip "unchanged" commands.
67
+ """
68
+ levels: list[int | None] = []
69
+ for line in self.read_property("CD", 64):
70
+ levels.append(None if line[:6] == "000000" else int(line[6:8], 16))
71
+ return levels
72
+
73
+ def scene_levels(self, scene: int) -> list[int]:
74
+ """Stored levels of one scene slot (0..15) for channels 1..64. Reliable right after programming."""
75
+ if not 0 <= scene <= 15:
76
+ raise ValueError("scene slot is 0..15")
77
+ lines = self.read_property("D7", 8, scene * 8 + 1)
78
+ return [int(line[i:i + 2], 16) for line in lines for i in range(0, 16, 2)]
dalicontrol_e64/knx.py ADDED
@@ -0,0 +1,98 @@
1
+ """KNX transport: the same three-byte commands, without the device's Ethernet port.
2
+
3
+ The gateway exposes a manufacturer-specific interface object (index 6, object
4
+ type 0xC352). Writing three bytes to its property 218 (0xDA) executes them
5
+ exactly like the HTTP CGI does. The telegrams are individually addressed
6
+ management telegrams, so no group address and no ETS change is needed.
7
+
8
+ Requires ``xknx`` and an already connected ``XKNX`` instance (for example the
9
+ one Home Assistant's KNX integration owns). No second tunnel is opened.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import time
15
+ from typing import Callable
16
+
17
+ from xknx import XKNX
18
+ from xknx.telegram import IndividualAddress
19
+ from xknx.telegram.apci import PropertyValueRead, PropertyValueResponse, PropertyValueWrite
20
+
21
+ from .commands import require_operational
22
+
23
+ OBJECT_INDEX = 6
24
+ OBJECT_TYPE = bytes.fromhex("c352")
25
+ PID_OBJECT_TYPE = 1
26
+ PID_COMMAND = 218
27
+
28
+
29
+ class KnxTransport:
30
+ """One short management connection per command, nothing while idle.
31
+
32
+ - The interface object type is verified once per bus connection before the
33
+ first write, so a wrong individual address fails instead of writing
34
+ property 218 on some other device.
35
+ - The response must echo object, property, index, count and the bytes.
36
+ - ``interval`` spaces transactions: one command is several telegrams on a
37
+ 9600 baud bus. Two commands per second proved reliable.
38
+ - A failed command is never retried here. The caller decides.
39
+ """
40
+
41
+ def __init__(self, provider: Callable[[], XKNX], address: str, *,
42
+ interval: float = 0.5, timeout: float = 8, cleanup_timeout: float = 2):
43
+ self._provider = provider
44
+ self._address = IndividualAddress(address)
45
+ self._interval = interval
46
+ self._timeout = timeout
47
+ self._cleanup_timeout = cleanup_timeout
48
+ self._next_send = 0.0
49
+ self._lock = asyncio.Lock()
50
+ self._verified: tuple[XKNX, object] | None = None
51
+
52
+ @staticmethod
53
+ def _check(response, pid: int, expected: bytes) -> None:
54
+ value = response.payload
55
+ if (value.object_index, value.property_id, value.start_index,
56
+ value.count, value.data) != (OBJECT_INDEX, pid, 1, 1, expected):
57
+ raise RuntimeError(f"device rejected or mismatched KNX property {pid}")
58
+
59
+ async def send(self, command: str) -> None:
60
+ payload = require_operational(command)
61
+ async with self._lock:
62
+ await asyncio.sleep(max(0, self._next_send - time.monotonic()))
63
+ xknx = self._provider() # resolve each time: the owner may have reconnected
64
+ if not xknx.connection_manager.connected.is_set():
65
+ raise RuntimeError("KNX connection is unavailable")
66
+ generation = (xknx, xknx.connection_manager.connected_since)
67
+ connection = None
68
+ failure = None
69
+ try:
70
+ async with asyncio.timeout(self._timeout):
71
+ connection = await xknx.management.connect(self._address, rate_limit=2)
72
+ if self._verified != generation:
73
+ response = await connection.request(PropertyValueRead(
74
+ object_index=OBJECT_INDEX, property_id=PID_OBJECT_TYPE,
75
+ count=1, start_index=1), PropertyValueResponse)
76
+ self._check(response, PID_OBJECT_TYPE, OBJECT_TYPE)
77
+ self._verified = generation
78
+ response = await connection.request(PropertyValueWrite(
79
+ object_index=OBJECT_INDEX, property_id=PID_COMMAND,
80
+ count=1, start_index=1, data=payload), PropertyValueResponse)
81
+ self._check(response, PID_COMMAND, payload)
82
+ except BaseException as exc:
83
+ self._verified = None
84
+ failure = exc
85
+ raise
86
+ finally:
87
+ try:
88
+ if connection is not None:
89
+ try:
90
+ await asyncio.wait_for(
91
+ xknx.management.disconnect(self._address),
92
+ self._cleanup_timeout)
93
+ except Exception:
94
+ self._verified = None
95
+ if failure is None:
96
+ raise
97
+ finally:
98
+ self._next_send = time.monotonic() + self._interval
@@ -0,0 +1,172 @@
1
+ """Single writer with last-request-wins per channel.
2
+
3
+ Tracks pending intentions, never physical lamp state. Transport-agnostic:
4
+ ``send`` is any coroutine taking a six-character command (HTTP or KNX).
5
+ See docs/lessons-learned.md for why a plain FIFO was not good enough.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from collections import deque
11
+ from dataclasses import dataclass, field
12
+ import time
13
+ from typing import Awaitable, Callable
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class Macro:
18
+ command: str
19
+ targets: dict[int, int]
20
+
21
+
22
+ @dataclass
23
+ class Job:
24
+ number: int
25
+ label: str
26
+ targets: dict[int, int]
27
+ remaining: dict[int, int]
28
+ macro: Macro | None
29
+ resets: list[Macro]
30
+ future: asyncio.Future
31
+ started: float = field(default_factory=time.monotonic)
32
+ sent: list[str] = field(default_factory=list)
33
+
34
+
35
+ class Writer:
36
+ """All methods run on one event loop; admission has no await/race window.
37
+
38
+ A cancelled caller cannot cancel the worker. A one-command native request
39
+ runs at its original FIFO position, before any newer overlapping request.
40
+ Optional resets inside multi-command jobs require full footprint ownership.
41
+ """
42
+
43
+ def __init__(self, send: Callable[[str], Awaitable[None]], completed=None):
44
+ self.send = send
45
+ self.completed = completed
46
+ self.owners: dict[int, int] = {}
47
+ self.pending: deque[Job] = deque()
48
+ self.sequence = 0
49
+ self.task: asyncio.Task | None = None
50
+ self.active: Job | None = None
51
+ self.closed = False
52
+ self.history = deque(maxlen=80)
53
+ self.error: str | None = None
54
+
55
+ def owns(self, job, channels):
56
+ return all(self.owners.get(c) == job.number for c in channels)
57
+
58
+ def submit(self, targets, *, label='', macro=None, resets=()):
59
+ if self.closed:
60
+ raise RuntimeError('writer is shutting down')
61
+ targets = dict(targets)
62
+ if not targets or any(type(c) is not int or not 1 <= c <= 64 or
63
+ type(v) is not int or not 0 <= v <= 255
64
+ for c, v in targets.items()):
65
+ raise ValueError('Expected channel 1..64 and value 0..255')
66
+ if macro and macro.targets != targets:
67
+ raise ValueError('Native command footprint must equal its target map')
68
+ for reset in resets:
69
+ if not reset.targets or not set(reset.targets) <= targets.keys() or any(reset.targets.values()):
70
+ raise ValueError('Reset must be a zero-valued subset of request')
71
+ self.sequence += 1
72
+ job = Job(self.sequence, label, targets, dict(targets), macro,
73
+ list(resets), asyncio.get_running_loop().create_future())
74
+ self.owners.update(dict.fromkeys(targets, job.number))
75
+ # Remove superseded queued work now, not after draining a long FIFO.
76
+ for old in list(self.pending):
77
+ old.remaining = {c:v for c,v in old.remaining.items() if self.owns(old, [c])}
78
+ if not old.remaining:
79
+ self.pending.remove(old)
80
+ self._finish(old, 'superseded')
81
+ self.pending.append(job)
82
+ self.error = None # a fresh explicit request is allowed after a failure
83
+ if self.task is None or self.task.done():
84
+ self.task = asyncio.create_task(self._run(), name='dalicontrol-writer')
85
+ return job
86
+
87
+ def _finish(self, job, status, error=None):
88
+ result = {'request':job.number, 'label':job.label, 'status':status,
89
+ 'seconds':round(time.monotonic()-job.started, 3),
90
+ 'commands':list(job.sent), 'target_count':len(job.targets)}
91
+ if error:
92
+ result['error'] = error
93
+ self.history.append(result)
94
+ if not job.future.done():
95
+ job.future.set_result(result)
96
+
97
+ def _command(self, job):
98
+ job.remaining = {c:v for c,v in job.remaining.items() if self.owns(job, [c])}
99
+ if not job.remaining:
100
+ return None
101
+ macro, job.macro = job.macro, None
102
+ if macro:
103
+ # A macro is the FIRST and ONLY command of its job. Its FIFO
104
+ # position precedes all newer jobs, so their overrides follow it.
105
+ # Never expand native scene read-back into individual writes:
106
+ # those values are not independently verified ECG memory.
107
+ return macro.command, dict(job.remaining)
108
+ # A reset is optional; retain exact individual fallback if unsafe or
109
+ # not cheaper. Acknowledged zeros are removed only from this job.
110
+ while job.resets:
111
+ reset = job.resets.pop(0)
112
+ scope = reset.targets.keys()
113
+ if set(scope) <= job.remaining.keys() and self.owns(job, scope):
114
+ zeros = {c:0 for c in scope if job.targets[c] == 0}
115
+ if len(zeros) > 1:
116
+ return reset.command, zeros
117
+ c = next(iter(job.remaining))
118
+ return f'0B{c-1:02X}{job.remaining[c]:02X}', {c:job.remaining[c]}
119
+
120
+ async def _run(self):
121
+ try:
122
+ while self.pending and not self.closed:
123
+ job = self.pending.popleft()
124
+ self.active = job
125
+ command = self._command(job)
126
+ if command is None:
127
+ self._finish(job, 'superseded')
128
+ continue
129
+ payload, fulfilled = command
130
+ try:
131
+ # Only this task invokes send. Never tied to caller task.
132
+ await self.send(payload)
133
+ job.sent.append(payload)
134
+ for c in fulfilled:
135
+ job.remaining.pop(c, None)
136
+ except Exception as exc:
137
+ self.error = f'{type(exc).__name__}: {exc}'
138
+ self._finish(job, 'failed', self.error)
139
+ # Unknown output after a failed write: no dependent sparse
140
+ # continuation or automatic retry, no replay of queued jobs.
141
+ while self.pending:
142
+ self._finish(self.pending.popleft(), 'aborted', self.error)
143
+ break
144
+ if self.closed and job.remaining:
145
+ self._finish(job, 'aborted', 'Writer shutdown')
146
+ continue
147
+ if job.remaining and not self.closed:
148
+ self.pending.append(job) # other rooms get a turn
149
+ else:
150
+ status = 'completed' if self.owns(job, job.targets) else 'partial'
151
+ if self.completed is not None:
152
+ try:
153
+ await self.completed(job)
154
+ except Exception as exc:
155
+ self.error = f'Completion callback: {exc}'
156
+ self._finish(job, 'failed', self.error)
157
+ continue
158
+ self._finish(job, status)
159
+ finally:
160
+ self.active = None
161
+
162
+ async def close(self):
163
+ self.closed = True
164
+ while self.pending:
165
+ self._finish(self.pending.popleft(), 'aborted', 'Writer shutdown')
166
+ if self.task:
167
+ await asyncio.shield(self.task) # finish the active command, never restart it
168
+
169
+ def status(self):
170
+ return {'active':self.active.number if self.active else None,
171
+ 'pending':len(self.pending), 'error':self.error,
172
+ 'history':list(self.history)}
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: dalicontrol-e64
3
+ Version: 0.2.0
4
+ Summary: Unofficial protocol notes and Python tools for the IPAS DaliControl e64 KNX/DALI gateway
5
+ Author: NashEQify
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/NashEQify/dalicontrol-e64
8
+ Project-URL: Documentation, https://github.com/NashEQify/dalicontrol-e64/tree/main/docs
9
+ Project-URL: Issues, https://github.com/NashEQify/dalicontrol-e64/issues
10
+ Keywords: dali,knx,ipas,dalicontrol,e64,lighting,home-assistant,home-automation
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Framework :: AsyncIO
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Topic :: Home Automation
15
+ Requires-Python: >=3.11
16
+ Description-Content-Type: text/markdown
17
+ License-File: LICENSE
18
+ Provides-Extra: knx
19
+ Requires-Dist: xknx>=3; extra == "knx"
20
+ Dynamic: license-file
21
+
22
+ # dalicontrol-e64
23
+
24
+ Unofficial protocol notes, field experience and a small Python library for the
25
+ **IPAS DaliControl e64** KNX/DALI gateway.
26
+
27
+ The gateway is usually commissioned once with ETS and then only reached
28
+ through KNX group addresses. It can do more: its built-in web page uses a plain
29
+ HTTP interface, and the same commands can be sent over KNX without any ETS
30
+ change. Neither is documented by the manufacturer. This repository writes down
31
+ what was found while building a lighting control for a flat with 38 DALI
32
+ ballasts, including the parts that went wrong.
33
+
34
+ Use it with or without a home automation system.
35
+
36
+ ## No ETS, no DCA, no KNX software
37
+
38
+ For working with the DALI side you do not need ETS, the DCA plug-in, an ETS
39
+ licence or any other KNX vendor software. Switching and dimming channels,
40
+ assigning groups, storing and recalling scenes and starting a DALI new
41
+ installation all work with this repository alone: over HTTP with a browser,
42
+ curl or Python, or over KNX with a free KNXnet/IP library such as
43
+ [xknx](https://github.com/XKNX/xknx).
44
+
45
+ ETS is only needed for the KNX side of the gateway: its individual address,
46
+ group objects and KNX application parameters. If the gateway was commissioned
47
+ once, you can leave that untouched. If you only use the HTTP interface, the
48
+ KNX side does not matter at all.
49
+
50
+ > **Tested on one device type and firmware only:** DaliControl e64, firmware
51
+ > 1.3_06, ETS application e64-01-0110. Not affiliated with or endorsed by
52
+ > IPAS. Several commands re-address or reconfigure your installation and have
53
+ > no confirmation step. Read [the command table](docs/http-api.md#commands)
54
+ > before you send anything. You act at your own risk.
55
+
56
+ ## What is in here
57
+
58
+ | | |
59
+ |---|---|
60
+ | [docs/http-api.md](docs/http-api.md) | The HTTP interface: request format, every command with its risk, readable properties, timing |
61
+ | [docs/knx-management.md](docs/knx-management.md) | The same commands over KNX, with the gateway's LAN cable unplugged; reading ETS parameters without ETS |
62
+ | [docs/scenes.md](docs/scenes.md) | How DALI scenes are stored and recalled, and how to use them from software |
63
+ | [docs/lessons-learned.md](docs/lessons-learned.md) | What failed and what replaced it |
64
+ | [docs/home-automation.md](docs/home-automation.md) | Home Assistant, Node-RED, openHAB, ioBroker: from one HTTP request to a full integration |
65
+ | `src/dalicontrol_e64/` | Python library on PyPI: command encoding with an allowlist, HTTP client, KNX transport, request queue |
66
+ | `custom_components/dalicontrol_e64/` | Home Assistant integration (HACS): one light per channel, scene recall, all on/off, HTTP or KNX |
67
+
68
+ ## Five things to know first
69
+
70
+ 1. `010000` starts a DALI new installation. Nothing about the bytes tells you.
71
+ 2. The answer `00` means "accepted". It does not mean a lamp reacted.
72
+ 3. The channel-state read is a cache and is wrong after programming writes.
73
+ 4. One scene recall beats twenty single-channel writes. Use the 16 slots.
74
+ 5. If lights react late or not at all, check whether the gateway restarts
75
+ itself every few minutes while its Ethernet port is connected.
76
+
77
+ ## Home Assistant
78
+
79
+ Add this repository in HACS as a custom repository (type *Integration*),
80
+ install *DaliControl e64*, restart, then add the integration with the
81
+ gateway's IP address or its KNX individual address. Details, a YAML-only
82
+ alternative and examples for Node-RED, openHAB and ioBroker:
83
+ [docs/home-automation.md](docs/home-automation.md).
84
+
85
+ ## Quick start (Python)
86
+
87
+ ```bash
88
+ pip install dalicontrol-e64
89
+ ```
90
+
91
+ ```python
92
+ from dalicontrol_e64 import HttpClient, commands
93
+
94
+ gateway = HttpClient("192.0.2.10") # the gateway's IP address
95
+ print(gateway.reachable()) # read-only check
96
+
97
+ gateway.send(commands.level(4, 255)) # channel 4 to 100 %
98
+ gateway.send(commands.level(4, 0)) # channel 4 off
99
+ gateway.send(commands.scene_recall(5)) # recall scene slot 5
100
+ gateway.send(commands.ALL_OFF)
101
+ ```
102
+
103
+ `send` refuses everything that is not a level, a scene recall or all-off, and
104
+ raises unless the gateway answers `00`.
105
+
106
+ With curl, no library needed:
107
+
108
+ ```bash
109
+ curl -sS --max-time 15 'http://192.0.2.10/?x=F6C35101DA000100010B03FF' # channel 4 on
110
+ ```
111
+
112
+ ### Over KNX
113
+
114
+ ```bash
115
+ pip install "dalicontrol-e64[knx]"
116
+ ```
117
+
118
+ ```python
119
+ from dalicontrol_e64.knx import KnxTransport
120
+
121
+ transport = KnxTransport(lambda: xknx, "1.1.2") # connected XKNX, gateway's individual address
122
+ await transport.send(commands.scene_recall(5))
123
+ ```
124
+
125
+ ### Many callers, one gateway
126
+
127
+ `Writer` serialises requests from switches, apps and automations. A newer
128
+ request replaces older waiting targets for the same channels; a failed write
129
+ aborts what is waiting instead of retrying.
130
+
131
+ ```python
132
+ from dalicontrol_e64 import Writer, Macro, commands
133
+
134
+ writer = Writer(transport.send) # or an async wrapper around HttpClient.send
135
+ job = writer.submit({14: 0, 15: 0, 16: 0, 17: 200}, label="dining strip white")
136
+ result = await job.future # {'status': 'completed', 'commands': [...], ...}
137
+
138
+ room = {c: 0 for c in range(20, 28)}
139
+ writer.submit(room, macro=Macro(commands.scene_recall(8), dict(room))) # one command
140
+ ```
141
+
142
+ The Home Assistant integration in `custom_components/` is built on exactly
143
+ these two parts and reuses the KNX integration's connection.
144
+
145
+ ## Tests
146
+
147
+ ```bash
148
+ PYTHONPATH=src python3 -m unittest discover -s tests # library
149
+ pip install pytest-homeassistant-custom-component xknx -e .
150
+ python3 -m pytest # Home Assistant integration (Python 3.13)
151
+ ```
152
+
153
+ The tests run against a simulated device. They prove the encoding, the
154
+ allowlist and the queue logic, not the behaviour of your hardware.
155
+
156
+ ## Contributing
157
+
158
+ Reports from other firmware versions, the e64 Pro, or decoded error flags and
159
+ properties are welcome. Please say what you observed on the lamps, not only
160
+ what the gateway answered.
161
+
162
+ ## License
163
+
164
+ MIT, see [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ dalicontrol_e64/__init__.py,sha256=lPMTvP0SokjmkjGFSozppIBraOlEYGdGTpvrIZRr2UI,271
2
+ dalicontrol_e64/commands.py,sha256=zSO_SCGy9F2RCzNLSNA57XsVockjQYNJ7qxCEhFXrYs,4430
3
+ dalicontrol_e64/http.py,sha256=_u0xbeOL8PG3YFaYTvAYjqp9hyx8DnqLSLgNx9zC_tA,3332
4
+ dalicontrol_e64/knx.py,sha256=3Lbhfs8pDJtZh1SOPw-ZBysivivbopQ1E7SAPT3EPWk,4480
5
+ dalicontrol_e64/writer.py,sha256=u4uXOXTP5Y74lCHBiwJPE-Ct69ctU5hlXXQwbAaAmhQ,7414
6
+ dalicontrol_e64-0.2.0.dist-info/licenses/LICENSE,sha256=uMG5xAPM-U5RHOH8yUfGYV-gucXrNOnbS8MTzYoctfU,1066
7
+ dalicontrol_e64-0.2.0.dist-info/METADATA,sha256=wSMUYLhKh20DWe_EntZ6LT0fY56F1lt7ND4Vg0sPiMg,6765
8
+ dalicontrol_e64-0.2.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
9
+ dalicontrol_e64-0.2.0.dist-info/top_level.txt,sha256=nQImJR8WbzFHOyZdsCIM6hPM-MLry4BcapdEezoZB6w,16
10
+ dalicontrol_e64-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 NashEQify
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ dalicontrol_e64