pytydom 0.1.0__tar.gz

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.
pytydom-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Repository Maintainer Council
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.
pytydom-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytydom
3
+ Version: 0.1.0
4
+ Summary: Home-Assistant-independent client for Delta Dore Tydom gateways
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: aiohttp>=3.9
10
+ Dynamic: license-file
11
+
12
+ # pytydom
13
+
14
+ A Home-Assistant-independent Python client for **Delta Dore Tydom**
15
+ gateways. It provides:
16
+
17
+ - the wire **protocol** — HTTP-over-websocket framing and payload parsing;
18
+ - the authenticated **gateway** session handshake (HTTP Digest → websocket);
19
+ - the **cloud** credential lookup that retrieves a gateway's password from
20
+ a Delta Dore account.
21
+
22
+ It raises its own exception hierarchy (`TydomError` and subclasses) and
23
+ takes all endpoints and timeouts as parameters, so it carries no
24
+ dependency on Home Assistant.
25
+
26
+ This package is developed alongside the `deltadore` Home Assistant
27
+ integration and is intended to be published to PyPI as the integration's
28
+ transport dependency (quality-scale `dependency-transparency`).
@@ -0,0 +1,17 @@
1
+ # pytydom
2
+
3
+ A Home-Assistant-independent Python client for **Delta Dore Tydom**
4
+ gateways. It provides:
5
+
6
+ - the wire **protocol** — HTTP-over-websocket framing and payload parsing;
7
+ - the authenticated **gateway** session handshake (HTTP Digest → websocket);
8
+ - the **cloud** credential lookup that retrieves a gateway's password from
9
+ a Delta Dore account.
10
+
11
+ It raises its own exception hierarchy (`TydomError` and subclasses) and
12
+ takes all endpoints and timeouts as parameters, so it carries no
13
+ dependency on Home Assistant.
14
+
15
+ This package is developed alongside the `deltadore` Home Assistant
16
+ integration and is intended to be published to PyPI as the integration's
17
+ transport dependency (quality-scale `dependency-transparency`).
@@ -0,0 +1,15 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "pytydom"
7
+ version = "0.1.0"
8
+ description = "Home-Assistant-independent client for Delta Dore Tydom gateways"
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = { text = "MIT" }
12
+ dependencies = ["aiohttp>=3.9"]
13
+
14
+ [tool.setuptools.packages.find]
15
+ include = ["pytydom*"]
@@ -0,0 +1,61 @@
1
+ """pytydom — a Home-Assistant-independent client for Delta Dore Tydom gateways.
2
+
3
+ Groups the wire protocol (HTTP-over-websocket framing and payload parsing),
4
+ the authenticated gateway session handshake, and the cloud credential
5
+ lookup used to retrieve a gateway's password from a Delta Dore account.
6
+ """
7
+
8
+ from . import cloud, gateway, protocol
9
+ from .cloud import async_get_gateway_password
10
+ from .exceptions import (
11
+ TydomAuthenticationError,
12
+ TydomCommunicationError,
13
+ TydomConnectionError,
14
+ TydomError,
15
+ )
16
+ from .gateway import async_open_session, async_validate_connection
17
+ from .protocol import (
18
+ Frame,
19
+ TydomDevice,
20
+ build_request,
21
+ has_pod_position,
22
+ next_frame,
23
+ parse_area_links,
24
+ parse_area_names,
25
+ parse_areas_data,
26
+ parse_areas_meta,
27
+ parse_cmeta,
28
+ parse_configs,
29
+ parse_devices_data,
30
+ parse_energy_cdata,
31
+ parse_meta,
32
+ parse_scenarios,
33
+ )
34
+
35
+ __all__ = [
36
+ "Frame",
37
+ "TydomAuthenticationError",
38
+ "TydomCommunicationError",
39
+ "TydomConnectionError",
40
+ "TydomDevice",
41
+ "TydomError",
42
+ "async_get_gateway_password",
43
+ "async_open_session",
44
+ "async_validate_connection",
45
+ "build_request",
46
+ "cloud",
47
+ "gateway",
48
+ "has_pod_position",
49
+ "next_frame",
50
+ "parse_area_links",
51
+ "parse_area_names",
52
+ "parse_areas_data",
53
+ "parse_areas_meta",
54
+ "parse_cmeta",
55
+ "parse_configs",
56
+ "parse_devices_data",
57
+ "parse_energy_cdata",
58
+ "parse_meta",
59
+ "parse_scenarios",
60
+ "protocol",
61
+ ]
@@ -0,0 +1,79 @@
1
+ """Delta Dore cloud client — gateway credential retrieval.
2
+
3
+ OpenID discovery, ROPC token grant, then
4
+ sites lookup to obtain the Tydom Gateway password for a MAC address.
5
+ """
6
+
7
+ import asyncio
8
+ import logging
9
+
10
+ import aiohttp
11
+
12
+ from .exceptions import TydomAuthenticationError, TydomCommunicationError
13
+
14
+ _LOGGER = logging.getLogger(__name__)
15
+
16
+ DEFAULT_TIMEOUT = 60
17
+
18
+
19
+ async def async_get_gateway_password(
20
+ session: aiohttp.ClientSession,
21
+ email: str,
22
+ password: str,
23
+ mac: str,
24
+ *,
25
+ discovery_url: str,
26
+ sites_url: str,
27
+ client_id: str,
28
+ scope: str,
29
+ timeout: float = DEFAULT_TIMEOUT,
30
+ ) -> str:
31
+ """Return the gateway password registered for `mac` on the account.
32
+
33
+ The OpenID discovery URL, sites URL, client id and scope are passed in
34
+ by the caller (the integration holds Delta Dore's endpoints), so the
35
+ library embeds no vendor configuration.
36
+ """
37
+ try:
38
+ async with asyncio.timeout(timeout):
39
+ async with session.get(discovery_url) as resp:
40
+ token_endpoint = (await resp.json())["token_endpoint"]
41
+
42
+ form = aiohttp.FormData()
43
+ for name, value in (
44
+ ("username", email),
45
+ ("password", password),
46
+ ("grant_type", "password"),
47
+ ("client_id", client_id),
48
+ ("scope", scope),
49
+ ):
50
+ # content_type forces multipart/form-data, as the endpoint expects
51
+ form.add_field(name, value, content_type="text/plain")
52
+ async with session.post(token_endpoint, data=form) as resp:
53
+ if resp.status != 200:
54
+ _LOGGER.debug("Account token rejected: HTTP %s", resp.status)
55
+ raise TydomAuthenticationError("Account credentials rejected")
56
+ access_token = (await resp.json())["access_token"]
57
+
58
+ async with session.get(
59
+ sites_url,
60
+ params={"gateway_mac": mac},
61
+ headers={"Authorization": f"Bearer {access_token}"},
62
+ ) as resp:
63
+ if resp.status != 200:
64
+ _LOGGER.debug("Sites lookup rejected: HTTP %s", resp.status)
65
+ raise TydomAuthenticationError("Sites lookup rejected")
66
+ sites = (await resp.json()).get("sites", [])
67
+ except (TimeoutError, aiohttp.ClientError, OSError) as err:
68
+ _LOGGER.debug(
69
+ "Cloud credential retrieval failed (%s): %s", type(err).__name__, err
70
+ )
71
+ raise TydomCommunicationError("Delta Dore cloud unreachable") from err
72
+
73
+ for site in sites:
74
+ gateway = site.get("gateway", {})
75
+ if gateway.get("mac", "").upper() == mac.upper() and gateway.get("password"):
76
+ _LOGGER.debug("Gateway password retrieved from the cloud for %s", mac)
77
+ return gateway["password"]
78
+ _LOGGER.debug("No gateway %s among the %d account site(s)", mac, len(sites))
79
+ raise TydomAuthenticationError("No matching gateway on this account")
@@ -0,0 +1,21 @@
1
+ """Exception hierarchy for the Tydom gateway client.
2
+
3
+ Home-Assistant-independent on purpose: the integration maps these to its
4
+ own Home Assistant errors at the boundary.
5
+ """
6
+
7
+
8
+ class TydomError(Exception):
9
+ """Base class for every error raised by this library."""
10
+
11
+
12
+ class TydomAuthenticationError(TydomError):
13
+ """Credentials were rejected (gateway digest or cloud account)."""
14
+
15
+
16
+ class TydomConnectionError(TydomError):
17
+ """The gateway could not be reached."""
18
+
19
+
20
+ class TydomCommunicationError(TydomError):
21
+ """The Delta Dore cloud could not be reached."""
@@ -0,0 +1,236 @@
1
+ """Tydom Gateway session client — websocket handshake.
2
+
3
+ HTTP Digest challenge, authenticated
4
+ websocket upgrade, then one HTTP-framed ping to validate the session.
5
+ """
6
+
7
+ import asyncio
8
+ import base64
9
+ import hashlib
10
+ import logging
11
+ import re
12
+ import secrets
13
+ import ssl
14
+ import time
15
+
16
+ import aiohttp
17
+
18
+ from . import protocol
19
+ from .exceptions import TydomAuthenticationError, TydomConnectionError
20
+
21
+ _LOGGER = logging.getLogger(__name__)
22
+
23
+ # Transport defaults; the integration overrides these from its own config.
24
+ DEFAULT_PORT = 443
25
+ DEFAULT_TIMEOUT = 60
26
+ DEFAULT_HEARTBEAT = 20
27
+
28
+
29
+ def _build_ssl_context(verify: bool) -> ssl.SSLContext:
30
+ """Gateway TLS stacks are old: OpenSSL 3 refuses their legacy
31
+ renegotiation by default, so OP_LEGACY_SERVER_CONNECT is required."""
32
+ context = ssl.create_default_context()
33
+ if not verify:
34
+ context.check_hostname = False
35
+ context.verify_mode = ssl.CERT_NONE
36
+ context.options |= getattr(ssl, "OP_LEGACY_SERVER_CONNECT", 0x4)
37
+ return context
38
+
39
+
40
+ # Built at import time: Home Assistant imports integrations off the loop.
41
+ _SSL_CONTEXT_VERIFY = _build_ssl_context(verify=True)
42
+ _SSL_CONTEXT_NO_VERIFY = _build_ssl_context(verify=False)
43
+
44
+
45
+ def _md5(value: str) -> str:
46
+ return hashlib.md5(value.encode()).hexdigest()
47
+
48
+
49
+ def _digest_header(
50
+ mac: str,
51
+ password: str,
52
+ realm: str,
53
+ nonce: str,
54
+ uri: str,
55
+ algorithm: str | None,
56
+ opaque: str | None,
57
+ ) -> str:
58
+ cnonce = secrets.token_hex(8)
59
+ nc = "00000001"
60
+ ha1 = _md5(f"{mac}:{realm}:{password}")
61
+ ha2 = _md5(f"GET:{uri}")
62
+ response = _md5(f"{ha1}:{nonce}:{nc}:{cnonce}:auth:{ha2}")
63
+ header = (
64
+ f'Digest username="{mac}", realm="{realm}", nonce="{nonce}", '
65
+ f'uri="{uri}", response="{response}", qop=auth, nc={nc}, '
66
+ f'cnonce="{cnonce}"'
67
+ )
68
+ # Challenge directives must be echoed when the gateway sends them.
69
+ if algorithm:
70
+ header += f", algorithm={algorithm}"
71
+ if opaque:
72
+ header += f', opaque="{opaque}"'
73
+ return header
74
+
75
+
76
+ def _probe_headers() -> dict[str, str]:
77
+ """Real gateways answer the digest challenge only to upgrade-style
78
+ requests."""
79
+ return {
80
+ "Connection": "Upgrade",
81
+ "Upgrade": "websocket",
82
+ "Sec-WebSocket-Key": base64.b64encode(secrets.token_bytes(16)).decode(),
83
+ "Sec-WebSocket-Version": "13",
84
+ }
85
+
86
+
87
+ async def async_open_session(
88
+ session: aiohttp.ClientSession,
89
+ host: str,
90
+ mac: str,
91
+ gateway_password: str,
92
+ verify_tls: bool,
93
+ *,
94
+ port: int = DEFAULT_PORT,
95
+ timeout: float = DEFAULT_TIMEOUT,
96
+ heartbeat: float = DEFAULT_HEARTBEAT,
97
+ ) -> aiohttp.ClientWebSocketResponse:
98
+ """Perform the digest handshake and return an open websocket."""
99
+ uri = f"/mediation/client?mac={mac}&appli=1"
100
+ endpoint = f"{host}:{port}{uri}"
101
+ ssl_ctx = _SSL_CONTEXT_VERIFY if verify_tls else _SSL_CONTEXT_NO_VERIFY
102
+ try:
103
+ async with asyncio.timeout(timeout):
104
+ async with session.get(
105
+ f"https://{endpoint}", headers=_probe_headers(), ssl=ssl_ctx
106
+ ) as resp:
107
+ challenge = resp.headers.get("WWW-Authenticate", "")
108
+ _LOGGER.debug(
109
+ "Challenge probe to %s: HTTP %s, digest challenge %s",
110
+ host,
111
+ resp.status,
112
+ "present" if challenge else "absent",
113
+ )
114
+ realm = re.search(r'realm="([^"]*)"', challenge)
115
+ nonce = re.search(r'nonce="([^"]*)"', challenge)
116
+ if not realm or not nonce:
117
+ # Booting gateways expose HTTPS before their authentication
118
+ # service: not ready is NOT an authentication failure
119
+ # (reauth requires an explicit digest rejection).
120
+ _LOGGER.debug(
121
+ "No usable digest challenge from %s: %r", host, challenge[:120]
122
+ )
123
+ raise TydomConnectionError(
124
+ "Gateway offered no digest challenge (still booting?)"
125
+ )
126
+
127
+ algorithm = re.search(r"algorithm=\"?([A-Za-z0-9\-]+)\"?", challenge)
128
+ opaque = re.search(r'opaque="([^"]*)"', challenge)
129
+ auth = _digest_header(
130
+ mac,
131
+ gateway_password,
132
+ realm.group(1),
133
+ nonce.group(1),
134
+ uri,
135
+ algorithm.group(1) if algorithm else None,
136
+ opaque.group(1) if opaque else None,
137
+ )
138
+ websocket = await session.ws_connect(
139
+ f"wss://{endpoint}",
140
+ headers={"Authorization": auth},
141
+ ssl=ssl_ctx,
142
+ # Second keep-alive level: protocol PINGs (the gateway
143
+ # drops silent sessions).
144
+ autoping=True,
145
+ heartbeat=heartbeat,
146
+ )
147
+ _LOGGER.debug("Websocket session established with %s", host)
148
+ return websocket
149
+ except aiohttp.WSServerHandshakeError as err:
150
+ _LOGGER.debug("Websocket upgrade to %s refused: HTTP %s", host, err.status)
151
+ if err.status == 401:
152
+ raise TydomAuthenticationError(
153
+ "Gateway rejected the digest credentials"
154
+ ) from err
155
+ raise TydomConnectionError("Websocket upgrade failed") from err
156
+ except (TimeoutError, aiohttp.ClientError, OSError) as err:
157
+ _LOGGER.debug(
158
+ "Gateway connection to %s failed (%s): %s", host, type(err).__name__, err
159
+ )
160
+ raise TydomConnectionError("Gateway unreachable") from err
161
+
162
+
163
+ def _scan_for_response(buffer: bytes, transac_id: str):
164
+ """Find our correlated response in the stream, skipping other messages.
165
+
166
+ Returns (frame or None, remaining buffer)."""
167
+ while buffer:
168
+ try:
169
+ frame, remainder = protocol.next_frame(buffer)
170
+ except ValueError:
171
+ return None, b""
172
+ if frame is None:
173
+ return None, remainder
174
+ if frame.kind == "response" and frame.transac_id == transac_id:
175
+ return frame, remainder
176
+ buffer = remainder
177
+ return None, b""
178
+
179
+
180
+ async def async_validate_connection(
181
+ session: aiohttp.ClientSession,
182
+ host: str,
183
+ mac: str,
184
+ gateway_password: str,
185
+ verify_tls: bool,
186
+ remote: bool = False,
187
+ *,
188
+ port: int = DEFAULT_PORT,
189
+ timeout: float = DEFAULT_TIMEOUT,
190
+ heartbeat: float = DEFAULT_HEARTBEAT,
191
+ ) -> None:
192
+ """Open an authenticated session, ping the gateway, and close.
193
+
194
+ The ping response is correlated by Transac-Id while scanning the
195
+ stream like the runtime router: it may be preceded by unsolicited
196
+ messages or split across frames, and cloud mode prefixes outbound
197
+ frames with 0x02.
198
+ """
199
+ ws = await async_open_session(
200
+ session,
201
+ host,
202
+ mac,
203
+ gateway_password,
204
+ verify_tls,
205
+ port=port,
206
+ timeout=timeout,
207
+ heartbeat=heartbeat,
208
+ )
209
+ prefix = b"\x02" if remote else b""
210
+ # Epoch milliseconds, like every outbound id.
211
+ transac_id = str(time.time_ns() // 1_000_000)
212
+ buffer = b""
213
+ try:
214
+ async with asyncio.timeout(timeout):
215
+ await ws.send_bytes(
216
+ prefix + protocol.build_request("GET", "/ping", transac_id)
217
+ )
218
+ while True:
219
+ msg = await ws.receive()
220
+ if msg.type not in (aiohttp.WSMsgType.BINARY, aiohttp.WSMsgType.TEXT):
221
+ raise TydomConnectionError("Gateway closed the session")
222
+ raw = (
223
+ bytes(msg.data)
224
+ if isinstance(msg.data, (bytes, bytearray))
225
+ else str(msg.data).encode()
226
+ )
227
+ buffer += raw.lstrip(b"\x02")
228
+ frame, buffer = _scan_for_response(buffer, transac_id)
229
+ if frame is not None:
230
+ if frame.status is not None and 200 <= frame.status < 300:
231
+ return
232
+ raise TydomConnectionError("Gateway rejected the ping")
233
+ except (TimeoutError, aiohttp.ClientError, OSError) as err:
234
+ raise TydomConnectionError("Gateway unreachable") from err
235
+ finally:
236
+ await ws.close()
@@ -0,0 +1,310 @@
1
+ """HTTP-over-websocket message catalogue for the Tydom Gateway.
2
+
3
+ Frame building and parsing, inventory payload parsing, and the
4
+ state-application rules (endpoint error must be 0, data element validity
5
+ must be "upToDate").
6
+ """
7
+
8
+ import json
9
+ from dataclasses import dataclass, field
10
+
11
+
12
+ @dataclass
13
+ class Frame:
14
+ """One complete HTTP message extracted from the websocket stream."""
15
+
16
+ kind: str # "request" or "response"
17
+ path: str
18
+ transac_id: str | None
19
+ body: str
20
+ method: str | None = None
21
+ status: int | None = None
22
+
23
+ @property
24
+ def json(self):
25
+ return json.loads(self.body) if self.body.strip() else None
26
+
27
+
28
+ def build_request(method: str, path: str, transac_id: str, body: str = "") -> bytes:
29
+ """Serialize an outbound request frame."""
30
+ return (
31
+ f"{method} {path} HTTP/1.1\r\n"
32
+ f"Content-Length: {len(body.encode())}\r\n"
33
+ "Content-Type: application/json; charset=UTF-8\r\n"
34
+ f"Transac-Id: {transac_id}\r\n\r\n{body}"
35
+ ).encode()
36
+
37
+
38
+ def _consume_chunked(payload: bytes) -> tuple[bytes, int] | None:
39
+ """Extract one chunked body: (body, bytes consumed), or None while
40
+ incomplete. Raises ValueError on malformed chunk sizes."""
41
+ out = bytearray()
42
+ offset = 0
43
+ while True:
44
+ line_end = payload.find(b"\r\n", offset)
45
+ if line_end == -1:
46
+ return None
47
+ size = int(payload[offset:line_end].strip(), 16)
48
+ data_start = line_end + 2
49
+ if size == 0:
50
+ if len(payload) < data_start + 2:
51
+ return None
52
+ return bytes(out), data_start + 2
53
+ if len(payload) < data_start + size + 2:
54
+ return None
55
+ out += payload[data_start : data_start + size]
56
+ offset = data_start + size + 2
57
+
58
+
59
+ def next_frame(buffer: bytes) -> tuple[Frame | None, bytes]:
60
+ """Extract the first complete message from the websocket byte stream.
61
+
62
+ The stream is continuous HTTP/1.1: one websocket frame may carry
63
+ several messages and one message may span frames. Returns
64
+ (frame, remainder); frame is None when nothing was parsed —
65
+ remainder == b"" means non-HTTP bytes were dropped, an unchanged
66
+ remainder means the message is still incomplete. Message bodies end
67
+ at the chunked terminal zero chunk, after Content-Length bytes, or
68
+ immediately when neither marker is present (headers-only
69
+ acknowledgements). Raises ValueError on malformed framing.
70
+ """
71
+ head, sep, rest = buffer.partition(b"\r\n\r\n")
72
+ lines = head.decode(errors="ignore").split("\r\n")
73
+ first = lines[0].split(" ")
74
+ is_response = first[0].startswith("HTTP/") and len(first) >= 2
75
+ is_request = len(first) >= 3 and first[2].startswith("HTTP/")
76
+ if not is_response and not is_request:
77
+ return None, b""
78
+ if not sep:
79
+ return None, buffer
80
+ headers = {}
81
+ for line in lines[1:]:
82
+ name, _, value = line.partition(":")
83
+ headers[name.strip().lower()] = value.strip()
84
+
85
+ if headers.get("transfer-encoding", "").lower().startswith("chunk"):
86
+ consumed = _consume_chunked(rest)
87
+ if consumed is None:
88
+ return None, buffer
89
+ body_bytes, used = consumed
90
+ remainder = rest[used:]
91
+ elif "content-length" in headers:
92
+ length = int(headers["content-length"])
93
+ if len(rest) < length:
94
+ return None, buffer
95
+ body_bytes, remainder = rest[:length], rest[length:]
96
+ else:
97
+ body_bytes, remainder = b"", rest
98
+
99
+ frame = Frame(
100
+ kind="response" if is_response else "request",
101
+ status=int(first[1]) if is_response else None,
102
+ method=None if is_response else first[0],
103
+ path=headers.get("uri-origin", "") if is_response else first[1],
104
+ transac_id=headers.get("transac-id"),
105
+ body=body_bytes.decode(errors="ignore"),
106
+ )
107
+ return frame, remainder
108
+
109
+
110
+ @dataclass
111
+ class TydomDevice:
112
+ """One gateway endpoint, identified by "{id_endpoint}_{id_device}"."""
113
+
114
+ key: str
115
+ device_id: int
116
+ endpoint_id: int
117
+ name: str
118
+ usage: str
119
+ data: dict = field(default_factory=dict)
120
+ metadata: dict = field(default_factory=dict)
121
+ energy_dests: list = field(default_factory=list)
122
+ energy_srcs: list = field(default_factory=list)
123
+ energy_units: list = field(default_factory=list)
124
+ area_name: str | None = None
125
+ _listeners: list = field(default_factory=list)
126
+
127
+ def add_listener(self, listener):
128
+ """Register a state-change callback; returns its remover."""
129
+ self._listeners.append(listener)
130
+
131
+ def _remove():
132
+ self._listeners.remove(listener)
133
+
134
+ return _remove
135
+
136
+ def apply(self, data: dict) -> None:
137
+ """Apply confirmed state values and notify listeners."""
138
+ self.data.update(data)
139
+ for listener in list(self._listeners):
140
+ listener()
141
+
142
+
143
+ def has_pod_position(device: TydomDevice) -> bool:
144
+ """Motorized doors carry podPosition; they map to lock, not contact."""
145
+ return "podPosition" in device.data or "podPosition" in device.metadata
146
+
147
+
148
+ def parse_configs(payload) -> dict[str, dict]:
149
+ """Endpoint directory from /configs/file, keyed by device key."""
150
+ entries = {}
151
+ for endpoint in (payload or {}).get("endpoints", []):
152
+ key = f"{endpoint['id_endpoint']}_{endpoint['id_device']}"
153
+ entries[key] = {
154
+ "device_id": endpoint["id_device"],
155
+ "endpoint_id": endpoint["id_endpoint"],
156
+ "name": endpoint["name"],
157
+ "usage": endpoint["last_usage"] or "unknown",
158
+ }
159
+ return entries
160
+
161
+
162
+ def parse_meta(payload) -> dict[str, dict]:
163
+ """Attribute metadata from /devices/meta, keyed by device key."""
164
+ entries = {}
165
+ for device in payload or []:
166
+ for endpoint in device.get("endpoints", []):
167
+ key = f"{endpoint['id']}_{device['id']}"
168
+ entries[key] = {meta["name"]: meta for meta in endpoint.get("metadata", [])}
169
+ return entries
170
+
171
+
172
+ def parse_scenarios(payload) -> list[dict]:
173
+ """Scenario definitions from the /configs/file `scenarios` array."""
174
+ return [
175
+ {"id": scenario["id"], "name": scenario["name"]}
176
+ for scenario in (payload or {}).get("scenarios", [])
177
+ ]
178
+
179
+
180
+ def parse_area_names(payload) -> dict[int, str]:
181
+ """Thermal-zone names from the /configs/file `areas` array."""
182
+ return {
183
+ area["id"]: area["name"]
184
+ for area in (payload or {}).get("areas", [])
185
+ if "id" in area and "name" in area
186
+ }
187
+
188
+
189
+ def parse_area_links(payload) -> dict[str, int]:
190
+ """Device-key → area id from the /devices/meta endpoint `link`."""
191
+ links: dict[str, int] = {}
192
+ for device in payload or []:
193
+ for endpoint in device.get("endpoints", []):
194
+ link = endpoint.get("link") or {}
195
+ if link.get("type") == "area" and link.get("id") is not None:
196
+ links[f"{endpoint['id']}_{device['id']}"] = link["id"]
197
+ return links
198
+
199
+
200
+ def parse_areas_meta(payload) -> dict[int, dict]:
201
+ """Zone attribute metadata from /areas/meta, keyed by area id."""
202
+ entries: dict[int, dict] = {}
203
+ for area in payload or []:
204
+ entries[area["id"]] = {meta["name"]: meta for meta in area.get("metadata", [])}
205
+ return entries
206
+
207
+
208
+ def parse_areas_data(payload) -> dict[int, dict]:
209
+ """Confirmed zone state from an /areas/data body, keyed by area id.
210
+
211
+ Same shape as a flat /devices/data element but the top-level `id`
212
+ is an area id and there is no `endpoints` key.
213
+ """
214
+ entries: dict[int, dict] = {}
215
+ for area in payload or []:
216
+ if area.get("error", 0) != 0:
217
+ continue
218
+ entries[area["id"]] = {
219
+ element["name"]: element["value"]
220
+ for element in area.get("data", [])
221
+ if element.get("validity") == "upToDate"
222
+ }
223
+ return entries
224
+
225
+
226
+ def parse_cmeta(payload) -> dict[str, dict]:
227
+ """Energy capabilities advertised by /devices/cmeta, per key:
228
+ energyIndex destinations, energyDistrib sources, and energyInstant
229
+ units."""
230
+ entries: dict[str, dict] = {}
231
+ for device in payload or []:
232
+ for endpoint in device.get("endpoints", []):
233
+ key = f"{endpoint['id']}_{device['id']}"
234
+ dests: list = []
235
+ srcs: list = []
236
+ units: list = []
237
+ for cmeta in endpoint.get("cmetadata", []):
238
+ name = cmeta.get("name")
239
+ if name == "energyIndex":
240
+ for parameter in cmeta.get("parameters", []):
241
+ if parameter.get("name") == "dest":
242
+ dests.extend(parameter.get("enum_values", []))
243
+ elif name == "energyDistrib":
244
+ for parameter in cmeta.get("parameters", []):
245
+ if parameter.get("name") == "src":
246
+ srcs.extend(parameter.get("enum_values", []))
247
+ elif name == "energyInstant":
248
+ for parameter in cmeta.get("parameters", []):
249
+ if parameter.get("name") == "unit":
250
+ units.extend(parameter.get("enum_values", []))
251
+ if dests or srcs or units:
252
+ entries[key] = {"dests": dests, "srcs": srcs, "units": units}
253
+ return entries
254
+
255
+
256
+ def parse_energy_cdata(payload) -> dict[str, dict]:
257
+ """Energy counters from a cdata response, keyed by device key."""
258
+ entries: dict[str, dict] = {}
259
+ for device in payload or []:
260
+ for endpoint in device.get("endpoints", []):
261
+ if endpoint.get("error", 0) != 0:
262
+ continue
263
+ key = f"{endpoint['id']}_{device['id']}"
264
+ values = {}
265
+ for element in endpoint.get("cdata", []):
266
+ name = element.get("name")
267
+ if name == "energyIndex":
268
+ dest = element.get("parameters", {}).get("dest")
269
+ counter = element.get("values", {}).get("counter")
270
+ if dest is not None and counter is not None:
271
+ values[f"energyIndex_{dest}"] = counter
272
+ elif name == "energyDistrib":
273
+ # The distribution echo carries a date plus one
274
+ # counter per breakdown slot, keyed by slot name.
275
+ for slot, counter in element.get("values", {}).items():
276
+ if slot != "date" and isinstance(counter, (int, float)):
277
+ values[f"energyIndex_{slot}"] = counter
278
+ elif name == "energyInstant":
279
+ unit = element.get("parameters", {}).get("unit")
280
+ measure = element.get("values", {}).get("measure")
281
+ if unit is not None and isinstance(measure, (int, float)):
282
+ # Hundredths of the physical unit (trace truth).
283
+ values[f"energyInstant_{unit}"] = measure / 100
284
+ if values:
285
+ entries[key] = values
286
+ return entries
287
+
288
+
289
+ def parse_devices_data(payload) -> dict[str, dict]:
290
+ """Confirmed state values from a /devices/data body, keyed by device key.
291
+
292
+ Application rules: endpoints with a non-zero error are skipped; data
293
+ elements are kept only when their validity is "upToDate".
294
+ """
295
+ if isinstance(payload, dict):
296
+ payload = [payload]
297
+ entries: dict[str, dict] = {}
298
+ for device in payload or []:
299
+ for endpoint in device.get("endpoints", []):
300
+ if endpoint.get("error", 0) != 0:
301
+ continue
302
+ key = f"{endpoint['id']}_{device['id']}"
303
+ values = {
304
+ element["name"]: element["value"]
305
+ for element in endpoint.get("data", [])
306
+ if element.get("validity") == "upToDate"
307
+ }
308
+ if values:
309
+ entries[key] = values
310
+ return entries
@@ -0,0 +1,28 @@
1
+ Metadata-Version: 2.4
2
+ Name: pytydom
3
+ Version: 0.1.0
4
+ Summary: Home-Assistant-independent client for Delta Dore Tydom gateways
5
+ License: MIT
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Requires-Dist: aiohttp>=3.9
10
+ Dynamic: license-file
11
+
12
+ # pytydom
13
+
14
+ A Home-Assistant-independent Python client for **Delta Dore Tydom**
15
+ gateways. It provides:
16
+
17
+ - the wire **protocol** — HTTP-over-websocket framing and payload parsing;
18
+ - the authenticated **gateway** session handshake (HTTP Digest → websocket);
19
+ - the **cloud** credential lookup that retrieves a gateway's password from
20
+ a Delta Dore account.
21
+
22
+ It raises its own exception hierarchy (`TydomError` and subclasses) and
23
+ takes all endpoints and timeouts as parameters, so it carries no
24
+ dependency on Home Assistant.
25
+
26
+ This package is developed alongside the `deltadore` Home Assistant
27
+ integration and is intended to be published to PyPI as the integration's
28
+ transport dependency (quality-scale `dependency-transparency`).
@@ -0,0 +1,13 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ pytydom/__init__.py
5
+ pytydom/cloud.py
6
+ pytydom/exceptions.py
7
+ pytydom/gateway.py
8
+ pytydom/protocol.py
9
+ pytydom.egg-info/PKG-INFO
10
+ pytydom.egg-info/SOURCES.txt
11
+ pytydom.egg-info/dependency_links.txt
12
+ pytydom.egg-info/requires.txt
13
+ pytydom.egg-info/top_level.txt
@@ -0,0 +1 @@
1
+ aiohttp>=3.9
@@ -0,0 +1 @@
1
+ pytydom
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+