adaraz 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.

Potentially problematic release.


This version of adaraz might be problematic. Click here for more details.

@@ -0,0 +1,7 @@
1
+ __pycache__/
2
+ *.egg-info/
3
+ .pytest_cache/
4
+ .mypy_cache/
5
+ .ruff_cache/
6
+ dist/
7
+ build/
adaraz-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ADARAZ
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.
adaraz-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,300 @@
1
+ Metadata-Version: 2.5
2
+ Name: adaraz
3
+ Version: 0.1.0
4
+ Summary: ADARAZ Link: secure, Noise based connection between ADARAZ devices (RAZ-RCP-1) and servers
5
+ Project-URL: Homepage, https://github.com/ADARAZ-P-S-A/ADARAZ-HA
6
+ Project-URL: Documentation, https://github.com/ADARAZ-P-S-A/ADARAZ-HA/tree/main/lib#readme
7
+ Project-URL: Protocol, https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md
8
+ Project-URL: Issues, https://github.com/ADARAZ-P-S-A/ADARAZ-HA/issues
9
+ Author: ADARAZ
10
+ License-Expression: MIT
11
+ License-File: LICENSE
12
+ Keywords: adaraz,home assistant,noise,raz-rcp-1,time clock
13
+ Classifier: Development Status :: 4 - Beta
14
+ Classifier: Framework :: AsyncIO
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Home Automation
21
+ Classifier: Topic :: Security :: Cryptography
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.12
24
+ Requires-Dist: cryptography>=42
25
+ Provides-Extra: aiohttp
26
+ Requires-Dist: aiohttp>=3.10; extra == 'aiohttp'
27
+ Provides-Extra: test
28
+ Requires-Dist: aiohttp>=3.10; extra == 'test'
29
+ Requires-Dist: noiseprotocol==0.3.1; extra == 'test'
30
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'test'
31
+ Requires-Dist: pytest>=8; extra == 'test'
32
+ Description-Content-Type: text/markdown
33
+
34
+ # adaraz
35
+
36
+ Python implementation of **ADARAZ Link**, the protocol that connects ADARAZ devices
37
+ (first: the RAZ-RCP-1 card reader / time clock) to a server. It is used by the Home
38
+ Assistant integration and can be used to build your own server.
39
+
40
+ - The device always connects out (WebSocket), the server never connects to a device.
41
+ - Every connection is a Noise session (`Noise_XXpsk3_25519_AESGCM_SHA256` for pairing,
42
+ `Noise_IK_25519_AESGCM_SHA256` for sessions) with mutual authentication, so plain
43
+ `ws://` in a LAN is safe.
44
+ - Pairing is confirmed with a one-time code that a person copies to the device.
45
+ - No punch is lost: delivery follows a cursor into the device log, and the server
46
+ acknowledges only after it stored the records.
47
+
48
+ The wire protocol is specified in
49
+ [`docs/protocol.md`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md).
50
+
51
+ ```
52
+ pip install adaraz # core (only depends on cryptography)
53
+ pip install "adaraz[aiohttp]" # plus the aiohttp WebSocket adapter
54
+ ```
55
+
56
+ Python 3.12 or newer. The library is fully typed (`py.typed`), async, and does no
57
+ blocking I/O in the event loop. It does not import Home Assistant.
58
+
59
+ ## Quick start: a server
60
+
61
+ ```python
62
+ import asyncio
63
+ from aiohttp import web
64
+ from adaraz import InMemoryStorage, LinkServer, PunchReceived, generate_private_key
65
+
66
+ async def main() -> None:
67
+ # Keep the private key and the storage persistent in real use: devices pin the
68
+ # server's public key at pairing.
69
+ server = LinkServer(generate_private_key(), InMemoryStorage(), server="My app", name="Office")
70
+
71
+ def on_event(event) -> None:
72
+ if isinstance(event, PunchReceived) and not event.punch.late:
73
+ print("card", event.punch.uid, "at", event.punch.ts, "on", event.device.device_id)
74
+
75
+ server.subscribe(on_event)
76
+
77
+ app = web.Application()
78
+ app.router.add_get("/api/adaraz/ws", server.handle_aiohttp)
79
+ runner = web.AppRunner(app)
80
+ await runner.setup()
81
+ await web.TCPSite(runner, "0.0.0.0", 8765).start()
82
+
83
+ window = server.open_pairing() # valid 10 minutes, one device
84
+ print("Enter http://<host>:8765 and code", window.code, "on the device")
85
+ device = await window # PairedDevice
86
+ print("paired", device.device_id)
87
+
88
+ await asyncio.sleep(5)
89
+ result = await server.send_relay(device.device_id, "pulse", ms=1500)
90
+ print("relay:", result.ok, result.err)
91
+ await asyncio.Event().wait()
92
+
93
+ asyncio.run(main())
94
+ ```
95
+
96
+ A complete standalone server with file storage and an admin HTTP API is in
97
+ [`examples/server.py`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/examples/server.py).
98
+
99
+ ## Public API
100
+
101
+ Everything below is importable from `adaraz` unless a module is named.
102
+
103
+ ### Keys and codes
104
+
105
+ | Name | Description |
106
+ |---|---|
107
+ | `generate_private_key() -> bytes` | New X25519 private key (32 bytes). |
108
+ | `public_key_from_private(sk) -> bytes` | Matching public key. |
109
+ | `server_id(public_key) -> str` | Server identifier shown on the device (hex SHA-256, first 16 bytes). |
110
+ | `generate_code() -> str` | New random pairing code: 12 symbols of Crockford Base32 (60 bits), formatted `XXXX-XXXX-XXXX`. |
111
+ | `normalize_code(code) -> str` | Uppercase, remove `-` and spaces, map `O` to `0` and `I`/`L` to `1`; returns the 12 symbols without dashes. `InvalidCodeError` (a `ValueError`) if the result is not 12 alphabet symbols. |
112
+ | `format_code(code) -> str` | Normalise, then return the display form `XXXX-XXXX-XXXX`. |
113
+ | `derive_psk(code) -> bytes` | Noise PSK of a code (HKDF-SHA256 of the normalised code). Test vector: `0123-4567-89AB` gives `377690b4f263fcbe5c12a80d283e43febcb90b05326ca85477c0861940cf99a4`. |
114
+
115
+ `adaraz.codes` also exports `ALPHABET`, `CODE_LENGTH` (12) and `GROUP_LENGTH` (4).
116
+
117
+ ### `LinkServer`
118
+
119
+ ```python
120
+ LinkServer(private_key: bytes, storage: LinkStorage, *, server="adaraz", name="",
121
+ ping_interval=30.0, idle_timeout=90.0, handshake_timeout=30.0,
122
+ establish_timeout=10.0, relay_timeout=10.0, late_after=300.0, clock=time.time)
123
+ ```
124
+
125
+ - `public_key`, `server_id`, `storage`, `sessions`, `pairing_windows`
126
+ - `open_pairing(timeout=600, *, import_history=True) -> PairingWindow`
127
+ - `window.code` (show it), `window.expires_at`, `window.failures`, `window.active`
128
+ - `await window` (or `await window.wait()`) returns the `PairedDevice`, or raises
129
+ `PairingExpired`, `PairingFailed` (5 failed attempts, see below) or
130
+ `PairingCancelled`
131
+ - a **failed attempt** is a handshake that reached the code check: a well-formed
132
+ pairing message 3 (its static key decrypts, which does not depend on the code)
133
+ whose code protected part decrypts with none of the open windows. It counts
134
+ against every open window, since the server cannot tell which code the device
135
+ was given. A connection that ends, times out, or sends a malformed or oversized
136
+ message before that point is closed (4000) without counting. Note that anyone
137
+ who can reach the endpoint can still run 5 complete handshakes with a wrong code
138
+ and so close an open window (this is what limits online guessing); show the
139
+ `PairingFailed` outcome to the user and offer a new code.
140
+ - `window.cancel()`
141
+ - with `import_history` the device sends its whole log in the first session,
142
+ otherwise only records made after pairing
143
+ - `await handle_aiohttp(request) -> web.WebSocketResponse`: aiohttp handler for the
144
+ WebSocket endpoint (aiohttp is imported lazily)
145
+ - `await handle_connection(transport)`: run one connection over any `Transport`
146
+ - `get_session(device_id) -> DeviceSession | None`, `is_connected(device_id)`:
147
+ by the id stored at pairing (a device cannot change it later, see below)
148
+ - `get_session_by_key(public_key) -> DeviceSession | None`: by the device's static
149
+ public key, its actual identity
150
+ - `await send_relay(device_id, cmd, ms=None, *, timeout=None) -> RelayResult`:
151
+ `cmd` is `open`, `on`, `off` or `pulse` (`ms` 100-10000). Raises `ValueError`,
152
+ `DeviceNotConnected`, `RelayTimeout`. A refusal by the device is
153
+ `RelayResult(ok=False, err="not_allowed" | "invalid" | "busy")`.
154
+ - `await unpair(device_id)`: forget the device and tell it to delete its pairing.
155
+ Raises `DeviceNotFound`.
156
+ - `subscribe(listener) -> unsubscribe`: listeners get one event object (sync
157
+ callables, or coroutine functions which are run as tasks)
158
+ - `await close()`: close all sessions and pairing windows
159
+
160
+ Events (frozen dataclasses):
161
+
162
+ | Event | Fields |
163
+ |---|---|
164
+ | `DevicePaired` | `device: PairedDevice` |
165
+ | `DeviceUnpaired` | `device` |
166
+ | `DeviceConnected` | `device`, `hello: Hello` (`fw`, `name`, `caps`, `log`); emitted when the session is established |
167
+ | `DeviceDisconnected` | `device`, `code: int \| None`; only for sessions that were established |
168
+ | `StateChanged` | `device`, `relay: bool`, `reader_ok: bool` |
169
+ | `PunchReceived` | `device`, `punch: PunchRecord` |
170
+
171
+ `PunchRecord` has `device_id`, `epoch`, `index`, `uid`, `ts`, `late`, `received_at` and
172
+ `key` (`(device_id, uid, ts)`). A late punch (arrived more than 5 minutes after `ts`)
173
+ belongs in history but should not trigger real-time actions.
174
+
175
+ `DeviceSession` (read-only): `device`, `hello`, `state`, `cursor`, `connected_at`,
176
+ `relay_allowed` (the device announced the `relay` capability).
177
+
178
+ Session rules implemented by the server:
179
+
180
+ - A device that is not paired, is paired with a different server key, or sends a
181
+ `hello.id` different from the id stored at pairing is closed with 4001. The device
182
+ is identified only by its static public key; `hello` updates `name`, `model` and
183
+ `fw` of the stored record, never `device_id`.
184
+ - A session is **established** only when the server has decrypted the device's
185
+ first transport message (the device sends `state` right after `welcome`). Session
186
+ message 1 can be replayed by anyone who recorded it, so before that point the
187
+ server changes nothing: no storage write, no event, no entry in `sessions` /
188
+ `get_session`, and an older session of the same device stays up. A session that is
189
+ not established within `establish_timeout` (10 s) is closed with 4000.
190
+ - When the new session is established, an older session of the same device is
191
+ closed with 4002 (its `DeviceDisconnected` is emitted before the new
192
+ `DeviceConnected`), the record is updated from `hello`, and a missing cursor is
193
+ set to the device's current count.
194
+ - `ping` after 30 s of silence, close with 1001 after 90 s without traffic.
195
+ - `ack` only after `storage.store_punches` returned. Empty `punches` batches (only
196
+ skipped indices) are stored and acknowledged like any other.
197
+ - Text frames, messages over 4096 bytes, bad JSON, unexpected types and integers
198
+ outside uint32 (log indices, counts, cursor positions, punch timestamps) close
199
+ with 4000.
200
+
201
+ ### Storage
202
+
203
+ Implement `LinkStorage` (abstract base class, all methods async):
204
+
205
+ ```python
206
+ class LinkStorage:
207
+ async def list_devices(self) -> Sequence[PairedDevice]
208
+ async def get_device(self, public_key: bytes) -> PairedDevice | None # default uses list_devices
209
+ async def add_device(self, device: PairedDevice) -> None # insert or replace by key
210
+ async def remove_device(self, public_key: bytes) -> None # and its cursor
211
+ async def load_cursor(self, public_key: bytes) -> Cursor | None
212
+ async def save_cursor(self, public_key: bytes, cursor: Cursor) -> None
213
+ async def store_punches(self, device, punches: Sequence[PunchRecord], cursor: Cursor
214
+ ) -> Sequence[PunchRecord] # returns the new ones
215
+ ```
216
+
217
+ `store_punches` must make the punches and the cursor durable before it returns and
218
+ return the punches that are not duplicates; `PunchDeduplicator` helps with that.
219
+ `InMemoryStorage` is a non-persistent implementation for tests.
220
+
221
+ A device id is unique: pairing a device whose id is already known under another key
222
+ (factory reset) removes the old record and closes its session. The id is set at
223
+ pairing and never changed by a session, so `device_id` lookups are stable; key
224
+ lookups (`get_device`, `get_session_by_key`) are the primary identity.
225
+
226
+ ### Transport
227
+
228
+ ```python
229
+ class Transport(Protocol):
230
+ async def recv(self) -> bytes | None # None when closed; text frame -> ProtocolError
231
+ async def send(self, data: bytes) -> None
232
+ async def close(self, code: int = 1000) -> None
233
+ @property
234
+ def close_code(self) -> int | None
235
+ ```
236
+
237
+ - `adaraz.aiohttp_ws.AiohttpTransport(ws)` wraps an aiohttp server or client
238
+ WebSocket; `adaraz.aiohttp_ws.connect(session, base_url)` opens a client connection.
239
+ - `memory_pipe()` returns two connected in-memory transports (tests).
240
+ - `build_ws_url(base_url)` maps a configured server URL to the WebSocket URL.
241
+
242
+ ### Simulated device: `adaraz.device`
243
+
244
+ `LinkDevice` is a complete device-side implementation, for testing servers without
245
+ hardware and as a reference for firmware:
246
+
247
+ ```python
248
+ from adaraz.aiohttp_ws import connect
249
+ from adaraz.device import LinkDevice
250
+
251
+ device = LinkDevice(device_id="C00F0202", relay_allowed=True)
252
+ await device.pair(await connect(http, "http://127.0.0.1:8765"), "ABCD-EFGH-1234")
253
+ task = asyncio.create_task(device.run_session(await connect(http, "http://127.0.0.1:8765")))
254
+ device.punch("04A1B2C3")
255
+ await device.wait_delivered()
256
+ await device.stop()
257
+ code = await task # WebSocket close code
258
+ ```
259
+
260
+ It keeps an `EventLog` (`append`, `delete`, `purge`; deleting or purging changes the
261
+ epoch), follows the server cursor, sends `state` as its first transport message
262
+ right after `welcome`, sends batches of at most 100 records that fit in one 4096
263
+ byte Noise message (empty when every index is skipped), waits for each `ack`, and
264
+ after an epoch change waits for the `ack` in flight and starts again from index 0.
265
+ It reports `state`, executes relay commands (`not_allowed` unless `relay_allowed`
266
+ and `relay_enabled`, `on`/`off` cancel a pulse in progress, `busy` for
267
+ `open`/`pulse` within 1 s), and on `unpair` deletes its pairing and returns the relay
268
+ to rest. It does not reconnect by itself.
269
+
270
+ - `LinkDevice(..., relay_allowed=False, relay_enabled=True, ...)`: `relay_allowed`
271
+ is the administrator's remote relay consent (announced as the `relay` capability),
272
+ `relay_enabled` is the local relay setting (False: relay disabled).
273
+ - `await device.set_relay_allowed(allowed)`: revoking consent returns the relay to
274
+ rest and reports `state`.
275
+ - `device.delete_record(index)`, `device.purge_log()`: change the log (new epoch)
276
+ and restart delivery during a running session.
277
+
278
+ ### Low level
279
+
280
+ `adaraz.noise` (Noise `CipherState`, `SymmetricState`, `HandshakeState`, patterns
281
+ `XXPSK3` and `IK`) and `adaraz.messages` (payload dataclasses, `encode`, `decode`) are
282
+ public too, for tools and tests. `adaraz.const` holds the protocol constants (e.g.
283
+ `ESTABLISH_TIMEOUT`, `MAX_UINT32`).
284
+
285
+ ## Development
286
+
287
+ ```
288
+ cd lib
289
+ pip install -e ".[test]" mypy ruff
290
+ pytest
291
+ mypy
292
+ ruff check src tests
293
+ ```
294
+
295
+ The Noise implementation is tested for interoperability against the reference
296
+ library `noiseprotocol` for both patterns in both roles.
297
+
298
+ ## License
299
+
300
+ MIT
adaraz-0.1.0/README.md ADDED
@@ -0,0 +1,267 @@
1
+ # adaraz
2
+
3
+ Python implementation of **ADARAZ Link**, the protocol that connects ADARAZ devices
4
+ (first: the RAZ-RCP-1 card reader / time clock) to a server. It is used by the Home
5
+ Assistant integration and can be used to build your own server.
6
+
7
+ - The device always connects out (WebSocket), the server never connects to a device.
8
+ - Every connection is a Noise session (`Noise_XXpsk3_25519_AESGCM_SHA256` for pairing,
9
+ `Noise_IK_25519_AESGCM_SHA256` for sessions) with mutual authentication, so plain
10
+ `ws://` in a LAN is safe.
11
+ - Pairing is confirmed with a one-time code that a person copies to the device.
12
+ - No punch is lost: delivery follows a cursor into the device log, and the server
13
+ acknowledges only after it stored the records.
14
+
15
+ The wire protocol is specified in
16
+ [`docs/protocol.md`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md).
17
+
18
+ ```
19
+ pip install adaraz # core (only depends on cryptography)
20
+ pip install "adaraz[aiohttp]" # plus the aiohttp WebSocket adapter
21
+ ```
22
+
23
+ Python 3.12 or newer. The library is fully typed (`py.typed`), async, and does no
24
+ blocking I/O in the event loop. It does not import Home Assistant.
25
+
26
+ ## Quick start: a server
27
+
28
+ ```python
29
+ import asyncio
30
+ from aiohttp import web
31
+ from adaraz import InMemoryStorage, LinkServer, PunchReceived, generate_private_key
32
+
33
+ async def main() -> None:
34
+ # Keep the private key and the storage persistent in real use: devices pin the
35
+ # server's public key at pairing.
36
+ server = LinkServer(generate_private_key(), InMemoryStorage(), server="My app", name="Office")
37
+
38
+ def on_event(event) -> None:
39
+ if isinstance(event, PunchReceived) and not event.punch.late:
40
+ print("card", event.punch.uid, "at", event.punch.ts, "on", event.device.device_id)
41
+
42
+ server.subscribe(on_event)
43
+
44
+ app = web.Application()
45
+ app.router.add_get("/api/adaraz/ws", server.handle_aiohttp)
46
+ runner = web.AppRunner(app)
47
+ await runner.setup()
48
+ await web.TCPSite(runner, "0.0.0.0", 8765).start()
49
+
50
+ window = server.open_pairing() # valid 10 minutes, one device
51
+ print("Enter http://<host>:8765 and code", window.code, "on the device")
52
+ device = await window # PairedDevice
53
+ print("paired", device.device_id)
54
+
55
+ await asyncio.sleep(5)
56
+ result = await server.send_relay(device.device_id, "pulse", ms=1500)
57
+ print("relay:", result.ok, result.err)
58
+ await asyncio.Event().wait()
59
+
60
+ asyncio.run(main())
61
+ ```
62
+
63
+ A complete standalone server with file storage and an admin HTTP API is in
64
+ [`examples/server.py`](https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/examples/server.py).
65
+
66
+ ## Public API
67
+
68
+ Everything below is importable from `adaraz` unless a module is named.
69
+
70
+ ### Keys and codes
71
+
72
+ | Name | Description |
73
+ |---|---|
74
+ | `generate_private_key() -> bytes` | New X25519 private key (32 bytes). |
75
+ | `public_key_from_private(sk) -> bytes` | Matching public key. |
76
+ | `server_id(public_key) -> str` | Server identifier shown on the device (hex SHA-256, first 16 bytes). |
77
+ | `generate_code() -> str` | New random pairing code: 12 symbols of Crockford Base32 (60 bits), formatted `XXXX-XXXX-XXXX`. |
78
+ | `normalize_code(code) -> str` | Uppercase, remove `-` and spaces, map `O` to `0` and `I`/`L` to `1`; returns the 12 symbols without dashes. `InvalidCodeError` (a `ValueError`) if the result is not 12 alphabet symbols. |
79
+ | `format_code(code) -> str` | Normalise, then return the display form `XXXX-XXXX-XXXX`. |
80
+ | `derive_psk(code) -> bytes` | Noise PSK of a code (HKDF-SHA256 of the normalised code). Test vector: `0123-4567-89AB` gives `377690b4f263fcbe5c12a80d283e43febcb90b05326ca85477c0861940cf99a4`. |
81
+
82
+ `adaraz.codes` also exports `ALPHABET`, `CODE_LENGTH` (12) and `GROUP_LENGTH` (4).
83
+
84
+ ### `LinkServer`
85
+
86
+ ```python
87
+ LinkServer(private_key: bytes, storage: LinkStorage, *, server="adaraz", name="",
88
+ ping_interval=30.0, idle_timeout=90.0, handshake_timeout=30.0,
89
+ establish_timeout=10.0, relay_timeout=10.0, late_after=300.0, clock=time.time)
90
+ ```
91
+
92
+ - `public_key`, `server_id`, `storage`, `sessions`, `pairing_windows`
93
+ - `open_pairing(timeout=600, *, import_history=True) -> PairingWindow`
94
+ - `window.code` (show it), `window.expires_at`, `window.failures`, `window.active`
95
+ - `await window` (or `await window.wait()`) returns the `PairedDevice`, or raises
96
+ `PairingExpired`, `PairingFailed` (5 failed attempts, see below) or
97
+ `PairingCancelled`
98
+ - a **failed attempt** is a handshake that reached the code check: a well-formed
99
+ pairing message 3 (its static key decrypts, which does not depend on the code)
100
+ whose code protected part decrypts with none of the open windows. It counts
101
+ against every open window, since the server cannot tell which code the device
102
+ was given. A connection that ends, times out, or sends a malformed or oversized
103
+ message before that point is closed (4000) without counting. Note that anyone
104
+ who can reach the endpoint can still run 5 complete handshakes with a wrong code
105
+ and so close an open window (this is what limits online guessing); show the
106
+ `PairingFailed` outcome to the user and offer a new code.
107
+ - `window.cancel()`
108
+ - with `import_history` the device sends its whole log in the first session,
109
+ otherwise only records made after pairing
110
+ - `await handle_aiohttp(request) -> web.WebSocketResponse`: aiohttp handler for the
111
+ WebSocket endpoint (aiohttp is imported lazily)
112
+ - `await handle_connection(transport)`: run one connection over any `Transport`
113
+ - `get_session(device_id) -> DeviceSession | None`, `is_connected(device_id)`:
114
+ by the id stored at pairing (a device cannot change it later, see below)
115
+ - `get_session_by_key(public_key) -> DeviceSession | None`: by the device's static
116
+ public key, its actual identity
117
+ - `await send_relay(device_id, cmd, ms=None, *, timeout=None) -> RelayResult`:
118
+ `cmd` is `open`, `on`, `off` or `pulse` (`ms` 100-10000). Raises `ValueError`,
119
+ `DeviceNotConnected`, `RelayTimeout`. A refusal by the device is
120
+ `RelayResult(ok=False, err="not_allowed" | "invalid" | "busy")`.
121
+ - `await unpair(device_id)`: forget the device and tell it to delete its pairing.
122
+ Raises `DeviceNotFound`.
123
+ - `subscribe(listener) -> unsubscribe`: listeners get one event object (sync
124
+ callables, or coroutine functions which are run as tasks)
125
+ - `await close()`: close all sessions and pairing windows
126
+
127
+ Events (frozen dataclasses):
128
+
129
+ | Event | Fields |
130
+ |---|---|
131
+ | `DevicePaired` | `device: PairedDevice` |
132
+ | `DeviceUnpaired` | `device` |
133
+ | `DeviceConnected` | `device`, `hello: Hello` (`fw`, `name`, `caps`, `log`); emitted when the session is established |
134
+ | `DeviceDisconnected` | `device`, `code: int \| None`; only for sessions that were established |
135
+ | `StateChanged` | `device`, `relay: bool`, `reader_ok: bool` |
136
+ | `PunchReceived` | `device`, `punch: PunchRecord` |
137
+
138
+ `PunchRecord` has `device_id`, `epoch`, `index`, `uid`, `ts`, `late`, `received_at` and
139
+ `key` (`(device_id, uid, ts)`). A late punch (arrived more than 5 minutes after `ts`)
140
+ belongs in history but should not trigger real-time actions.
141
+
142
+ `DeviceSession` (read-only): `device`, `hello`, `state`, `cursor`, `connected_at`,
143
+ `relay_allowed` (the device announced the `relay` capability).
144
+
145
+ Session rules implemented by the server:
146
+
147
+ - A device that is not paired, is paired with a different server key, or sends a
148
+ `hello.id` different from the id stored at pairing is closed with 4001. The device
149
+ is identified only by its static public key; `hello` updates `name`, `model` and
150
+ `fw` of the stored record, never `device_id`.
151
+ - A session is **established** only when the server has decrypted the device's
152
+ first transport message (the device sends `state` right after `welcome`). Session
153
+ message 1 can be replayed by anyone who recorded it, so before that point the
154
+ server changes nothing: no storage write, no event, no entry in `sessions` /
155
+ `get_session`, and an older session of the same device stays up. A session that is
156
+ not established within `establish_timeout` (10 s) is closed with 4000.
157
+ - When the new session is established, an older session of the same device is
158
+ closed with 4002 (its `DeviceDisconnected` is emitted before the new
159
+ `DeviceConnected`), the record is updated from `hello`, and a missing cursor is
160
+ set to the device's current count.
161
+ - `ping` after 30 s of silence, close with 1001 after 90 s without traffic.
162
+ - `ack` only after `storage.store_punches` returned. Empty `punches` batches (only
163
+ skipped indices) are stored and acknowledged like any other.
164
+ - Text frames, messages over 4096 bytes, bad JSON, unexpected types and integers
165
+ outside uint32 (log indices, counts, cursor positions, punch timestamps) close
166
+ with 4000.
167
+
168
+ ### Storage
169
+
170
+ Implement `LinkStorage` (abstract base class, all methods async):
171
+
172
+ ```python
173
+ class LinkStorage:
174
+ async def list_devices(self) -> Sequence[PairedDevice]
175
+ async def get_device(self, public_key: bytes) -> PairedDevice | None # default uses list_devices
176
+ async def add_device(self, device: PairedDevice) -> None # insert or replace by key
177
+ async def remove_device(self, public_key: bytes) -> None # and its cursor
178
+ async def load_cursor(self, public_key: bytes) -> Cursor | None
179
+ async def save_cursor(self, public_key: bytes, cursor: Cursor) -> None
180
+ async def store_punches(self, device, punches: Sequence[PunchRecord], cursor: Cursor
181
+ ) -> Sequence[PunchRecord] # returns the new ones
182
+ ```
183
+
184
+ `store_punches` must make the punches and the cursor durable before it returns and
185
+ return the punches that are not duplicates; `PunchDeduplicator` helps with that.
186
+ `InMemoryStorage` is a non-persistent implementation for tests.
187
+
188
+ A device id is unique: pairing a device whose id is already known under another key
189
+ (factory reset) removes the old record and closes its session. The id is set at
190
+ pairing and never changed by a session, so `device_id` lookups are stable; key
191
+ lookups (`get_device`, `get_session_by_key`) are the primary identity.
192
+
193
+ ### Transport
194
+
195
+ ```python
196
+ class Transport(Protocol):
197
+ async def recv(self) -> bytes | None # None when closed; text frame -> ProtocolError
198
+ async def send(self, data: bytes) -> None
199
+ async def close(self, code: int = 1000) -> None
200
+ @property
201
+ def close_code(self) -> int | None
202
+ ```
203
+
204
+ - `adaraz.aiohttp_ws.AiohttpTransport(ws)` wraps an aiohttp server or client
205
+ WebSocket; `adaraz.aiohttp_ws.connect(session, base_url)` opens a client connection.
206
+ - `memory_pipe()` returns two connected in-memory transports (tests).
207
+ - `build_ws_url(base_url)` maps a configured server URL to the WebSocket URL.
208
+
209
+ ### Simulated device: `adaraz.device`
210
+
211
+ `LinkDevice` is a complete device-side implementation, for testing servers without
212
+ hardware and as a reference for firmware:
213
+
214
+ ```python
215
+ from adaraz.aiohttp_ws import connect
216
+ from adaraz.device import LinkDevice
217
+
218
+ device = LinkDevice(device_id="C00F0202", relay_allowed=True)
219
+ await device.pair(await connect(http, "http://127.0.0.1:8765"), "ABCD-EFGH-1234")
220
+ task = asyncio.create_task(device.run_session(await connect(http, "http://127.0.0.1:8765")))
221
+ device.punch("04A1B2C3")
222
+ await device.wait_delivered()
223
+ await device.stop()
224
+ code = await task # WebSocket close code
225
+ ```
226
+
227
+ It keeps an `EventLog` (`append`, `delete`, `purge`; deleting or purging changes the
228
+ epoch), follows the server cursor, sends `state` as its first transport message
229
+ right after `welcome`, sends batches of at most 100 records that fit in one 4096
230
+ byte Noise message (empty when every index is skipped), waits for each `ack`, and
231
+ after an epoch change waits for the `ack` in flight and starts again from index 0.
232
+ It reports `state`, executes relay commands (`not_allowed` unless `relay_allowed`
233
+ and `relay_enabled`, `on`/`off` cancel a pulse in progress, `busy` for
234
+ `open`/`pulse` within 1 s), and on `unpair` deletes its pairing and returns the relay
235
+ to rest. It does not reconnect by itself.
236
+
237
+ - `LinkDevice(..., relay_allowed=False, relay_enabled=True, ...)`: `relay_allowed`
238
+ is the administrator's remote relay consent (announced as the `relay` capability),
239
+ `relay_enabled` is the local relay setting (False: relay disabled).
240
+ - `await device.set_relay_allowed(allowed)`: revoking consent returns the relay to
241
+ rest and reports `state`.
242
+ - `device.delete_record(index)`, `device.purge_log()`: change the log (new epoch)
243
+ and restart delivery during a running session.
244
+
245
+ ### Low level
246
+
247
+ `adaraz.noise` (Noise `CipherState`, `SymmetricState`, `HandshakeState`, patterns
248
+ `XXPSK3` and `IK`) and `adaraz.messages` (payload dataclasses, `encode`, `decode`) are
249
+ public too, for tools and tests. `adaraz.const` holds the protocol constants (e.g.
250
+ `ESTABLISH_TIMEOUT`, `MAX_UINT32`).
251
+
252
+ ## Development
253
+
254
+ ```
255
+ cd lib
256
+ pip install -e ".[test]" mypy ruff
257
+ pytest
258
+ mypy
259
+ ruff check src tests
260
+ ```
261
+
262
+ The Noise implementation is tested for interoperability against the reference
263
+ library `noiseprotocol` for both patterns in both roles.
264
+
265
+ ## License
266
+
267
+ MIT
@@ -0,0 +1,70 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.24"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "adaraz"
7
+ version = "0.1.0"
8
+ description = "ADARAZ Link: secure, Noise based connection between ADARAZ devices (RAZ-RCP-1) and servers"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.12"
13
+ authors = [{ name = "ADARAZ" }]
14
+ keywords = ["adaraz", "raz-rcp-1", "noise", "time clock", "home assistant"]
15
+ classifiers = [
16
+ "Development Status :: 4 - Beta",
17
+ "Framework :: AsyncIO",
18
+ "Intended Audience :: Developers",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Topic :: Home Automation",
24
+ "Topic :: Security :: Cryptography",
25
+ "Typing :: Typed",
26
+ ]
27
+ dependencies = ["cryptography>=42"]
28
+
29
+ [project.optional-dependencies]
30
+ aiohttp = ["aiohttp>=3.10"]
31
+ test = [
32
+ "aiohttp>=3.10",
33
+ "noiseprotocol==0.3.1",
34
+ "pytest>=8",
35
+ "pytest-asyncio>=0.24",
36
+ ]
37
+
38
+ [project.urls]
39
+ Homepage = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA"
40
+ Documentation = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA/tree/main/lib#readme"
41
+ Protocol = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA/blob/main/docs/protocol.md"
42
+ Issues = "https://github.com/ADARAZ-P-S-A/ADARAZ-HA/issues"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["src/adaraz"]
46
+
47
+ [tool.hatch.build.targets.sdist]
48
+ include = ["src/adaraz", "tests", "README.md", "LICENSE"]
49
+
50
+ [tool.pytest.ini_options]
51
+ asyncio_mode = "auto"
52
+ asyncio_default_fixture_loop_scope = "function"
53
+ testpaths = ["tests"]
54
+
55
+ [tool.mypy]
56
+ strict = true
57
+ python_version = "3.12"
58
+ files = ["src/adaraz"]
59
+
60
+ [tool.ruff]
61
+ line-length = 100
62
+ target-version = "py312"
63
+
64
+ [tool.ruff.lint]
65
+ select = ["E", "F", "W", "I", "UP", "B", "SIM", "RUF", "ASYNC"]
66
+ # Public coroutines take an explicit timeout argument on purpose.
67
+ ignore = ["ASYNC109"]
68
+
69
+ [tool.ruff.lint.per-file-ignores]
70
+ "tests/*" = ["ASYNC110"]