pytydom 0.1.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.
- pytydom/__init__.py +61 -0
- pytydom/cloud.py +79 -0
- pytydom/exceptions.py +21 -0
- pytydom/gateway.py +236 -0
- pytydom/protocol.py +310 -0
- pytydom-0.1.0.dist-info/METADATA +28 -0
- pytydom-0.1.0.dist-info/RECORD +10 -0
- pytydom-0.1.0.dist-info/WHEEL +5 -0
- pytydom-0.1.0.dist-info/licenses/LICENSE +21 -0
- pytydom-0.1.0.dist-info/top_level.txt +1 -0
pytydom/__init__.py
ADDED
|
@@ -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
|
+
]
|
pytydom/cloud.py
ADDED
|
@@ -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")
|
pytydom/exceptions.py
ADDED
|
@@ -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."""
|
pytydom/gateway.py
ADDED
|
@@ -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()
|
pytydom/protocol.py
ADDED
|
@@ -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,10 @@
|
|
|
1
|
+
pytydom/__init__.py,sha256=gi0XyV2nA0Obk4Sm24NnyzcrEGxvuzHXaM3sgbBoCV0,1492
|
|
2
|
+
pytydom/cloud.py,sha256=a6H_YWLuUE1dW4SJC-mqcg3Hbce9T0JddItk7BVCWfs,3002
|
|
3
|
+
pytydom/exceptions.py,sha256=MIeumdV3RHQfNbqUhwUN-QpIbQcPCZts8HLlpL2vlGk,567
|
|
4
|
+
pytydom/gateway.py,sha256=Jf_gSSSXYJilGLrlwLRvKbOcEvjvG8id_uBENJLzt74,8173
|
|
5
|
+
pytydom/protocol.py,sha256=RQAnPNT9qrylOuuzuBFHTEt1LNPy4aoSgfatWJPTTOs,11762
|
|
6
|
+
pytydom-0.1.0.dist-info/licenses/LICENSE,sha256=lKNRJ2HDnaA9bInVLfFHlEM77w51fG6bsSt5HTKKpNU,1086
|
|
7
|
+
pytydom-0.1.0.dist-info/METADATA,sha256=NYlduYP-P6fxhfOX3uBiDxB9j-EySnMIoZU2Jk4I6C0,1005
|
|
8
|
+
pytydom-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
9
|
+
pytydom-0.1.0.dist-info/top_level.txt,sha256=vNyfGHORwst4dz-9phBls6u5dncO0ZOSY75tULy7rsQ,8
|
|
10
|
+
pytydom-0.1.0.dist-info/RECORD,,
|
|
@@ -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.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pytydom
|