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.
- octo_bed_protocol-0.2.0/.gitignore +16 -0
- octo_bed_protocol-0.2.0/PKG-INFO +47 -0
- octo_bed_protocol-0.2.0/README.md +28 -0
- octo_bed_protocol-0.2.0/pyproject.toml +36 -0
- octo_bed_protocol-0.2.0/src/octo_bed_protocol/__init__.py +61 -0
- octo_bed_protocol-0.2.0/src/octo_bed_protocol/const.py +63 -0
- octo_bed_protocol-0.2.0/src/octo_bed_protocol/protocol.py +285 -0
- octo_bed_protocol-0.2.0/src/octo_bed_protocol/py.typed +0 -0
- octo_bed_protocol-0.2.0/tests/test_protocol.py +263 -0
|
@@ -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
|