octo-bed-protocol 0.2.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.
@@ -0,0 +1,16 @@
1
+ .env
2
+ .env.local
3
+
4
+ __pycache__/
5
+ *.pyc
6
+ .venv/
7
+
8
+ # Vendor app binaries and verbatim extracted third-party code. The protocol
9
+ # description in docs/protocol.md is our own write-up and stays versioned;
10
+ # the material it was derived from does not belong in this repository.
11
+ reference/
12
+ *.apk
13
+
14
+ # Testartefakte - alles, was ein Lauf erzeugt, landet hier
15
+ .artefakte/
16
+ .ruff_cache/
@@ -0,0 +1,47 @@
1
+ Metadata-Version: 2.5
2
+ Name: octo-bed-protocol
3
+ Version: 0.2.0
4
+ Summary: Wire protocol of Octo adjustable bed controllers over Bluetooth Low Energy
5
+ Project-URL: Homepage, https://github.com/skjall/home-assistant-octo-bed
6
+ Project-URL: Source, https://github.com/skjall/home-assistant-octo-bed/tree/main/lib/octo_bed_protocol
7
+ Project-URL: Issues, https://github.com/skjall/home-assistant-octo-bed/issues
8
+ Author: Jan Großheim
9
+ License-Expression: MIT
10
+ Keywords: adjustable-bed,ble,bluetooth,home-assistant,octo
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Topic :: Home Automation
16
+ Classifier: Typing :: Typed
17
+ Requires-Python: >=3.12
18
+ Description-Content-Type: text/markdown
19
+
20
+ # octo-bed-protocol
21
+
22
+ The wire protocol of Octo adjustable bed controllers (the RC2 receiver and its
23
+ relatives) over Bluetooth Low Energy, as used by the
24
+ [Octo Bed](https://github.com/skjall/home-assistant-octo-bed) Home Assistant
25
+ integration.
26
+
27
+ It builds frames and reads replies. It opens no connections and depends on no
28
+ Bluetooth stack, so it can be used and tested without hardware.
29
+
30
+ ```python
31
+ import octo_bed_protocol as octo
32
+
33
+ octo.move(octo.MOTOR_HEAD, up=True).hex(" ") # '40 02 70 00 01 0b 02 40'
34
+ octo.stop().hex(" ") # '40 02 73 00 00 0b 40'
35
+
36
+ reader = octo.FrameReader()
37
+ features = octo.Features()
38
+ for packet in reader.feed(notification):
39
+ features.add(packet)
40
+ ```
41
+
42
+ Every frame goes to characteristic `ffe1` of service `ffe0`. How each byte was
43
+ established is recorded in
44
+ [docs/protocol.md](https://github.com/skjall/home-assistant-octo-bed/blob/main/docs/protocol.md).
45
+
46
+ Not affiliated with, endorsed by, or supported by Octo. All trademarks belong
47
+ to their owners.
@@ -0,0 +1,28 @@
1
+ # octo-bed-protocol
2
+
3
+ The wire protocol of Octo adjustable bed controllers (the RC2 receiver and its
4
+ relatives) over Bluetooth Low Energy, as used by the
5
+ [Octo Bed](https://github.com/skjall/home-assistant-octo-bed) Home Assistant
6
+ integration.
7
+
8
+ It builds frames and reads replies. It opens no connections and depends on no
9
+ Bluetooth stack, so it can be used and tested without hardware.
10
+
11
+ ```python
12
+ import octo_bed_protocol as octo
13
+
14
+ octo.move(octo.MOTOR_HEAD, up=True).hex(" ") # '40 02 70 00 01 0b 02 40'
15
+ octo.stop().hex(" ") # '40 02 73 00 00 0b 40'
16
+
17
+ reader = octo.FrameReader()
18
+ features = octo.Features()
19
+ for packet in reader.feed(notification):
20
+ features.add(packet)
21
+ ```
22
+
23
+ Every frame goes to characteristic `ffe1` of service `ffe0`. How each byte was
24
+ established is recorded in
25
+ [docs/protocol.md](https://github.com/skjall/home-assistant-octo-bed/blob/main/docs/protocol.md).
26
+
27
+ Not affiliated with, endorsed by, or supported by Octo. All trademarks belong
28
+ to their owners.
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "octo-bed-protocol"
7
+ version = "0.2.0" # x-release-please-version
8
+ description = "Wire protocol of Octo adjustable bed controllers over Bluetooth Low Energy"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ requires-python = ">=3.12"
12
+ authors = [{ name = "Jan Großheim" }]
13
+ keywords = ["octo", "adjustable-bed", "bluetooth", "ble", "home-assistant"]
14
+ classifiers = [
15
+ "Development Status :: 3 - Alpha",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Programming Language :: Python :: 3",
19
+ "Topic :: Home Automation",
20
+ "Typing :: Typed",
21
+ ]
22
+ dependencies = []
23
+
24
+ [project.urls]
25
+ Homepage = "https://github.com/skjall/home-assistant-octo-bed"
26
+ Source = "https://github.com/skjall/home-assistant-octo-bed/tree/main/lib/octo_bed_protocol"
27
+ Issues = "https://github.com/skjall/home-assistant-octo-bed/issues"
28
+
29
+ [tool.hatch.build.targets.wheel]
30
+ packages = ["src/octo_bed_protocol"]
31
+
32
+ [tool.pytest.ini_options]
33
+ # Without this the repository's own pytest.ini applies here too and asks for
34
+ # coverage of the integration, which this package knows nothing about.
35
+ testpaths = ["tests"]
36
+ addopts = ""
@@ -0,0 +1,61 @@
1
+ """Wire protocol of Octo adjustable bed controllers.
2
+
3
+ This package knows how to build and read the bytes; it opens no connections
4
+ and depends on no Bluetooth stack, so it can be tested without hardware.
5
+ """
6
+
7
+ from .const import (
8
+ ADVERTISED_SERVICE_UUID,
9
+ CHAR_UUID,
10
+ MANUFACTURER,
11
+ MOTOR_3,
12
+ MOTOR_4,
13
+ MOTOR_BITS,
14
+ MOTOR_FEET,
15
+ MOTOR_HEAD,
16
+ NAME_PREFIXES,
17
+ REPLY_PIN_LOCK,
18
+ SERVICE_UUID,
19
+ )
20
+ from .protocol import (
21
+ Features,
22
+ FrameReader,
23
+ Packet,
24
+ build_frame,
25
+ checksum,
26
+ move,
27
+ pin,
28
+ pin_accepted,
29
+ read_listing,
30
+ recall_memory,
31
+ request_features,
32
+ set_light,
33
+ stop,
34
+ )
35
+
36
+ __all__ = [
37
+ "ADVERTISED_SERVICE_UUID",
38
+ "CHAR_UUID",
39
+ "MANUFACTURER",
40
+ "MOTOR_3",
41
+ "MOTOR_4",
42
+ "MOTOR_BITS",
43
+ "MOTOR_FEET",
44
+ "MOTOR_HEAD",
45
+ "NAME_PREFIXES",
46
+ "REPLY_PIN_LOCK",
47
+ "SERVICE_UUID",
48
+ "Features",
49
+ "FrameReader",
50
+ "Packet",
51
+ "build_frame",
52
+ "checksum",
53
+ "move",
54
+ "pin",
55
+ "pin_accepted",
56
+ "read_listing",
57
+ "recall_memory",
58
+ "request_features",
59
+ "set_light",
60
+ "stop",
61
+ ]
@@ -0,0 +1,63 @@
1
+ """Identifiers and command words of Octo bed controllers."""
2
+
3
+ from typing import Final
4
+
5
+ MANUFACTURER: Final = "Octo"
6
+
7
+ # Every frame travels over this one characteristic, in both directions: writes
8
+ # carry commands, notifications carry the answers.
9
+ SERVICE_UUID: Final = "0000ffe0-0000-1000-8000-00805f9b34fb"
10
+ CHAR_UUID: Final = "0000ffe1-0000-1000-8000-00805f9b34fb"
11
+
12
+ # Advertised next to FFE0 by the RC2 receivers this was verified against.
13
+ # FFE0 alone is shared with several other bed families, and the local name is
14
+ # not reliable either: one of the two receivers advertised garbage instead of
15
+ # "RC2". This UUID is what both had in common.
16
+ ADVERTISED_SERVICE_UUID: Final = "58debc00-4083-4735-8926-6721a778ae5e"
17
+
18
+ # Names the vendor app lists for Octo receivers, matched as prefixes.
19
+ NAME_PREFIXES: Final = (
20
+ "RC2",
21
+ "RC3",
22
+ "MC1",
23
+ "MC2",
24
+ "RTV",
25
+ "L2M",
26
+ "CLI",
27
+ "BMB",
28
+ "BMS",
29
+ "BM3",
30
+ "OCTOBRICK",
31
+ "OCTOIQ",
32
+ )
33
+
34
+ FRAME_DELIMITER: Final = 0x40
35
+
36
+ # Commands to the receiver. A reply sets bit 0 of the first byte.
37
+ CMD_MOVE_UP: Final = b"\x02\x70"
38
+ CMD_MOVE_DOWN: Final = b"\x02\x71"
39
+ CMD_MEMORY_RECALL: Final = b"\x02\x72"
40
+ CMD_STOP: Final = b"\x02\x73"
41
+ CMD_PIN: Final = b"\x20\x43"
42
+ CMD_FEATURES: Final = b"\x20\x71"
43
+ CMD_SET_FEATURE: Final = b"\x20\x72"
44
+
45
+ # Notifications from the receiver.
46
+ REPLY_PIN_STATE: Final = b"\x21\x43"
47
+ REPLY_PIN_LOCK: Final = b"\x21\x44"
48
+ REPLY_FEATURE: Final = b"\x21\x71"
49
+
50
+ # Motor bits of the move command. M1 lifts the back, M2 the legs.
51
+ MOTOR_HEAD: Final = 0x02
52
+ MOTOR_FEET: Final = 0x04
53
+ MOTOR_3: Final = 0x08
54
+ MOTOR_4: Final = 0x10
55
+ MOTOR_BITS: Final = (MOTOR_HEAD, MOTOR_FEET, MOTOR_3, MOTOR_4)
56
+
57
+ # Feature ids the receiver lists in answer to CMD_FEATURES.
58
+ FEATURE_MOTOR_COUNT: Final = 0x000001
59
+ FEATURE_MEMORY_COUNT: Final = 0x000002
60
+ FEATURE_PIN: Final = 0x000003
61
+ FEATURE_MEMORY_INFO: Final = 0x000004
62
+ FEATURE_LIGHT: Final = 0x000102
63
+ FEATURE_END: Final = 0xFFFFFF
@@ -0,0 +1,285 @@
1
+ """The wire format: building frames, and reading what the receiver sends back.
2
+
3
+ A frame is ``40 | command (2) | data length (2, big-endian) | checksum | data |
4
+ 40``. The checksum makes the byte sum of the whole frame, both delimiters
5
+ included, come out as zero.
6
+
7
+ Between the delimiters the vendor app escapes the four bytes that would
8
+ otherwise be taken for framing. None of the commands this package builds
9
+ happens to contain one, which is why the escape never showed up in a capture.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ from .const import (
17
+ CMD_FEATURES,
18
+ CMD_MEMORY_RECALL,
19
+ CMD_MOVE_DOWN,
20
+ CMD_MOVE_UP,
21
+ CMD_PIN,
22
+ CMD_SET_FEATURE,
23
+ CMD_STOP,
24
+ FEATURE_END,
25
+ FEATURE_LIGHT,
26
+ FEATURE_MEMORY_COUNT,
27
+ FEATURE_MEMORY_INFO,
28
+ FEATURE_MOTOR_COUNT,
29
+ FEATURE_PIN,
30
+ FRAME_DELIMITER,
31
+ MOTOR_BITS,
32
+ REPLY_FEATURE,
33
+ REPLY_PIN_STATE,
34
+ )
35
+
36
+ _ESCAPE = 0x3C
37
+ _ESCAPED = {0x40: 0x01, 0x3C: 0x02, 0x4F: 0x03, 0x41: 0x04}
38
+ _UNESCAPED = {value: key for key, value in _ESCAPED.items()}
39
+
40
+ # Longest frame that is still believable. A buffer that grows past this without
41
+ # a closing delimiter is noise, not a frame that is still arriving.
42
+ _MAX_FRAME = 256
43
+
44
+
45
+ def checksum(frame: bytes) -> int:
46
+ """Return the byte that makes the frame sum to zero.
47
+
48
+ The checksum position itself is expected to hold zero while summing.
49
+ """
50
+ return (-sum(frame)) & 0xFF
51
+
52
+
53
+ def _escape(payload: bytes) -> bytes:
54
+ out = bytearray()
55
+ for byte in payload:
56
+ if byte in _ESCAPED:
57
+ out += bytes((_ESCAPE, _ESCAPED[byte]))
58
+ else:
59
+ out.append(byte)
60
+ return bytes(out)
61
+
62
+
63
+ def _unescape(payload: bytes) -> bytes | None:
64
+ out = bytearray()
65
+ it = iter(payload)
66
+ for byte in it:
67
+ if byte != _ESCAPE:
68
+ out.append(byte)
69
+ continue
70
+ code = next(it, None)
71
+ if code not in _UNESCAPED:
72
+ return None
73
+ out.append(_UNESCAPED[code])
74
+ return bytes(out)
75
+
76
+
77
+ def build_frame(command: bytes, data: bytes = b"") -> bytes:
78
+ """Wrap one command and its data into a frame."""
79
+ if len(command) != 2:
80
+ raise ValueError("a command is two bytes")
81
+ body = bytearray(command)
82
+ body += len(data).to_bytes(2, "big")
83
+ body.append(0)
84
+ body += data
85
+ body[4] = checksum(bytes((FRAME_DELIMITER, *body, FRAME_DELIMITER)))
86
+ return bytes((FRAME_DELIMITER, *_escape(bytes(body)), FRAME_DELIMITER))
87
+
88
+
89
+ def move(motors: int, up: bool) -> bytes:
90
+ """Run the given motors one step; the receiver stops unless it is repeated."""
91
+ if not motors or motors & ~sum(MOTOR_BITS):
92
+ raise ValueError(f"not a motor mask: {motors:#x}")
93
+ return build_frame(CMD_MOVE_UP if up else CMD_MOVE_DOWN, bytes((motors,)))
94
+
95
+
96
+ def stop() -> bytes:
97
+ """Stop every motor."""
98
+ return build_frame(CMD_STOP)
99
+
100
+
101
+ def recall_memory(slot: int) -> bytes:
102
+ """Drive one step towards a stored position (slot counted from zero)."""
103
+ if not 0 <= slot <= 0xFF:
104
+ raise ValueError(f"not a memory slot: {slot}")
105
+ return build_frame(CMD_MEMORY_RECALL, bytes((slot,)))
106
+
107
+
108
+ def pin(digits: str) -> bytes:
109
+ """Unlock the receiver: four digits, one raw byte each."""
110
+ if len(digits) != 4 or not digits.isdigit():
111
+ raise ValueError("the PIN is four digits")
112
+ return build_frame(CMD_PIN, bytes(int(c) for c in digits))
113
+
114
+
115
+ def request_features() -> bytes:
116
+ """Ask the receiver which motors, memories and light it has."""
117
+ return build_frame(CMD_FEATURES)
118
+
119
+
120
+ def set_light(on: bool) -> bytes:
121
+ """Switch the under-bed light.
122
+
123
+ A feature record: id 0x000102, flag, one characteristic byte, value type,
124
+ then the value. Byte for byte what the vendor app sends.
125
+ """
126
+ record = FEATURE_LIGHT.to_bytes(3, "big") + bytes((1, 1, 1, 1, int(on)))
127
+ return build_frame(CMD_SET_FEATURE, record)
128
+
129
+
130
+ @dataclass(frozen=True)
131
+ class Packet:
132
+ """One frame received from the bed, already checked."""
133
+
134
+ command: bytes
135
+ data: bytes
136
+
137
+
138
+ class FrameReader:
139
+ """Reassemble frames from notifications.
140
+
141
+ A frame may be split across notifications, and one notification may carry
142
+ several frames. Frames are cut by their length field first, which is how
143
+ smartbed-mqtt reads replies from real receivers: unescaped, so a 0x40
144
+ inside the data or the checksum is not a delimiter. Only a frame that does
145
+ not add up that way is tried again as the escaped form the app notes
146
+ describe. Anything that fits neither is
147
+ dropped rather than guessed at.
148
+ """
149
+
150
+ def __init__(self) -> None:
151
+ """Start with nothing buffered."""
152
+ self._buffer = bytearray()
153
+
154
+ def feed(self, chunk: bytes) -> list[Packet]:
155
+ """Take one notification, return every frame it completed."""
156
+ self._buffer += chunk
157
+ packets: list[Packet] = []
158
+ while True:
159
+ start = self._buffer.find(FRAME_DELIMITER)
160
+ if start < 0:
161
+ self._buffer.clear()
162
+ return packets
163
+ del self._buffer[:start]
164
+ taken = self._take()
165
+ if taken is _INCOMPLETE:
166
+ if len(self._buffer) > _MAX_FRAME:
167
+ self._buffer.clear()
168
+ return packets
169
+ if taken is None:
170
+ # Not a frame that starts here; look for the next start.
171
+ del self._buffer[:1]
172
+ continue
173
+ assert isinstance(taken, Packet)
174
+ packets.append(taken)
175
+
176
+ def _take(self) -> Packet | _Incomplete | None:
177
+ """Cut one frame off the front of the buffer, if one is complete."""
178
+ buffer = self._buffer
179
+ if len(buffer) < 7:
180
+ return _INCOMPLETE
181
+ total = 7 + int.from_bytes(buffer[3:5], "big")
182
+ if total > _MAX_FRAME:
183
+ return None
184
+ if len(buffer) < total:
185
+ # An escaped frame is longer than its length field says, never
186
+ # shorter, so it cannot be complete either.
187
+ return _INCOMPLETE
188
+ if buffer[total - 1] == FRAME_DELIMITER and not sum(buffer[:total]) & 0xFF:
189
+ packet = Packet(
190
+ command=bytes(buffer[1:3]), data=bytes(buffer[6 : total - 1])
191
+ )
192
+ del buffer[:total]
193
+ return packet
194
+ end = buffer.find(FRAME_DELIMITER, 1)
195
+ if end < 0:
196
+ return _INCOMPLETE
197
+ if (packet := _decode_escaped(bytes(buffer[1:end]))) is not None:
198
+ del buffer[: end + 1]
199
+ return packet
200
+ return None
201
+
202
+
203
+ class _Incomplete:
204
+ """The frame at the front of the buffer has not fully arrived yet."""
205
+
206
+
207
+ _INCOMPLETE = _Incomplete()
208
+
209
+
210
+ def _decode_escaped(raw: bytes) -> Packet | None:
211
+ body = _unescape(raw)
212
+ if body is None or len(body) < 5:
213
+ return None
214
+ length = int.from_bytes(body[2:4], "big")
215
+ if len(body) != 5 + length:
216
+ return None
217
+ if sum((FRAME_DELIMITER, *body, FRAME_DELIMITER)) & 0xFF:
218
+ return None
219
+ return Packet(command=body[:2], data=body[5:])
220
+
221
+
222
+ def read_listing(chunks: list[bytes]) -> Features:
223
+ """Read the features out of a listing as it was received."""
224
+ reader = FrameReader()
225
+ features = Features()
226
+ for chunk in chunks:
227
+ for packet in reader.feed(chunk):
228
+ features.add(packet)
229
+ return features
230
+
231
+
232
+ def pin_accepted(packet: Packet) -> bool | None:
233
+ """Return whether a PIN state reply says unlocked, or None for other frames."""
234
+ if packet.command != REPLY_PIN_STATE:
235
+ return None
236
+ return bool(packet.data) and packet.data[0] == 1
237
+
238
+
239
+ @dataclass
240
+ class Features:
241
+ """What the receiver says about itself, one record at a time."""
242
+
243
+ motor_count: int | None = None
244
+ memory_count: int = 0
245
+ memory_kinds: list[int] = field(default_factory=list)
246
+ has_light: bool = False
247
+ light_on: bool = False
248
+ pin_set: bool = False
249
+ pin_unlocked: bool = True
250
+ complete: bool = False
251
+
252
+ def add(self, packet: Packet) -> None:
253
+ """Take one feature record into account."""
254
+ if packet.command != REPLY_FEATURE or len(packet.data) < 5:
255
+ return
256
+ data = packet.data
257
+ feature = int.from_bytes(data[0:3], "big")
258
+ # A flag, then a length-prefixed characteristic, then one byte of value
259
+ # type, then the value.
260
+ characteristic = data[5 : 5 + data[4]]
261
+ value = data[6 + data[4] :]
262
+ if feature == FEATURE_END:
263
+ self.complete = True
264
+ elif feature == FEATURE_MOTOR_COUNT:
265
+ # The RC2 carries the count in the characteristic and sends no
266
+ # value at all: 00 00 01 | 01 | 01 | 02 | 00. A value, where a
267
+ # receiver sends one, is what the app notes describe.
268
+ count = value or characteristic
269
+ if count:
270
+ self.motor_count = count[0]
271
+ elif feature == FEATURE_MEMORY_COUNT and value:
272
+ self.memory_count = value[0]
273
+ elif feature == FEATURE_MEMORY_INFO:
274
+ self.memory_kinds = list(value)
275
+ elif feature == FEATURE_PIN and value:
276
+ self.pin_set = value[0] == 1
277
+ self.pin_unlocked = len(value) < 2 or value[1] == 1
278
+ elif feature == FEATURE_LIGHT:
279
+ self.has_light = True
280
+ self.light_on = bool(value) and value[0] == 1
281
+
282
+ def motor_mask(self) -> int:
283
+ """Return the bits of every motor the receiver drives."""
284
+ count = min(self.motor_count or 0, len(MOTOR_BITS))
285
+ return sum(MOTOR_BITS[:count])
File without changes
@@ -0,0 +1,263 @@
1
+ """The wire format, checked against frames the vendor app was seen to send."""
2
+
3
+ import pytest
4
+ from octo_bed_protocol import (
5
+ MOTOR_3,
6
+ MOTOR_4,
7
+ MOTOR_FEET,
8
+ MOTOR_HEAD,
9
+ Features,
10
+ FrameReader,
11
+ Packet,
12
+ build_frame,
13
+ move,
14
+ pin,
15
+ pin_accepted,
16
+ read_listing,
17
+ recall_memory,
18
+ request_features,
19
+ set_light,
20
+ stop,
21
+ )
22
+
23
+ BOTH = MOTOR_HEAD | MOTOR_FEET
24
+
25
+
26
+ def frame(text: str) -> bytes:
27
+ return bytes.fromhex(text)
28
+
29
+
30
+ # Captured from the vendor app, and sent by the ESPHome setup this package
31
+ # replaces for months without complaint.
32
+ @pytest.mark.parametrize(
33
+ ("motors", "up", "captured"),
34
+ [
35
+ (MOTOR_HEAD, True, "40 02 70 00 01 0b 02 40"),
36
+ (MOTOR_HEAD, False, "40 02 71 00 01 0a 02 40"),
37
+ (MOTOR_FEET, True, "40 02 70 00 01 09 04 40"),
38
+ (MOTOR_FEET, False, "40 02 71 00 01 08 04 40"),
39
+ (BOTH, True, "40 02 70 00 01 07 06 40"),
40
+ (BOTH, False, "40 02 71 00 01 06 06 40"),
41
+ ],
42
+ )
43
+ def test_move_matches_the_capture(motors: int, up: bool, captured: str) -> None:
44
+ assert move(motors, up) == frame(captured)
45
+
46
+
47
+ def test_stop_matches_the_capture() -> None:
48
+ assert stop() == frame("40 02 73 00 00 0b 40")
49
+
50
+
51
+ def test_light_matches_the_capture() -> None:
52
+ assert set_light(True) == frame("40 20 72 00 08 de 00 01 02 01 01 01 01 01 40")
53
+ assert set_light(False) == frame("40 20 72 00 08 df 00 01 02 01 01 01 01 00 40")
54
+
55
+
56
+ def test_pin_is_four_raw_digits() -> None:
57
+ assert pin("2345") == frame("40 20 43 00 04 0b 02 03 04 05 40")
58
+
59
+
60
+ @pytest.mark.parametrize("digits", ["123", "12345", "12a4", ""])
61
+ def test_pin_rejects_anything_but_four_digits(digits: str) -> None:
62
+ with pytest.raises(ValueError):
63
+ pin(digits)
64
+
65
+
66
+ def test_every_frame_sums_to_zero() -> None:
67
+ for built in (
68
+ move(MOTOR_3 | MOTOR_4, True),
69
+ recall_memory(2),
70
+ request_features(),
71
+ pin("9999"),
72
+ ):
73
+ assert sum(built) & 0xFF == 0
74
+
75
+
76
+ def test_feature_request() -> None:
77
+ assert request_features() == frame("40 20 71 00 00 ef 40")
78
+
79
+
80
+ def test_memory_recall() -> None:
81
+ assert recall_memory(0) == frame("40 02 72 00 01 0b 00 40")
82
+ with pytest.raises(ValueError):
83
+ recall_memory(256)
84
+
85
+
86
+ @pytest.mark.parametrize("mask", [0, 0x01, 0x20, 0x06 | 0x40])
87
+ def test_move_rejects_what_is_not_a_motor(mask: int) -> None:
88
+ with pytest.raises(ValueError):
89
+ move(mask, True)
90
+
91
+
92
+ def test_build_frame_wants_a_two_byte_command() -> None:
93
+ with pytest.raises(ValueError):
94
+ build_frame(b"\x02")
95
+
96
+
97
+ def test_framing_bytes_in_the_payload_are_escaped() -> None:
98
+ built = build_frame(b"\x02\x70", bytes((0x40, 0x3C, 0x4F, 0x41)))
99
+ inner = built[1:-1]
100
+ assert 0x40 not in inner
101
+ assert bytes((0x3C, 0x01)) in inner
102
+ # And a reader takes it apart again.
103
+ [packet] = FrameReader().feed(built)
104
+ assert packet.data == bytes((0x40, 0x3C, 0x4F, 0x41))
105
+
106
+
107
+ def _feature(feature: int, value: bytes, characteristic: bytes = b"\x01") -> bytes:
108
+ record = (
109
+ feature.to_bytes(3, "big")
110
+ + bytes((1, len(characteristic)))
111
+ + characteristic
112
+ + b"\x01"
113
+ + value
114
+ )
115
+ return build_frame(b"\x21\x71", record)
116
+
117
+
118
+ def test_reader_reassembles_split_and_joined_frames() -> None:
119
+ reader = FrameReader()
120
+ first = _feature(0x000001, b"\x02")
121
+ second = _feature(0x000102, b"\x01")
122
+ assert reader.feed(first[:4]) == []
123
+ packets = reader.feed(first[4:] + second)
124
+ assert [p.command for p in packets] == [b"\x21\x71", b"\x21\x71"]
125
+
126
+
127
+ def test_reader_drops_what_does_not_add_up() -> None:
128
+ reader = FrameReader()
129
+ good = _feature(0x000001, b"\x02")
130
+ bad_checksum = bytearray(good)
131
+ bad_checksum[5] ^= 0xFF
132
+ bad_length = bytearray(good)
133
+ bad_length[4] += 1
134
+ bad_escape = bytes((0x40, 0x21, 0x71, 0x3C, 0x09, 0x40))
135
+ packets = reader.feed(
136
+ b"noise" + bytes(bad_checksum) + bytes(bad_length) + bad_escape + good
137
+ )
138
+ assert len(packets) == 1
139
+
140
+
141
+ def test_reader_forgets_a_frame_that_never_ends() -> None:
142
+ reader = FrameReader()
143
+ assert reader.feed(bytes((0x40,)) + bytes(300)) == []
144
+ assert reader.feed(stop()) == [Packet(command=b"\x02\x73", data=b"")]
145
+
146
+
147
+ def test_reader_without_any_delimiter() -> None:
148
+ assert FrameReader().feed(b"\x01\x02") == []
149
+
150
+
151
+ def test_features_from_a_full_listing() -> None:
152
+ reader = FrameReader()
153
+ features = Features()
154
+ listing = (
155
+ _feature(0x000001, b"\x02")
156
+ + _feature(0x000002, b"\x04")
157
+ + _feature(0x000004, b"\x01\x02\x03\x04", characteristic=b"\x04\x00\x00")
158
+ + _feature(0x000003, b"\x01\x01")
159
+ + _feature(0x000101, b"")
160
+ + _feature(0x000102, b"\x01")
161
+ + _feature(0xFFFFFF, b"")
162
+ )
163
+ for packet in reader.feed(listing):
164
+ features.add(packet)
165
+ assert features.complete
166
+ assert features.motor_count == 2
167
+ assert features.motor_mask() == BOTH
168
+ assert features.memory_count == 4
169
+ assert features.memory_kinds == [1, 2, 3, 4]
170
+ assert features.pin_set
171
+ assert features.pin_unlocked
172
+ assert features.has_light
173
+ assert features.light_on
174
+
175
+
176
+ def test_features_ignore_other_frames() -> None:
177
+ features = Features()
178
+ features.add(Packet(command=b"\x21\x43", data=b"\x01"))
179
+ features.add(Packet(command=b"\x21\x71", data=b"\x00"))
180
+ assert features == Features()
181
+
182
+
183
+ def test_locked_pin_and_no_light() -> None:
184
+ features = Features()
185
+ [packet] = FrameReader().feed(_feature(0x000003, b"\x01\x00"))
186
+ features.add(packet)
187
+ assert features.pin_set
188
+ assert not features.pin_unlocked
189
+ assert not features.has_light
190
+ assert features.motor_mask() == 0
191
+
192
+
193
+ def test_pin_state_reply() -> None:
194
+ assert pin_accepted(Packet(command=b"\x21\x43", data=b"\x01")) is True
195
+ assert pin_accepted(Packet(command=b"\x21\x43", data=b"\x00")) is False
196
+ assert pin_accepted(Packet(command=b"\x21\x43", data=b"")) is False
197
+ assert pin_accepted(Packet(command=b"\x21\x71", data=b"\x01")) is None
198
+
199
+
200
+ def _unescaped(command: bytes, data: bytes) -> bytes:
201
+ """A frame the way the RC2 sends its replies: no escaping at all."""
202
+ body = bytearray(command) + len(data).to_bytes(2, "big") + b"\x00" + data
203
+ body[4] = (-(0x80 + sum(body))) & 0xFF
204
+ return bytes((0x40, *body, 0x40))
205
+
206
+
207
+ def test_an_unescaped_delimiter_in_the_data_is_data() -> None:
208
+ record = bytes.fromhex("00 00 01 01 01 40 01 02")
209
+ frame = _unescaped(b"\x21\x71", record)
210
+ [packet] = FrameReader().feed(frame)
211
+ assert packet.data == record
212
+
213
+
214
+ def test_an_unescaped_delimiter_as_checksum() -> None:
215
+ reader = FrameReader()
216
+ # Search a record whose checksum comes out as 0x40.
217
+ for value in range(256):
218
+ frame = _unescaped(b"\x21\x71", bytes((0, 0, 1, 1, 1, 1, 1, value)))
219
+ if frame[5] == 0x40:
220
+ break
221
+ else: # pragma: no cover
222
+ raise AssertionError("no such record")
223
+ features = Features()
224
+ for packet in reader.feed(
225
+ frame + _unescaped(b"\x21\x71", bytes.fromhex("ff ff ff 00 00 00"))
226
+ ):
227
+ features.add(packet)
228
+ assert features.motor_count == value
229
+ assert features.complete
230
+
231
+
232
+ def test_a_frame_split_before_its_length_is_known() -> None:
233
+ reader = FrameReader()
234
+ frame = _unescaped(b"\x21\x71", bytes.fromhex("00 00 01 01 01 01 01 02"))
235
+ assert reader.feed(frame[:3]) == []
236
+ assert reader.feed(frame[3:9]) == []
237
+ assert len(reader.feed(frame[9:])) == 1
238
+
239
+
240
+ # The listing both RC2 receivers here sent, notification by notification.
241
+ RC2_LISTING = [
242
+ bytes.fromhex("40 21 71 00 07 e2 00 00 01 01 01 02 00 40"),
243
+ bytes.fromhex("40 21 71 00 08 df 00 01 02 01 01 01 01 00 40"),
244
+ bytes.fromhex("40 21 71 00 08 d2 00 00 10 01 01 01 01 00 40"),
245
+ bytes.fromhex("40 21 71 00 06 ea ff ff ff 01 00 00 40"),
246
+ ]
247
+
248
+
249
+ def test_the_rc2_listing() -> None:
250
+ """Two motors in the characteristic, a light that is off, no PIN."""
251
+ features = read_listing(RC2_LISTING)
252
+ assert features.complete
253
+ assert features.motor_count == 2
254
+ assert features.motor_mask() == MOTOR_HEAD | MOTOR_FEET
255
+ assert features.has_light
256
+ assert not features.light_on
257
+ assert not features.pin_set
258
+ assert features.memory_count == 0
259
+
260
+
261
+ def test_a_motor_count_without_any_bytes() -> None:
262
+ frame = build_frame(b"\x21\x71", bytes.fromhex("00 00 01 01 00 00"))
263
+ assert read_listing([frame]).motor_count is None