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.
Files changed (51) hide show
  1. blocksd/__init__.py +3 -0
  2. blocksd/__main__.py +5 -0
  3. blocksd/api/__init__.py +5 -0
  4. blocksd/api/events.py +183 -0
  5. blocksd/api/http.py +146 -0
  6. blocksd/api/protocol.py +62 -0
  7. blocksd/api/server.py +691 -0
  8. blocksd/api/websocket.py +73 -0
  9. blocksd/cli/__init__.py +1 -0
  10. blocksd/cli/app.py +186 -0
  11. blocksd/cli/config.py +140 -0
  12. blocksd/cli/install.py +153 -0
  13. blocksd/cli/led.py +150 -0
  14. blocksd/config/__init__.py +1 -0
  15. blocksd/config/loader.py +38 -0
  16. blocksd/config/schema.py +32 -0
  17. blocksd/daemon.py +158 -0
  18. blocksd/device/__init__.py +1 -0
  19. blocksd/device/config_ids.py +31 -0
  20. blocksd/device/connection.py +119 -0
  21. blocksd/device/models.py +122 -0
  22. blocksd/device/registry.py +67 -0
  23. blocksd/led/__init__.py +1 -0
  24. blocksd/led/bitmap.py +132 -0
  25. blocksd/led/patterns.py +90 -0
  26. blocksd/littlefoot/__init__.py +1 -0
  27. blocksd/littlefoot/assembler.py +300 -0
  28. blocksd/littlefoot/opcodes.py +75 -0
  29. blocksd/littlefoot/programs.py +153 -0
  30. blocksd/logging.py +26 -0
  31. blocksd/protocol/__init__.py +1 -0
  32. blocksd/protocol/builder.py +124 -0
  33. blocksd/protocol/checksum.py +12 -0
  34. blocksd/protocol/constants.py +157 -0
  35. blocksd/protocol/data_change.py +405 -0
  36. blocksd/protocol/decoder.py +426 -0
  37. blocksd/protocol/packing.py +95 -0
  38. blocksd/protocol/remote_heap.py +251 -0
  39. blocksd/protocol/serial.py +50 -0
  40. blocksd/py.typed +0 -0
  41. blocksd/sdnotify.py +95 -0
  42. blocksd/topology/__init__.py +1 -0
  43. blocksd/topology/detector.py +97 -0
  44. blocksd/topology/device_group.py +695 -0
  45. blocksd/topology/manager.py +175 -0
  46. blocksd/web/__init__.py +14 -0
  47. blocksd-0.2.0.dist-info/METADATA +362 -0
  48. blocksd-0.2.0.dist-info/RECORD +51 -0
  49. blocksd-0.2.0.dist-info/WHEEL +4 -0
  50. blocksd-0.2.0.dist-info/entry_points.txt +2 -0
  51. blocksd-0.2.0.dist-info/licenses/LICENSE +15 -0
blocksd/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """blocksd — Linux daemon for ROLI Blocks devices."""
2
+
3
+ __version__ = "0.1.0"
blocksd/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """Entry point for `python -m blocksd`."""
2
+
3
+ from blocksd.cli.app import app
4
+
5
+ app()
@@ -0,0 +1,5 @@
1
+ """blocksd API — Unix socket server for external integration (Hypercolor, etc.)."""
2
+
3
+ from blocksd.api.server import ApiServer
4
+
5
+ __all__ = ["ApiServer"]
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 &amp;&amp; bun install &amp;&amp; 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")
@@ -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)