shorthand-client 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.
- shorthand_client-0.1.0/PKG-INFO +15 -0
- shorthand_client-0.1.0/README.md +3 -0
- shorthand_client-0.1.0/pyproject.toml +19 -0
- shorthand_client-0.1.0/pyproject.toml.orig +16 -0
- shorthand_client-0.1.0/src/shorthand_client/__init__.py +7 -0
- shorthand_client-0.1.0/src/shorthand_client/client.py +216 -0
- shorthand_client-0.1.0/src/shorthand_client/errors.py +55 -0
- shorthand_client-0.1.0/src/shorthand_client/models.py +454 -0
- shorthand_client-0.1.0/src/shorthand_client/py.typed +0 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: shorthand-client
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async client for the Shorthand server, used by the Home Assistant integration
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Classifier: Framework :: AsyncIO
|
|
7
|
+
Classifier: Typing :: Typed
|
|
8
|
+
Requires-Dist: aiohttp>=3.14
|
|
9
|
+
Requires-Python: >=3.14.2
|
|
10
|
+
Project-URL: Source, https://github.com/JoshuaLoran/ha-shorthand
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# shorthand-client
|
|
14
|
+
|
|
15
|
+
The async Python client for the [Shorthand](https://github.com/JoshuaLoran/ha-shorthand) server. The Home Assistant integration uses it to talk to the server. You don't need to install it yourself.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "shorthand-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async client for the Shorthand server, used by the Home Assistant integration"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.14.2"
|
|
8
|
+
dependencies = ["aiohttp>=3.14"]
|
|
9
|
+
classifiers = [
|
|
10
|
+
"Framework :: AsyncIO",
|
|
11
|
+
"Typing :: Typed",
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
[project.urls]
|
|
15
|
+
Source = "https://github.com/JoshuaLoran/ha-shorthand"
|
|
16
|
+
|
|
17
|
+
[build-system]
|
|
18
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
19
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "shorthand-client"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Async client for the Shorthand server, used by the Home Assistant integration"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
requires-python = ">=3.14.2"
|
|
8
|
+
dependencies = ["aiohttp>=3.14"]
|
|
9
|
+
classifiers = ["Framework :: AsyncIO", "Typing :: Typed"]
|
|
10
|
+
|
|
11
|
+
[project.urls]
|
|
12
|
+
Source = "https://github.com/JoshuaLoran/ha-shorthand"
|
|
13
|
+
|
|
14
|
+
[build-system]
|
|
15
|
+
requires = ["uv_build>=0.12,<0.13"]
|
|
16
|
+
build-backend = "uv_build"
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""The client (01 §9, D19): one WebSocket to the server.
|
|
2
|
+
|
|
3
|
+
The integration opens it. Requests are matched to replies by id; anything
|
|
4
|
+
the server starts (`learn_request`, `sync_request`) goes to `on_message`.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import contextlib
|
|
11
|
+
import itertools
|
|
12
|
+
import logging
|
|
13
|
+
from collections.abc import Awaitable, Callable
|
|
14
|
+
|
|
15
|
+
import aiohttp
|
|
16
|
+
|
|
17
|
+
from . import models
|
|
18
|
+
from .errors import (
|
|
19
|
+
AuthenticationError,
|
|
20
|
+
CannotConnectError,
|
|
21
|
+
ProtocolError,
|
|
22
|
+
RequestTimeoutError,
|
|
23
|
+
ServerError,
|
|
24
|
+
ShorthandError,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
_LOGGER = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
type MessageHandler = Callable[[models.Envelope], Awaitable[None]]
|
|
30
|
+
type ConnectHandler = Callable[[models.Welcome], Awaitable[None]]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ShorthandClient:
|
|
34
|
+
"""A connection to one Shorthand server.
|
|
35
|
+
|
|
36
|
+
The aiohttp session is the caller's (Home Assistant's) and is never
|
|
37
|
+
closed here.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
url: str,
|
|
43
|
+
session: aiohttp.ClientSession,
|
|
44
|
+
*,
|
|
45
|
+
client_name: str,
|
|
46
|
+
ha_version: str,
|
|
47
|
+
on_message: MessageHandler | None = None,
|
|
48
|
+
request_timeout: float = 10.0,
|
|
49
|
+
backoff: tuple[float, float] = (1.0, 30.0),
|
|
50
|
+
) -> None:
|
|
51
|
+
"""Set up a client for the server at `url` (its `/ws` endpoint)."""
|
|
52
|
+
self._url = url
|
|
53
|
+
self._session = session
|
|
54
|
+
self._client_name = client_name
|
|
55
|
+
self._ha_version = ha_version
|
|
56
|
+
self._on_message = on_message
|
|
57
|
+
self._timeout = request_timeout
|
|
58
|
+
self._backoff = backoff
|
|
59
|
+
self._ws: aiohttp.ClientWebSocketResponse | None = None
|
|
60
|
+
self._reader: asyncio.Task[None] | None = None
|
|
61
|
+
self._pending: dict[str, asyncio.Future[models.Envelope]] = {}
|
|
62
|
+
self._ids = itertools.count(1)
|
|
63
|
+
self._closing = False
|
|
64
|
+
|
|
65
|
+
@property
|
|
66
|
+
def connected(self) -> bool:
|
|
67
|
+
"""Whether the connection is open and welcomed."""
|
|
68
|
+
return self._ws is not None and not self._ws.closed
|
|
69
|
+
|
|
70
|
+
async def connect(self, auth: models.Auth) -> models.Welcome:
|
|
71
|
+
"""Open the connection and say hello; the welcome says who answered."""
|
|
72
|
+
try:
|
|
73
|
+
ws = await self._session.ws_connect(self._url, heartbeat=30)
|
|
74
|
+
except (aiohttp.ClientError, OSError) as err:
|
|
75
|
+
raise CannotConnectError(str(err)) from err
|
|
76
|
+
hello = models.Envelope(
|
|
77
|
+
id=self._next_id(),
|
|
78
|
+
body=models.Hello(
|
|
79
|
+
client=self._client_name, ha_version=self._ha_version, auth=auth
|
|
80
|
+
),
|
|
81
|
+
)
|
|
82
|
+
try:
|
|
83
|
+
await ws.send_str(hello.to_json())
|
|
84
|
+
msg = await asyncio.wait_for(ws.receive(), self._timeout)
|
|
85
|
+
except TimeoutError as err:
|
|
86
|
+
await ws.close()
|
|
87
|
+
raise RequestTimeoutError("no answer to hello") from err
|
|
88
|
+
if msg.type is not aiohttp.WSMsgType.TEXT:
|
|
89
|
+
await ws.close()
|
|
90
|
+
raise CannotConnectError("the server closed the connection")
|
|
91
|
+
reply = models.parse(msg.data)
|
|
92
|
+
if isinstance(reply.body, models.ErrorReply):
|
|
93
|
+
await ws.close()
|
|
94
|
+
raise AuthenticationError(reply.body.message)
|
|
95
|
+
if not isinstance(reply.body, models.Welcome):
|
|
96
|
+
await ws.close()
|
|
97
|
+
raise CannotConnectError(f"expected welcome, got {reply.body.TYPE}")
|
|
98
|
+
self._ws = ws
|
|
99
|
+
self._reader = asyncio.create_task(self._read(ws))
|
|
100
|
+
return reply.body
|
|
101
|
+
|
|
102
|
+
async def request(self, body: models.Message) -> models.Message:
|
|
103
|
+
"""Send a request and wait for the reply with its id."""
|
|
104
|
+
ws = self._require()
|
|
105
|
+
env = models.Envelope(id=self._next_id(), body=body)
|
|
106
|
+
future: asyncio.Future[models.Envelope] = (
|
|
107
|
+
asyncio.get_running_loop().create_future()
|
|
108
|
+
)
|
|
109
|
+
self._pending[env.id] = future
|
|
110
|
+
try:
|
|
111
|
+
await ws.send_str(env.to_json())
|
|
112
|
+
reply = await asyncio.wait_for(future, self._timeout)
|
|
113
|
+
except TimeoutError as err:
|
|
114
|
+
raise RequestTimeoutError(f"no answer to {body.TYPE}") from err
|
|
115
|
+
finally:
|
|
116
|
+
self._pending.pop(env.id, None)
|
|
117
|
+
if isinstance(reply.body, models.ErrorReply):
|
|
118
|
+
raise ServerError(reply.body.code, reply.body.message)
|
|
119
|
+
return reply.body
|
|
120
|
+
|
|
121
|
+
async def send(self, body: models.Message, *, reply_to: str | None = None) -> None:
|
|
122
|
+
"""Send a message that needs no reply (a push, or an answer)."""
|
|
123
|
+
ws = self._require()
|
|
124
|
+
env = models.Envelope(id=reply_to or self._next_id(), body=body)
|
|
125
|
+
await ws.send_str(env.to_json())
|
|
126
|
+
|
|
127
|
+
async def ping(self) -> None:
|
|
128
|
+
"""Check the server answers."""
|
|
129
|
+
reply = await self.request(models.Ping())
|
|
130
|
+
if not isinstance(reply, models.Pong):
|
|
131
|
+
raise CannotConnectError(f"expected pong, got {reply.TYPE}")
|
|
132
|
+
|
|
133
|
+
async def decide(self, decide: models.Decide) -> models.Decision:
|
|
134
|
+
"""Ask what to do with these words."""
|
|
135
|
+
reply = await self.request(decide)
|
|
136
|
+
if not isinstance(reply, models.Decision):
|
|
137
|
+
raise CannotConnectError(f"expected decision, got {reply.TYPE}")
|
|
138
|
+
return reply
|
|
139
|
+
|
|
140
|
+
async def run_forever(
|
|
141
|
+
self,
|
|
142
|
+
auth: models.Auth,
|
|
143
|
+
*,
|
|
144
|
+
on_connect: ConnectHandler | None = None,
|
|
145
|
+
on_disconnect: Callable[[], Awaitable[None]] | None = None,
|
|
146
|
+
) -> None:
|
|
147
|
+
"""Stay connected until `close()`, reconnecting with backoff."""
|
|
148
|
+
first, most = self._backoff
|
|
149
|
+
delay = first
|
|
150
|
+
while not self._stopping():
|
|
151
|
+
try:
|
|
152
|
+
welcome = await self.connect(auth)
|
|
153
|
+
except AuthenticationError:
|
|
154
|
+
raise
|
|
155
|
+
except ShorthandError as err:
|
|
156
|
+
_LOGGER.debug("Can't reach the Shorthand server: %s", err)
|
|
157
|
+
else:
|
|
158
|
+
delay = first
|
|
159
|
+
if on_connect is not None:
|
|
160
|
+
await on_connect(welcome)
|
|
161
|
+
if self._reader is not None:
|
|
162
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
163
|
+
await self._reader
|
|
164
|
+
if on_disconnect is not None and not self._stopping():
|
|
165
|
+
await on_disconnect()
|
|
166
|
+
if self._stopping():
|
|
167
|
+
break
|
|
168
|
+
await asyncio.sleep(delay)
|
|
169
|
+
delay = min(delay * 2, most)
|
|
170
|
+
|
|
171
|
+
async def close(self) -> None:
|
|
172
|
+
"""Close the connection; `run_forever` returns."""
|
|
173
|
+
self._closing = True
|
|
174
|
+
if self._ws is not None:
|
|
175
|
+
await self._ws.close()
|
|
176
|
+
if self._reader is not None:
|
|
177
|
+
self._reader.cancel()
|
|
178
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
179
|
+
await self._reader
|
|
180
|
+
|
|
181
|
+
async def _read(self, ws: aiohttp.ClientWebSocketResponse) -> None:
|
|
182
|
+
try:
|
|
183
|
+
async for msg in ws:
|
|
184
|
+
if msg.type is not aiohttp.WSMsgType.TEXT:
|
|
185
|
+
continue
|
|
186
|
+
try:
|
|
187
|
+
env = models.parse(msg.data)
|
|
188
|
+
except ProtocolError as err:
|
|
189
|
+
_LOGGER.warning("Ignoring a message from the server: %s", err)
|
|
190
|
+
continue
|
|
191
|
+
future = self._pending.get(env.id)
|
|
192
|
+
if future is not None and not future.done():
|
|
193
|
+
future.set_result(env)
|
|
194
|
+
elif self._on_message is not None:
|
|
195
|
+
await self._on_message(env)
|
|
196
|
+
finally:
|
|
197
|
+
self._ws = None
|
|
198
|
+
lost = CannotConnectError("the connection to the server was lost")
|
|
199
|
+
for future in self._pending.values():
|
|
200
|
+
if not future.done():
|
|
201
|
+
future.set_exception(lost)
|
|
202
|
+
|
|
203
|
+
def _stopping(self) -> bool:
|
|
204
|
+
# A method, so type checkers don't assume it can't change across an await.
|
|
205
|
+
return self._closing
|
|
206
|
+
|
|
207
|
+
def _require(self) -> aiohttp.ClientWebSocketResponse:
|
|
208
|
+
if self._ws is None or self._ws.closed:
|
|
209
|
+
raise CannotConnectError("not connected")
|
|
210
|
+
return self._ws
|
|
211
|
+
|
|
212
|
+
def _next_id(self) -> str:
|
|
213
|
+
return str(next(self._ids))
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
__all__ = ["ShorthandClient"]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""What can go wrong talking to the Shorthand server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .models import ErrorCode
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class ShorthandError(Exception):
|
|
12
|
+
"""Anything this library raises."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ProtocolError(ShorthandError):
|
|
16
|
+
"""Text that isn't a message this version can use."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VersionMismatchError(ProtocolError):
|
|
20
|
+
"""The peer speaks another protocol version."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, got: int) -> None:
|
|
23
|
+
"""Record the version the peer spoke."""
|
|
24
|
+
super().__init__(f"protocol version {got} isn't supported")
|
|
25
|
+
self.got = got
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class UnknownMessageTypeError(ProtocolError):
|
|
29
|
+
"""A message type this version doesn't know."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class MalformedMessageError(ProtocolError):
|
|
33
|
+
"""Not JSON, not an object, or a field that's missing or wrong."""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class CannotConnectError(ShorthandError):
|
|
37
|
+
"""The server couldn't be reached, or the connection was lost."""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class AuthenticationError(ShorthandError):
|
|
41
|
+
"""The server refused the pairing code or key."""
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class RequestTimeoutError(ShorthandError):
|
|
45
|
+
"""The server didn't answer a request in time."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class ServerError(ShorthandError):
|
|
49
|
+
"""The server answered a request with an error."""
|
|
50
|
+
|
|
51
|
+
def __init__(self, code: ErrorCode, message: str) -> None:
|
|
52
|
+
"""Record the server's error code and message."""
|
|
53
|
+
super().__init__(f"{code}: {message}")
|
|
54
|
+
self.code = code
|
|
55
|
+
self.message = message
|
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
"""The protocol's messages (07 §2), mirroring `shorthand-protocol` in Rust.
|
|
2
|
+
|
|
3
|
+
The Rust crate's fixtures are the contract: every one must parse into these
|
|
4
|
+
types and serialize back to the same JSON (`tests/test_contract.py`).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import enum
|
|
10
|
+
import json
|
|
11
|
+
import types
|
|
12
|
+
from dataclasses import MISSING, dataclass, field, fields, is_dataclass
|
|
13
|
+
from typing import Any, ClassVar, Union, get_args, get_origin, get_type_hints
|
|
14
|
+
|
|
15
|
+
from .errors import (
|
|
16
|
+
MalformedMessageError,
|
|
17
|
+
UnknownMessageTypeError,
|
|
18
|
+
VersionMismatchError,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
VERSION = 1
|
|
22
|
+
"""The protocol version this library speaks."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class CheckResult(enum.StrEnum):
|
|
26
|
+
"""Whether the house showed what a call should have done (03 §5)."""
|
|
27
|
+
|
|
28
|
+
VERIFIED = "verified"
|
|
29
|
+
MISMATCH = "mismatch"
|
|
30
|
+
UNCONFIRMED = "unconfirmed"
|
|
31
|
+
DEVICE_TROUBLE = "device_trouble"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ErrorCode(enum.StrEnum):
|
|
35
|
+
"""Why a request couldn't be done."""
|
|
36
|
+
|
|
37
|
+
UNAUTHORIZED = "unauthorized"
|
|
38
|
+
BAD_MESSAGE = "bad_message"
|
|
39
|
+
INTERNAL = "internal"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Tagged variants: `TAG_KEY` names the field that says which one.
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
46
|
+
class Pair:
|
|
47
|
+
"""The first time: the code the server shows."""
|
|
48
|
+
|
|
49
|
+
TAG_KEY: ClassVar[str] = "method"
|
|
50
|
+
TAG: ClassVar[str] = "pair"
|
|
51
|
+
code: str
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
55
|
+
class Key:
|
|
56
|
+
"""Every time after: the key the server gave back."""
|
|
57
|
+
|
|
58
|
+
TAG_KEY: ClassVar[str] = "method"
|
|
59
|
+
TAG: ClassVar[str] = "key"
|
|
60
|
+
key: str
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
type Auth = Pair | Key
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
67
|
+
class ServiceCall:
|
|
68
|
+
"""One service call, as captured or to run (01 §5)."""
|
|
69
|
+
|
|
70
|
+
service: str
|
|
71
|
+
entity_ids: list[str] = field(default_factory=list)
|
|
72
|
+
data: Any = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
76
|
+
class Run:
|
|
77
|
+
"""Run a learned command."""
|
|
78
|
+
|
|
79
|
+
TAG_KEY: ClassVar[str] = "decision"
|
|
80
|
+
TAG: ClassVar[str] = "run"
|
|
81
|
+
command: int
|
|
82
|
+
calls: list[ServiceCall]
|
|
83
|
+
why: str
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
87
|
+
class Pass:
|
|
88
|
+
"""Pass the words to the brain."""
|
|
89
|
+
|
|
90
|
+
TAG_KEY: ClassVar[str] = "decision"
|
|
91
|
+
TAG: ClassVar[str] = "pass"
|
|
92
|
+
why: str
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
type Verdict = Run | Pass
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
99
|
+
class Floor:
|
|
100
|
+
"""A floor, with its aliases."""
|
|
101
|
+
|
|
102
|
+
id: str
|
|
103
|
+
name: str
|
|
104
|
+
aliases: list[str] = field(default_factory=list)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
108
|
+
class Area:
|
|
109
|
+
"""An area, with its aliases and floor."""
|
|
110
|
+
|
|
111
|
+
id: str
|
|
112
|
+
name: str
|
|
113
|
+
aliases: list[str] = field(default_factory=list)
|
|
114
|
+
floor: str | None = None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
118
|
+
class Entity:
|
|
119
|
+
"""An entity exposed to Assist."""
|
|
120
|
+
|
|
121
|
+
id: str
|
|
122
|
+
name: str
|
|
123
|
+
aliases: list[str] = field(default_factory=list)
|
|
124
|
+
domain: str
|
|
125
|
+
device_class: str | None = None
|
|
126
|
+
area: str | None = None
|
|
127
|
+
sensitive: bool = False
|
|
128
|
+
state: str
|
|
129
|
+
attributes: Any = None
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
133
|
+
class Media:
|
|
134
|
+
"""Something to play, from Music Assistant's library."""
|
|
135
|
+
|
|
136
|
+
uri: str
|
|
137
|
+
name: str
|
|
138
|
+
kind: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
142
|
+
class Home:
|
|
143
|
+
"""The house as Shorthand sees it (04 §1)."""
|
|
144
|
+
|
|
145
|
+
floors: list[Floor] = field(default_factory=list)
|
|
146
|
+
areas: list[Area] = field(default_factory=list)
|
|
147
|
+
entities: list[Entity] = field(default_factory=list)
|
|
148
|
+
media: list[Media] = field(default_factory=list)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
152
|
+
class State:
|
|
153
|
+
"""One entity's state, as it is now or was then."""
|
|
154
|
+
|
|
155
|
+
entity_id: str
|
|
156
|
+
state: str
|
|
157
|
+
attributes: Any = None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
161
|
+
class Check:
|
|
162
|
+
"""One call's check (03 §5)."""
|
|
163
|
+
|
|
164
|
+
call: int
|
|
165
|
+
result: CheckResult
|
|
166
|
+
detail: str | None = None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
170
|
+
class TurnRecord:
|
|
171
|
+
"""What the brain did in one turn (03 §2)."""
|
|
172
|
+
|
|
173
|
+
words: str
|
|
174
|
+
conversation_id: str | None = None
|
|
175
|
+
here: str | None = None
|
|
176
|
+
focus_before: list[str] = field(default_factory=list)
|
|
177
|
+
calls: list[ServiceCall] = field(default_factory=list)
|
|
178
|
+
before: list[State] = field(default_factory=list)
|
|
179
|
+
checks: list[Check] = field(default_factory=list)
|
|
180
|
+
tools: Any = None
|
|
181
|
+
reply: str
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# Messages: `TYPE` is the envelope's "type".
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
188
|
+
class Hello:
|
|
189
|
+
"""Integration → server: who's connecting, and how it proves it."""
|
|
190
|
+
|
|
191
|
+
TYPE: ClassVar[str] = "hello"
|
|
192
|
+
client: str
|
|
193
|
+
ha_version: str
|
|
194
|
+
auth: Auth
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
198
|
+
class Welcome:
|
|
199
|
+
"""Server → integration: accepted; the key only when pairing."""
|
|
200
|
+
|
|
201
|
+
TYPE: ClassVar[str] = "welcome"
|
|
202
|
+
server: str
|
|
203
|
+
server_id: str
|
|
204
|
+
key: str | None = None
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
208
|
+
class Ping:
|
|
209
|
+
"""Are you there?"""
|
|
210
|
+
|
|
211
|
+
TYPE: ClassVar[str] = "ping"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
215
|
+
class Pong:
|
|
216
|
+
"""Yes."""
|
|
217
|
+
|
|
218
|
+
TYPE: ClassVar[str] = "pong"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
222
|
+
class Snapshot:
|
|
223
|
+
"""The whole house, on connecting and when asked."""
|
|
224
|
+
|
|
225
|
+
TYPE: ClassVar[str] = "snapshot"
|
|
226
|
+
home: Home
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
230
|
+
class States:
|
|
231
|
+
"""State changes of exposed entities."""
|
|
232
|
+
|
|
233
|
+
TYPE: ClassVar[str] = "states"
|
|
234
|
+
changes: list[State]
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
238
|
+
class Decide:
|
|
239
|
+
"""What to do with these words."""
|
|
240
|
+
|
|
241
|
+
TYPE: ClassVar[str] = "decide"
|
|
242
|
+
words: str
|
|
243
|
+
conversation_id: str | None = None
|
|
244
|
+
here: str | None = None
|
|
245
|
+
focus: list[str] = field(default_factory=list)
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
249
|
+
class Decision:
|
|
250
|
+
"""The answer to a decide."""
|
|
251
|
+
|
|
252
|
+
TYPE: ClassVar[str] = "decision"
|
|
253
|
+
verdict: Verdict
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
257
|
+
class Turn:
|
|
258
|
+
"""What the brain did in a turn."""
|
|
259
|
+
|
|
260
|
+
TYPE: ClassVar[str] = "turn"
|
|
261
|
+
record: TurnRecord
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
265
|
+
class Outcome:
|
|
266
|
+
"""How a learned command's run went."""
|
|
267
|
+
|
|
268
|
+
TYPE: ClassVar[str] = "outcome"
|
|
269
|
+
command: int
|
|
270
|
+
checks: list[Check] = field(default_factory=list)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
274
|
+
class LearnRequest:
|
|
275
|
+
"""Server → integration: run this AI Task."""
|
|
276
|
+
|
|
277
|
+
TYPE: ClassVar[str] = "learn_request"
|
|
278
|
+
task: str
|
|
279
|
+
instructions: str
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
283
|
+
class LearnResult:
|
|
284
|
+
"""Integration → server: the AI Task's result."""
|
|
285
|
+
|
|
286
|
+
TYPE: ClassVar[str] = "learn_result"
|
|
287
|
+
data: Any = None
|
|
288
|
+
error: str | None = None
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
292
|
+
class SyncRequest:
|
|
293
|
+
"""Server → integration: send a full snapshot."""
|
|
294
|
+
|
|
295
|
+
TYPE: ClassVar[str] = "sync_request"
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
@dataclass(frozen=True, slots=True, kw_only=True)
|
|
299
|
+
class ErrorReply:
|
|
300
|
+
"""A request couldn't be done."""
|
|
301
|
+
|
|
302
|
+
TYPE: ClassVar[str] = "error"
|
|
303
|
+
code: ErrorCode
|
|
304
|
+
message: str
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
type Message = (
|
|
308
|
+
Hello
|
|
309
|
+
| Welcome
|
|
310
|
+
| Ping
|
|
311
|
+
| Pong
|
|
312
|
+
| Snapshot
|
|
313
|
+
| States
|
|
314
|
+
| Decide
|
|
315
|
+
| Decision
|
|
316
|
+
| Turn
|
|
317
|
+
| Outcome
|
|
318
|
+
| LearnRequest
|
|
319
|
+
| LearnResult
|
|
320
|
+
| SyncRequest
|
|
321
|
+
| ErrorReply
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
MESSAGE_TYPES: dict[str, type[Any]] = {
|
|
325
|
+
cls.TYPE: cls
|
|
326
|
+
for cls in (
|
|
327
|
+
Hello,
|
|
328
|
+
Welcome,
|
|
329
|
+
Ping,
|
|
330
|
+
Pong,
|
|
331
|
+
Snapshot,
|
|
332
|
+
States,
|
|
333
|
+
Decide,
|
|
334
|
+
Decision,
|
|
335
|
+
Turn,
|
|
336
|
+
Outcome,
|
|
337
|
+
LearnRequest,
|
|
338
|
+
LearnResult,
|
|
339
|
+
SyncRequest,
|
|
340
|
+
ErrorReply,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
@dataclass(frozen=True, slots=True)
|
|
346
|
+
class Envelope:
|
|
347
|
+
"""A message with its version and id."""
|
|
348
|
+
|
|
349
|
+
id: str
|
|
350
|
+
body: Message
|
|
351
|
+
v: int = VERSION
|
|
352
|
+
|
|
353
|
+
def to_json(self) -> str:
|
|
354
|
+
"""The message as one JSON object, as the server expects it."""
|
|
355
|
+
return json.dumps(
|
|
356
|
+
{"v": self.v, "id": self.id, "type": self.body.TYPE, **_to_dict(self.body)}
|
|
357
|
+
)
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
def parse(text: str) -> Envelope:
|
|
361
|
+
"""Read a message: the version first, then the type, then its fields."""
|
|
362
|
+
try:
|
|
363
|
+
raw = json.loads(text)
|
|
364
|
+
except ValueError as err:
|
|
365
|
+
raise MalformedMessageError(str(err)) from err
|
|
366
|
+
if not isinstance(raw, dict):
|
|
367
|
+
raise MalformedMessageError("not an object")
|
|
368
|
+
v = raw.pop("v", None)
|
|
369
|
+
if not isinstance(v, int) or isinstance(v, bool):
|
|
370
|
+
raise MalformedMessageError("no version")
|
|
371
|
+
if v != VERSION:
|
|
372
|
+
raise VersionMismatchError(v)
|
|
373
|
+
msg_id = raw.pop("id", None)
|
|
374
|
+
if not isinstance(msg_id, str):
|
|
375
|
+
raise MalformedMessageError("no id")
|
|
376
|
+
kind = raw.pop("type", None)
|
|
377
|
+
if not isinstance(kind, str):
|
|
378
|
+
raise MalformedMessageError("no type")
|
|
379
|
+
cls = MESSAGE_TYPES.get(kind)
|
|
380
|
+
if cls is None:
|
|
381
|
+
raise UnknownMessageTypeError(f"unknown message type {kind!r}")
|
|
382
|
+
return Envelope(id=msg_id, body=_from_dict(cls, raw, kind))
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
# A small typed converter between these dataclasses and JSON values.
|
|
386
|
+
|
|
387
|
+
|
|
388
|
+
def _to_dict(obj: Any) -> dict[str, Any]:
|
|
389
|
+
out = {f.name: _to_json(getattr(obj, f.name)) for f in fields(obj)}
|
|
390
|
+
if tag_key := getattr(type(obj), "TAG_KEY", None):
|
|
391
|
+
out[tag_key] = type(obj).TAG
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _to_json(value: Any) -> Any:
|
|
396
|
+
if is_dataclass(value) and not isinstance(value, type):
|
|
397
|
+
return _to_dict(value)
|
|
398
|
+
if isinstance(value, list):
|
|
399
|
+
return [_to_json(v) for v in value]
|
|
400
|
+
if isinstance(value, enum.Enum):
|
|
401
|
+
return value.value
|
|
402
|
+
return value
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _from_dict(cls: type[Any], raw: Any, where: str) -> Any:
|
|
406
|
+
if not isinstance(raw, dict):
|
|
407
|
+
raise MalformedMessageError(f"{where}: expected an object")
|
|
408
|
+
hints = get_type_hints(cls)
|
|
409
|
+
kwargs: dict[str, Any] = {}
|
|
410
|
+
for f in fields(cls):
|
|
411
|
+
if f.name in raw:
|
|
412
|
+
kwargs[f.name] = _convert(hints[f.name], raw[f.name], f"{where}.{f.name}")
|
|
413
|
+
elif f.default is MISSING and f.default_factory is MISSING:
|
|
414
|
+
raise MalformedMessageError(f"{where}: missing {f.name}")
|
|
415
|
+
return cls(**kwargs)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _convert(tp: Any, value: Any, where: str) -> Any: # noqa: PLR0911, PLR0912
|
|
419
|
+
# One branch per kind of type; splitting it would hide the whole picture.
|
|
420
|
+
tp = getattr(tp, "__value__", tp) # a `type X = …` alias
|
|
421
|
+
origin = get_origin(tp)
|
|
422
|
+
if tp is Any:
|
|
423
|
+
return value
|
|
424
|
+
if origin in (Union, types.UnionType):
|
|
425
|
+
options = [a for a in get_args(tp) if a is not type(None)]
|
|
426
|
+
if value is None and len(options) < len(get_args(tp)):
|
|
427
|
+
return None
|
|
428
|
+
tagged = [o for o in options if hasattr(o, "TAG_KEY")]
|
|
429
|
+
if tagged:
|
|
430
|
+
tag = value.get(tagged[0].TAG_KEY) if isinstance(value, dict) else None
|
|
431
|
+
for o in tagged:
|
|
432
|
+
if tag == o.TAG:
|
|
433
|
+
return _from_dict(o, value, where)
|
|
434
|
+
raise MalformedMessageError(f"{where}: unknown variant {tag!r}")
|
|
435
|
+
return _convert(options[0], value, where)
|
|
436
|
+
if origin is list:
|
|
437
|
+
if not isinstance(value, list):
|
|
438
|
+
raise MalformedMessageError(f"{where}: expected a list")
|
|
439
|
+
(item,) = get_args(tp)
|
|
440
|
+
return [_convert(item, v, f"{where}[{i}]") for i, v in enumerate(value)]
|
|
441
|
+
if isinstance(tp, type) and is_dataclass(tp):
|
|
442
|
+
return _from_dict(tp, value, where)
|
|
443
|
+
if isinstance(tp, type) and issubclass(tp, enum.Enum):
|
|
444
|
+
try:
|
|
445
|
+
return tp(value)
|
|
446
|
+
except ValueError as err:
|
|
447
|
+
raise MalformedMessageError(
|
|
448
|
+
f"{where}: {value!r} isn't a {tp.__name__}"
|
|
449
|
+
) from err
|
|
450
|
+
if tp is int and (not isinstance(value, int) or isinstance(value, bool)):
|
|
451
|
+
raise MalformedMessageError(f"{where}: expected an integer")
|
|
452
|
+
if tp in (str, bool) and not isinstance(value, tp):
|
|
453
|
+
raise MalformedMessageError(f"{where}: expected {tp.__name__}")
|
|
454
|
+
return value
|
|
File without changes
|