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.
- dalicontrol_e64/__init__.py +7 -0
- dalicontrol_e64/commands.py +114 -0
- dalicontrol_e64/http.py +78 -0
- dalicontrol_e64/knx.py +98 -0
- dalicontrol_e64/writer.py +172 -0
- dalicontrol_e64-0.2.0.dist-info/METADATA +164 -0
- dalicontrol_e64-0.2.0.dist-info/RECORD +10 -0
- dalicontrol_e64-0.2.0.dist-info/WHEEL +5 -0
- dalicontrol_e64-0.2.0.dist-info/licenses/LICENSE +21 -0
- dalicontrol_e64-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -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)
|
dalicontrol_e64/http.py
ADDED
|
@@ -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,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
|