blocksd 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blocksd/__init__.py +3 -0
- blocksd/__main__.py +5 -0
- blocksd/api/__init__.py +5 -0
- blocksd/api/events.py +183 -0
- blocksd/api/http.py +146 -0
- blocksd/api/protocol.py +62 -0
- blocksd/api/server.py +691 -0
- blocksd/api/websocket.py +73 -0
- blocksd/cli/__init__.py +1 -0
- blocksd/cli/app.py +186 -0
- blocksd/cli/config.py +140 -0
- blocksd/cli/install.py +153 -0
- blocksd/cli/led.py +150 -0
- blocksd/config/__init__.py +1 -0
- blocksd/config/loader.py +38 -0
- blocksd/config/schema.py +32 -0
- blocksd/daemon.py +158 -0
- blocksd/device/__init__.py +1 -0
- blocksd/device/config_ids.py +31 -0
- blocksd/device/connection.py +119 -0
- blocksd/device/models.py +122 -0
- blocksd/device/registry.py +67 -0
- blocksd/led/__init__.py +1 -0
- blocksd/led/bitmap.py +132 -0
- blocksd/led/patterns.py +90 -0
- blocksd/littlefoot/__init__.py +1 -0
- blocksd/littlefoot/assembler.py +300 -0
- blocksd/littlefoot/opcodes.py +75 -0
- blocksd/littlefoot/programs.py +153 -0
- blocksd/logging.py +26 -0
- blocksd/protocol/__init__.py +1 -0
- blocksd/protocol/builder.py +124 -0
- blocksd/protocol/checksum.py +12 -0
- blocksd/protocol/constants.py +157 -0
- blocksd/protocol/data_change.py +405 -0
- blocksd/protocol/decoder.py +426 -0
- blocksd/protocol/packing.py +95 -0
- blocksd/protocol/remote_heap.py +251 -0
- blocksd/protocol/serial.py +50 -0
- blocksd/py.typed +0 -0
- blocksd/sdnotify.py +95 -0
- blocksd/topology/__init__.py +1 -0
- blocksd/topology/detector.py +97 -0
- blocksd/topology/device_group.py +695 -0
- blocksd/topology/manager.py +175 -0
- blocksd/web/__init__.py +14 -0
- blocksd-0.2.0.dist-info/METADATA +362 -0
- blocksd-0.2.0.dist-info/RECORD +51 -0
- blocksd-0.2.0.dist-info/WHEEL +4 -0
- blocksd-0.2.0.dist-info/entry_points.txt +2 -0
- blocksd-0.2.0.dist-info/licenses/LICENSE +15 -0
blocksd/__init__.py
ADDED
blocksd/__main__.py
ADDED
blocksd/api/__init__.py
ADDED
blocksd/api/events.py
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
"""Event subscription manager — broadcasts device/touch/button events to clients."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import logging
|
|
7
|
+
from typing import TYPE_CHECKING, Any
|
|
8
|
+
|
|
9
|
+
from blocksd.device.registry import bitmap_grid_dimensions
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from blocksd.device.models import (
|
|
13
|
+
ButtonEvent,
|
|
14
|
+
ConfigValue,
|
|
15
|
+
DeviceConnection,
|
|
16
|
+
DeviceInfo,
|
|
17
|
+
Topology,
|
|
18
|
+
TouchEvent,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
log = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
# Supported event categories
|
|
24
|
+
VALID_EVENTS = frozenset({"device", "touch", "button", "config", "topology"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class EventBroadcaster:
|
|
28
|
+
"""Manages client subscriptions and broadcasts events."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self._subscribers: dict[int, _Subscriber] = {}
|
|
32
|
+
self._next_id = 0
|
|
33
|
+
|
|
34
|
+
def subscribe(self, queue: asyncio.Queue[dict[str, Any]], events: set[str]) -> int:
|
|
35
|
+
"""Register a client for event types. Returns subscription ID."""
|
|
36
|
+
valid = events & VALID_EVENTS
|
|
37
|
+
sub_id = self._next_id
|
|
38
|
+
self._next_id += 1
|
|
39
|
+
self._subscribers[sub_id] = _Subscriber(queue=queue, events=valid)
|
|
40
|
+
log.debug("Client %d subscribed to %s", sub_id, valid)
|
|
41
|
+
return sub_id
|
|
42
|
+
|
|
43
|
+
def unsubscribe(self, sub_id: int) -> None:
|
|
44
|
+
"""Remove a client subscription."""
|
|
45
|
+
self._subscribers.pop(sub_id, None)
|
|
46
|
+
|
|
47
|
+
def broadcast_device_added(self, dev: DeviceInfo) -> None:
|
|
48
|
+
"""Broadcast a device_added event."""
|
|
49
|
+
msg = {
|
|
50
|
+
"type": "device_added",
|
|
51
|
+
"device": _device_to_dict(dev),
|
|
52
|
+
}
|
|
53
|
+
self._broadcast("device", msg)
|
|
54
|
+
|
|
55
|
+
def broadcast_device_removed(self, dev: DeviceInfo) -> None:
|
|
56
|
+
"""Broadcast a device_removed event."""
|
|
57
|
+
msg = {
|
|
58
|
+
"type": "device_removed",
|
|
59
|
+
"uid": dev.uid,
|
|
60
|
+
"reason": "disconnected",
|
|
61
|
+
}
|
|
62
|
+
self._broadcast("device", msg)
|
|
63
|
+
|
|
64
|
+
def broadcast_touch(self, event: TouchEvent) -> None:
|
|
65
|
+
"""Broadcast a touch event."""
|
|
66
|
+
if event.is_start:
|
|
67
|
+
action = "start"
|
|
68
|
+
elif event.is_end:
|
|
69
|
+
action = "end"
|
|
70
|
+
else:
|
|
71
|
+
action = "move"
|
|
72
|
+
|
|
73
|
+
msg = {
|
|
74
|
+
"type": "touch",
|
|
75
|
+
"uid": event.uid,
|
|
76
|
+
"action": action,
|
|
77
|
+
"index": event.touch_index,
|
|
78
|
+
"x": round(event.x, 4),
|
|
79
|
+
"y": round(event.y, 4),
|
|
80
|
+
"z": round(event.z, 4),
|
|
81
|
+
"vx": round(event.vx, 4),
|
|
82
|
+
"vy": round(event.vy, 4),
|
|
83
|
+
"vz": round(event.vz, 4),
|
|
84
|
+
}
|
|
85
|
+
self._broadcast("touch", msg)
|
|
86
|
+
|
|
87
|
+
def broadcast_button(self, event: ButtonEvent) -> None:
|
|
88
|
+
"""Broadcast a button event."""
|
|
89
|
+
msg = {
|
|
90
|
+
"type": "button",
|
|
91
|
+
"uid": event.uid,
|
|
92
|
+
"action": "press" if event.is_down else "release",
|
|
93
|
+
}
|
|
94
|
+
self._broadcast("button", msg)
|
|
95
|
+
|
|
96
|
+
def broadcast_topology_changed(self, topo: Topology) -> None:
|
|
97
|
+
"""Broadcast a topology change event."""
|
|
98
|
+
msg: dict[str, Any] = {
|
|
99
|
+
"type": "topology_changed",
|
|
100
|
+
"devices": [_device_to_dict(d) for d in topo.devices],
|
|
101
|
+
"connections": [_connection_to_dict(c) for c in topo.connections],
|
|
102
|
+
}
|
|
103
|
+
self._broadcast("topology", msg)
|
|
104
|
+
|
|
105
|
+
def broadcast_config_changed(self, uid: int, config: ConfigValue) -> None:
|
|
106
|
+
"""Broadcast a config change event."""
|
|
107
|
+
msg: dict[str, Any] = {
|
|
108
|
+
"type": "config_changed",
|
|
109
|
+
"uid": uid,
|
|
110
|
+
"item": config.item,
|
|
111
|
+
"value": config.value,
|
|
112
|
+
}
|
|
113
|
+
self._broadcast("config", msg)
|
|
114
|
+
|
|
115
|
+
def _broadcast(self, category: str, msg: dict[str, Any]) -> None:
|
|
116
|
+
"""Send message to all subscribers of the given category."""
|
|
117
|
+
dead: list[int] = []
|
|
118
|
+
for sub_id, sub in self._subscribers.items():
|
|
119
|
+
if category in sub.events:
|
|
120
|
+
try:
|
|
121
|
+
sub.queue.put_nowait(msg)
|
|
122
|
+
except asyncio.QueueFull:
|
|
123
|
+
dead.append(sub_id)
|
|
124
|
+
log.debug("Client %d queue full, dropping", sub_id)
|
|
125
|
+
|
|
126
|
+
for sub_id in dead:
|
|
127
|
+
self._subscribers.pop(sub_id, None)
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def subscriber_count(self) -> int:
|
|
131
|
+
return len(self._subscribers)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class _Subscriber:
|
|
135
|
+
__slots__ = ("events", "queue")
|
|
136
|
+
|
|
137
|
+
def __init__(self, queue: asyncio.Queue[dict[str, Any]], events: set[str]) -> None:
|
|
138
|
+
self.queue = queue
|
|
139
|
+
self.events = events
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _device_to_dict(dev: DeviceInfo) -> dict[str, Any]:
|
|
143
|
+
"""Serialize DeviceInfo to the API wire format."""
|
|
144
|
+
grid_width, grid_height = bitmap_grid_dimensions(dev.block_type)
|
|
145
|
+
return {
|
|
146
|
+
"uid": dev.uid,
|
|
147
|
+
"serial": dev.serial,
|
|
148
|
+
"block_type": _block_type_to_api(dev.block_type),
|
|
149
|
+
"name": dev.name or dev.block_type.value,
|
|
150
|
+
"battery_level": dev.battery_level,
|
|
151
|
+
"battery_charging": dev.battery_charging,
|
|
152
|
+
"grid_width": grid_width,
|
|
153
|
+
"grid_height": grid_height,
|
|
154
|
+
"firmware_version": dev.version or None,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _connection_to_dict(conn: DeviceConnection) -> dict[str, Any]:
|
|
159
|
+
"""Serialize DeviceConnection to the API wire format."""
|
|
160
|
+
return {
|
|
161
|
+
"device1_uid": conn.device1_uid,
|
|
162
|
+
"device2_uid": conn.device2_uid,
|
|
163
|
+
"port1": conn.port1,
|
|
164
|
+
"port2": conn.port2,
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _block_type_to_api(block_type: Any) -> str:
|
|
169
|
+
"""Convert BlockType enum to API string."""
|
|
170
|
+
from blocksd.device.models import BlockType
|
|
171
|
+
|
|
172
|
+
mapping = {
|
|
173
|
+
BlockType.LIGHTPAD: "lightpad",
|
|
174
|
+
BlockType.LIGHTPAD_M: "lightpad_m",
|
|
175
|
+
BlockType.SEABOARD: "seaboard",
|
|
176
|
+
BlockType.LUMI_KEYS: "lumi_keys",
|
|
177
|
+
BlockType.LIVE: "live",
|
|
178
|
+
BlockType.LOOP: "loop",
|
|
179
|
+
BlockType.TOUCH: "touch",
|
|
180
|
+
BlockType.DEV_CTRL: "developer",
|
|
181
|
+
BlockType.UNKNOWN: "unknown",
|
|
182
|
+
}
|
|
183
|
+
return mapping.get(block_type, "unknown")
|
blocksd/api/http.py
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Minimal HTTP/1.1 server — static file serving and WebSocket upgrade.
|
|
2
|
+
|
|
3
|
+
Not a general-purpose HTTP server. Handles exactly what blocksd needs: serve the
|
|
4
|
+
built SPA from a static directory (with SPA fallback) and upgrade ``/ws`` to WebSocket.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import base64
|
|
10
|
+
import hashlib
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
import asyncio
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
|
|
18
|
+
|
|
19
|
+
MIME_TYPES: dict[str, str] = {
|
|
20
|
+
".html": "text/html; charset=utf-8",
|
|
21
|
+
".css": "text/css; charset=utf-8",
|
|
22
|
+
".js": "application/javascript; charset=utf-8",
|
|
23
|
+
".mjs": "application/javascript; charset=utf-8",
|
|
24
|
+
".json": "application/json",
|
|
25
|
+
".svg": "image/svg+xml",
|
|
26
|
+
".png": "image/png",
|
|
27
|
+
".jpg": "image/jpeg",
|
|
28
|
+
".ico": "image/x-icon",
|
|
29
|
+
".woff": "font/woff",
|
|
30
|
+
".woff2": "font/woff2",
|
|
31
|
+
".map": "application/json",
|
|
32
|
+
".txt": "text/plain; charset=utf-8",
|
|
33
|
+
".wasm": "application/wasm",
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
CORS_HEADERS = "Access-Control-Allow-Origin: *\r\nAccess-Control-Allow-Headers: *\r\n"
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class HttpRequest:
|
|
40
|
+
"""Parsed HTTP/1.1 request — method, path, and headers."""
|
|
41
|
+
|
|
42
|
+
__slots__ = ("headers", "method", "path")
|
|
43
|
+
|
|
44
|
+
def __init__(self, method: str, path: str, headers: dict[str, str]) -> None:
|
|
45
|
+
self.method = method
|
|
46
|
+
self.path = path
|
|
47
|
+
self.headers = headers
|
|
48
|
+
|
|
49
|
+
@property
|
|
50
|
+
def is_websocket_upgrade(self) -> bool:
|
|
51
|
+
return (
|
|
52
|
+
self.headers.get("upgrade", "").lower() == "websocket"
|
|
53
|
+
and "upgrade" in self.headers.get("connection", "").lower()
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def ws_key(self) -> str:
|
|
58
|
+
return self.headers.get("sec-websocket-key", "")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
async def parse_request(reader: asyncio.StreamReader) -> HttpRequest | None:
|
|
62
|
+
"""Parse an HTTP/1.1 request line + headers. Returns None on EOF."""
|
|
63
|
+
line = await reader.readline()
|
|
64
|
+
if not line:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
parts = line.decode("latin-1").strip().split(" ", 2)
|
|
68
|
+
if len(parts) < 2:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
method, raw_path = parts[0], parts[1]
|
|
72
|
+
path = raw_path.split("?", 1)[0] # strip query string
|
|
73
|
+
|
|
74
|
+
headers: dict[str, str] = {}
|
|
75
|
+
while True:
|
|
76
|
+
hdr = await reader.readline()
|
|
77
|
+
if hdr in (b"\r\n", b"\n", b""):
|
|
78
|
+
break
|
|
79
|
+
decoded = hdr.decode("latin-1").strip()
|
|
80
|
+
if ":" in decoded:
|
|
81
|
+
k, _, v = decoded.partition(":")
|
|
82
|
+
headers[k.strip().lower()] = v.strip()
|
|
83
|
+
|
|
84
|
+
return HttpRequest(method, path, headers)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def ws_upgrade_response(key: str) -> bytes:
|
|
88
|
+
"""Build the 101 Switching Protocols response for WebSocket upgrade."""
|
|
89
|
+
accept = base64.b64encode(hashlib.sha1((key + WS_MAGIC).encode()).digest()).decode()
|
|
90
|
+
return (
|
|
91
|
+
"HTTP/1.1 101 Switching Protocols\r\n"
|
|
92
|
+
"Upgrade: websocket\r\n"
|
|
93
|
+
"Connection: Upgrade\r\n"
|
|
94
|
+
f"Sec-WebSocket-Accept: {accept}\r\n"
|
|
95
|
+
"\r\n"
|
|
96
|
+
).encode()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def http_response(
|
|
100
|
+
status: int,
|
|
101
|
+
body: bytes,
|
|
102
|
+
content_type: str = "text/plain; charset=utf-8",
|
|
103
|
+
) -> bytes:
|
|
104
|
+
"""Build a minimal HTTP/1.1 response with CORS headers."""
|
|
105
|
+
reason = {200: "OK", 204: "No Content", 404: "Not Found", 405: "Method Not Allowed"}.get(
|
|
106
|
+
status, "Error"
|
|
107
|
+
)
|
|
108
|
+
return (
|
|
109
|
+
f"HTTP/1.1 {status} {reason}\r\n"
|
|
110
|
+
f"Content-Type: {content_type}\r\n"
|
|
111
|
+
f"Content-Length: {len(body)}\r\n"
|
|
112
|
+
f"{CORS_HEADERS}"
|
|
113
|
+
"Connection: close\r\n"
|
|
114
|
+
"\r\n"
|
|
115
|
+
).encode() + body
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
_NOT_BUILT_HTML = (
|
|
119
|
+
b"<html><body style='background:#1a1a2e;color:#e0e0e0;font-family:monospace;padding:2em'>"
|
|
120
|
+
b"<h1>blocksd</h1><p>Web UI not built yet.</p>"
|
|
121
|
+
b"<pre>cd web && bun install && bun run build</pre></body></html>"
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def serve_static(path: str, static_dir: Path) -> bytes:
|
|
126
|
+
"""Serve a file from *static_dir* with SPA fallback to ``index.html``."""
|
|
127
|
+
if not static_dir.is_dir():
|
|
128
|
+
return http_response(200, _NOT_BUILT_HTML, "text/html; charset=utf-8")
|
|
129
|
+
|
|
130
|
+
clean = path.lstrip("/") or "index.html"
|
|
131
|
+
target = (static_dir / clean).resolve()
|
|
132
|
+
|
|
133
|
+
# Path traversal guard
|
|
134
|
+
if not str(target).startswith(str(static_dir.resolve())):
|
|
135
|
+
return http_response(404, b"Not Found")
|
|
136
|
+
|
|
137
|
+
if target.is_file():
|
|
138
|
+
mime = MIME_TYPES.get(target.suffix, "application/octet-stream")
|
|
139
|
+
return http_response(200, target.read_bytes(), mime)
|
|
140
|
+
|
|
141
|
+
# SPA fallback — serve index.html for client-side routes
|
|
142
|
+
index = static_dir / "index.html"
|
|
143
|
+
if index.is_file():
|
|
144
|
+
return http_response(200, index.read_bytes(), MIME_TYPES[".html"])
|
|
145
|
+
|
|
146
|
+
return http_response(404, b"Not Found")
|
blocksd/api/protocol.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Wire protocol — NDJSON + binary frame parser/serializer.
|
|
2
|
+
|
|
3
|
+
Two message formats coexist on the same socket:
|
|
4
|
+
|
|
5
|
+
- **JSON:** newline-delimited JSON objects (first byte is `{` = 0x7B)
|
|
6
|
+
- **Binary:** fixed-size frame writes (first byte is magic 0xBD)
|
|
7
|
+
|
|
8
|
+
The server peeks at the first byte to dispatch to the correct parser.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import struct
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
# Binary frame constants
|
|
19
|
+
BINARY_MAGIC = 0xBD
|
|
20
|
+
BINARY_TYPE_FRAME = 0x01
|
|
21
|
+
BINARY_FRAME_SIZE = 685 # 1 magic + 1 type + 8 uid + 675 pixels
|
|
22
|
+
BINARY_HEADER = struct.Struct("<BBQ") # magic, type, uid (u64 little-endian)
|
|
23
|
+
PIXEL_DATA_SIZE = 675 # 15 * 15 * 3 bytes (RGB888)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@dataclass(frozen=True, slots=True)
|
|
27
|
+
class BinaryFrame:
|
|
28
|
+
"""A binary frame write message."""
|
|
29
|
+
|
|
30
|
+
uid: int
|
|
31
|
+
pixels: bytes # 675 bytes of RGB888
|
|
32
|
+
|
|
33
|
+
def to_bytes(self) -> bytes:
|
|
34
|
+
return BINARY_HEADER.pack(BINARY_MAGIC, BINARY_TYPE_FRAME, self.uid) + self.pixels
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_binary_frame(data: bytes) -> BinaryFrame:
|
|
38
|
+
"""Parse a 685-byte binary frame message.
|
|
39
|
+
|
|
40
|
+
Raises ValueError if the data is malformed.
|
|
41
|
+
"""
|
|
42
|
+
if len(data) != BINARY_FRAME_SIZE:
|
|
43
|
+
raise ValueError(f"binary frame wrong size: {len(data)} != {BINARY_FRAME_SIZE}")
|
|
44
|
+
|
|
45
|
+
magic, msg_type, uid = BINARY_HEADER.unpack_from(data)
|
|
46
|
+
if magic != BINARY_MAGIC:
|
|
47
|
+
raise ValueError(f"bad magic: 0x{magic:02X}")
|
|
48
|
+
if msg_type != BINARY_TYPE_FRAME:
|
|
49
|
+
raise ValueError(f"unknown binary type: 0x{msg_type:02X}")
|
|
50
|
+
|
|
51
|
+
pixels = data[BINARY_HEADER.size : BINARY_HEADER.size + PIXEL_DATA_SIZE]
|
|
52
|
+
return BinaryFrame(uid=uid, pixels=pixels)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def encode_json(msg: dict[str, Any]) -> bytes:
|
|
56
|
+
"""Encode a JSON message as NDJSON (newline-terminated)."""
|
|
57
|
+
return json.dumps(msg, separators=(",", ":")).encode() + b"\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def decode_json(line: bytes) -> dict[str, Any]:
|
|
61
|
+
"""Decode a single NDJSON line."""
|
|
62
|
+
return json.loads(line)
|