pitwall-agents 0.1.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.
Potentially problematic release.
This version of pitwall-agents might be problematic. Click here for more details.
- pitwall/__init__.py +2 -0
- pitwall/agents/__init__.py +1 -0
- pitwall/agents/claude_code.py +50 -0
- pitwall/agents/claude_settings.py +119 -0
- pitwall/badge_app/__init__.py +239 -0
- pitwall/badge_app/icon.png +0 -0
- pitwall/badge_app/links.py +179 -0
- pitwall/badge_app/model.py +98 -0
- pitwall/board.py +112 -0
- pitwall/cli.py +146 -0
- pitwall/config.py +102 -0
- pitwall/hook.py +43 -0
- pitwall/hub.py +140 -0
- pitwall/install_badge.py +66 -0
- pitwall/links/__init__.py +1 -0
- pitwall/links/ble.py +186 -0
- pitwall/links/usb.py +99 -0
- pitwall/pairing.py +60 -0
- pitwall/paths.py +24 -0
- pitwall/protocol.py +86 -0
- pitwall/render.py +36 -0
- pitwall/seal.py +132 -0
- pitwall/server.py +148 -0
- pitwall/service.py +90 -0
- pitwall/setup.py +121 -0
- pitwall/sim.py +88 -0
- pitwall/terminals/__init__.py +13 -0
- pitwall/terminals/iterm2.py +84 -0
- pitwall_agents-0.1.0.dist-info/METADATA +137 -0
- pitwall_agents-0.1.0.dist-info/RECORD +33 -0
- pitwall_agents-0.1.0.dist-info/WHEEL +4 -0
- pitwall_agents-0.1.0.dist-info/entry_points.txt +3 -0
- pitwall_agents-0.1.0.dist-info/licenses/LICENSE +21 -0
pitwall/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Agent plug-ins: map an agent's own events to pitwall session events."""
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Claude Code hooks → pitwall session events.
|
|
2
|
+
|
|
3
|
+
Only ids, the folder and the terminal tab leave this module. Prompts, tool input
|
|
4
|
+
and output, and assistant messages are never read.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
from typing import Mapping
|
|
10
|
+
|
|
11
|
+
AGENT = "claude-code"
|
|
12
|
+
|
|
13
|
+
_EVENT_STATE = {
|
|
14
|
+
"UserPromptSubmit": "green",
|
|
15
|
+
"PreToolUse": "green",
|
|
16
|
+
"PostToolUse": "green",
|
|
17
|
+
"PermissionRequest": "box",
|
|
18
|
+
"Stop": "done",
|
|
19
|
+
"StopFailure": "red",
|
|
20
|
+
"SessionStart": "done",
|
|
21
|
+
"SessionEnd": "end",
|
|
22
|
+
}
|
|
23
|
+
_NOTIFICATION_STATE = {"permission_prompt": "box", "elicitation_dialog": "box",
|
|
24
|
+
# Stop doesn't run when the user interrupts a turn; idle_prompt covers it
|
|
25
|
+
"idle_prompt": "done"}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def iterm_ref(env: Mapping[str, str]) -> dict | None:
|
|
29
|
+
raw = env.get("ITERM_SESSION_ID") or ""
|
|
30
|
+
ref = raw.split(":", 1)[-1].strip()
|
|
31
|
+
return {"kind": "iterm2", "ref": ref} if ref else None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def map_hook(payload: dict, env: Mapping[str, str]) -> dict | None:
|
|
35
|
+
if not isinstance(payload, dict):
|
|
36
|
+
return None
|
|
37
|
+
sid = payload.get("session_id")
|
|
38
|
+
event = payload.get("hook_event_name")
|
|
39
|
+
if not isinstance(sid, str) or not sid:
|
|
40
|
+
return None
|
|
41
|
+
if event == "Notification":
|
|
42
|
+
state = _NOTIFICATION_STATE.get(payload.get("notification_type"))
|
|
43
|
+
else:
|
|
44
|
+
state = _EVENT_STATE.get(event)
|
|
45
|
+
if state is None:
|
|
46
|
+
return None
|
|
47
|
+
cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
|
|
48
|
+
label = os.path.basename(cwd.rstrip("/")) or "session"
|
|
49
|
+
return {"type": "session", "agent": AGENT, "id": sid, "state": state,
|
|
50
|
+
"label": label, "cwd": cwd, "terminal": iterm_ref(env)}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Add or remove pitwall's hooks in Claude Code's settings.json, safely."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import tempfile
|
|
8
|
+
import time
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
EVENTS = ("SessionStart", "UserPromptSubmit", "PreToolUse", "PostToolUse", "PermissionRequest",
|
|
13
|
+
"Notification", "Stop", "StopFailure", "SessionEnd")
|
|
14
|
+
_MATCHER_EVENTS = {"PreToolUse", "PostToolUse", "PermissionRequest"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class SettingsError(Exception):
|
|
18
|
+
pass
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class InstallResult:
|
|
23
|
+
changed: bool
|
|
24
|
+
backup: Path | None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def default_settings_path() -> Path:
|
|
28
|
+
return Path.home() / ".claude" / "settings.json"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def hook_command() -> str:
|
|
32
|
+
return shutil.which("pitwall-hook") or "pitwall-hook"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_pitwall(cmd: str) -> bool:
|
|
36
|
+
return "pitwall-hook" in cmd or cmd.rstrip().endswith("pitwall hook")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _load(path: Path) -> dict:
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return {}
|
|
42
|
+
try:
|
|
43
|
+
data = json.loads(path.read_text() or "{}")
|
|
44
|
+
except ValueError as e:
|
|
45
|
+
raise SettingsError(f"{path} is not valid JSON ({e}); fix it first, nothing was changed") from e
|
|
46
|
+
if not isinstance(data, dict):
|
|
47
|
+
raise SettingsError(f"{path} must contain a JSON object; nothing was changed")
|
|
48
|
+
hooks = data.get("hooks", {})
|
|
49
|
+
if not isinstance(hooks, dict):
|
|
50
|
+
raise SettingsError(f'"hooks" in {path} must be an object; nothing was changed')
|
|
51
|
+
return data
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _strip(hooks: dict) -> dict:
|
|
55
|
+
"""Return hooks with every pitwall command removed (and emptied entries dropped)."""
|
|
56
|
+
out = {}
|
|
57
|
+
for event, entries in hooks.items():
|
|
58
|
+
if not isinstance(entries, list):
|
|
59
|
+
out[event] = entries
|
|
60
|
+
continue
|
|
61
|
+
kept = []
|
|
62
|
+
for entry in entries:
|
|
63
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("hooks"), list):
|
|
64
|
+
kept.append(entry)
|
|
65
|
+
continue
|
|
66
|
+
inner = [h for h in entry["hooks"]
|
|
67
|
+
if not (isinstance(h, dict) and _is_pitwall(str(h.get("command", ""))))]
|
|
68
|
+
if inner:
|
|
69
|
+
kept.append({**entry, "hooks": inner})
|
|
70
|
+
elif not entry["hooks"]:
|
|
71
|
+
kept.append(entry)
|
|
72
|
+
if kept:
|
|
73
|
+
out[event] = kept
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _write(path: Path, original: str | None, data: dict) -> Path | None:
|
|
78
|
+
link = path
|
|
79
|
+
path = path.resolve() # keep a symlinked settings.json a symlink (dotfiles)
|
|
80
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
81
|
+
mode = path.stat().st_mode & 0o777 if path.exists() else 0o644
|
|
82
|
+
backup = None
|
|
83
|
+
if original is not None:
|
|
84
|
+
backup = link.with_name(f"{link.name}.pitwall-bak-{time.strftime('%Y%m%d-%H%M%S')}")
|
|
85
|
+
backup.write_text(original)
|
|
86
|
+
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".settings.", suffix=".tmp")
|
|
87
|
+
with os.fdopen(fd, "w") as f:
|
|
88
|
+
json.dump(data, f, indent=2)
|
|
89
|
+
f.write("\n")
|
|
90
|
+
os.chmod(tmp, mode)
|
|
91
|
+
os.replace(tmp, path)
|
|
92
|
+
return backup
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def install(settings_path: Path, command: str) -> InstallResult:
|
|
96
|
+
data = _load(settings_path)
|
|
97
|
+
original = settings_path.read_text() if settings_path.exists() else None
|
|
98
|
+
hooks = _strip(data.get("hooks", {}))
|
|
99
|
+
for event in EVENTS:
|
|
100
|
+
entry: dict = {"hooks": [{"type": "command", "command": command, "timeout": 5}]}
|
|
101
|
+
if event in _MATCHER_EVENTS:
|
|
102
|
+
entry = {"matcher": "", **entry}
|
|
103
|
+
hooks.setdefault(event, []).append(entry)
|
|
104
|
+
new = {**data, "hooks": hooks}
|
|
105
|
+
if new == data:
|
|
106
|
+
return InstallResult(False, None)
|
|
107
|
+
return InstallResult(True, _write(settings_path, original, new))
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def uninstall(settings_path: Path) -> InstallResult:
|
|
111
|
+
if not settings_path.exists():
|
|
112
|
+
return InstallResult(False, None)
|
|
113
|
+
data = _load(settings_path)
|
|
114
|
+
original = settings_path.read_text()
|
|
115
|
+
hooks = _strip(data.get("hooks", {}))
|
|
116
|
+
new = {**data, "hooks": hooks} if hooks else {k: v for k, v in data.items() if k != "hooks"}
|
|
117
|
+
if new == data:
|
|
118
|
+
return InstallResult(False, None)
|
|
119
|
+
return InstallResult(True, _write(settings_path, original, new))
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# pitwall: a pit wall for your coding agents, on the GitHub Universe badge.
|
|
2
|
+
# Shows every agent session's state and focuses its terminal tab on a button press.
|
|
3
|
+
import math
|
|
4
|
+
|
|
5
|
+
import links
|
|
6
|
+
import model
|
|
7
|
+
|
|
8
|
+
badge.mode(HIRES | VSYNC)
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import missing_details
|
|
12
|
+
FONT = missing_details.mona_sans()
|
|
13
|
+
except Exception:
|
|
14
|
+
FONT = None
|
|
15
|
+
|
|
16
|
+
UID = badge.uid if isinstance(badge.uid, str) else "".join("%02x" % b for b in badge.uid)
|
|
17
|
+
CAPS = {"display": [320, 240], "color": True,
|
|
18
|
+
"buttons": ["up", "down", "left", "right", "select"], "keys": 0, "led": True}
|
|
19
|
+
|
|
20
|
+
bg = color.rgb(12, 14, 20)
|
|
21
|
+
panel = color.rgb(30, 34, 46)
|
|
22
|
+
white = color.rgb(238, 240, 246)
|
|
23
|
+
dim = color.rgb(238, 240, 246, 110)
|
|
24
|
+
faint = color.rgb(238, 240, 246, 50)
|
|
25
|
+
sub = color.rgb(238, 240, 246, 165)
|
|
26
|
+
amber = color.rgb(255, 196, 40)
|
|
27
|
+
red = color.rgb(240, 70, 70)
|
|
28
|
+
green = color.rgb(80, 210, 110)
|
|
29
|
+
black = color.rgb(20, 20, 20)
|
|
30
|
+
|
|
31
|
+
STATE_PEN = {"box": amber, "red": red, "green": green}
|
|
32
|
+
STATE_WORD = {"red": "failed", "done": "done", "green": "working"}
|
|
33
|
+
|
|
34
|
+
ROW_TOP, ROW_H, ROWS = 27, 31, 6
|
|
35
|
+
FOOT_Y = 215
|
|
36
|
+
|
|
37
|
+
view = model.View()
|
|
38
|
+
usb = links.Usb()
|
|
39
|
+
try:
|
|
40
|
+
ble = links.Ble(UID, {"type": "hello", "device": "badger2350", "uid": UID, "transport": "ble", "caps": CAPS})
|
|
41
|
+
except Exception as e:
|
|
42
|
+
print("pitwall: bluetooth unavailable", e)
|
|
43
|
+
ble = None
|
|
44
|
+
|
|
45
|
+
last_rx = {"usb": None, "ble": None}
|
|
46
|
+
last_hello_ms = -10000
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def hello(transport):
|
|
50
|
+
return {"type": "hello", "device": "badger2350", "uid": UID, "transport": transport, "caps": CAPS}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def active_transport(now):
|
|
54
|
+
if last_rx["usb"] is not None and now - last_rx["usb"] <= model.STALE_MS:
|
|
55
|
+
return "usb"
|
|
56
|
+
if ble is not None and ble.authed:
|
|
57
|
+
return "ble"
|
|
58
|
+
return None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def send(msg, now):
|
|
62
|
+
t = active_transport(now)
|
|
63
|
+
if t == "usb":
|
|
64
|
+
usb.send(msg)
|
|
65
|
+
elif t == "ble":
|
|
66
|
+
ble.send(msg)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def handle(msg, transport, now):
|
|
70
|
+
kind = msg.get("type")
|
|
71
|
+
if kind == "hello":
|
|
72
|
+
if transport == "usb":
|
|
73
|
+
usb.send(hello("usb"))
|
|
74
|
+
return
|
|
75
|
+
if kind == "pair_key" and transport == "usb":
|
|
76
|
+
# USB is a local cable: the helper hands over its Bluetooth key here
|
|
77
|
+
try:
|
|
78
|
+
host, key = msg["host"], bytes.fromhex(msg["key"])
|
|
79
|
+
except Exception:
|
|
80
|
+
return
|
|
81
|
+
if len(key) == 32:
|
|
82
|
+
if ble is not None:
|
|
83
|
+
ble.add_key(host, key)
|
|
84
|
+
else:
|
|
85
|
+
keys = links.load_keys()
|
|
86
|
+
keys[host] = key
|
|
87
|
+
links.save_keys(keys)
|
|
88
|
+
usb.send({"type": "paired", "host": host})
|
|
89
|
+
return
|
|
90
|
+
if kind in ("board", "alert"):
|
|
91
|
+
last_rx[transport] = now
|
|
92
|
+
# while USB is live, ignore a stale BLE board (the helper only talks on one anyway)
|
|
93
|
+
if transport == "ble" and active_transport(now) == "usb":
|
|
94
|
+
return
|
|
95
|
+
view.on_message(msg, now)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def text(s, x, y, size, pen):
|
|
99
|
+
screen.pen = pen
|
|
100
|
+
screen.text(s, x, y, size)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def text_w(s, size):
|
|
104
|
+
return screen.measure_text(s, size)[0]
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def chip(state, x, y, s, flash):
|
|
108
|
+
if state == "done":
|
|
109
|
+
half = s / 2
|
|
110
|
+
for i in range(2):
|
|
111
|
+
for j in range(2):
|
|
112
|
+
screen.pen = white if (i + j) % 2 == 0 else black
|
|
113
|
+
screen.shape(shape.rectangle(x + i * half, y + j * half, half, half))
|
|
114
|
+
return
|
|
115
|
+
pen = STATE_PEN.get(state, dim)
|
|
116
|
+
if state == "box" and flash:
|
|
117
|
+
pen = white
|
|
118
|
+
screen.pen = pen
|
|
119
|
+
screen.shape(shape.rounded_rectangle(x, y, s, s, 3))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def draw_header(now):
|
|
123
|
+
screen.pen = panel
|
|
124
|
+
screen.shape(shape.rectangle(0, 0, 320, 24))
|
|
125
|
+
x = 8
|
|
126
|
+
for state in ("box", "red", "done", "green"):
|
|
127
|
+
chip(state, x, 6, 12, False)
|
|
128
|
+
n = str(view.counts.get(state, 0))
|
|
129
|
+
text(n, x + 16, 4, 14, white)
|
|
130
|
+
x += 26 + text_w(n, 14)
|
|
131
|
+
t = active_transport(now)
|
|
132
|
+
link = "USB" if t == "usb" else ("BT" if t == "ble" else "--")
|
|
133
|
+
try:
|
|
134
|
+
level = int(badge.battery_level())
|
|
135
|
+
except Exception:
|
|
136
|
+
level = None
|
|
137
|
+
right = link + (" %d%%" % level if level is not None else "")
|
|
138
|
+
if len(view.sessions) > ROWS:
|
|
139
|
+
right = "%d/%d " % (view.cursor + 1, len(view.sessions)) + right
|
|
140
|
+
text(right, 312 - text_w(right, 12), 6, 12, dim if t else red)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def fit(s, size, max_w):
|
|
144
|
+
"""Cut text to fit max_w pixels, with an ellipsis."""
|
|
145
|
+
s = str(s)
|
|
146
|
+
if text_w(s, size) <= max_w:
|
|
147
|
+
return s
|
|
148
|
+
while s and text_w(s + "…", size) > max_w:
|
|
149
|
+
s = s[:-1]
|
|
150
|
+
return s + "…"
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def draw_rows(now, stale):
|
|
154
|
+
rows = view.rows(ROWS)
|
|
155
|
+
if not rows:
|
|
156
|
+
msg = "No agent sessions yet" if not stale else "Waiting for pit wall…"
|
|
157
|
+
text(msg, 160 - text_w(msg, 16) / 2, 105, 16, dim)
|
|
158
|
+
return
|
|
159
|
+
flash = view.alerting(now) and (now // 250) % 2 == 0
|
|
160
|
+
for idx, (i, s, selected) in enumerate(rows):
|
|
161
|
+
y = ROW_TOP + idx * ROW_H
|
|
162
|
+
if selected:
|
|
163
|
+
screen.pen = panel
|
|
164
|
+
screen.shape(shape.rounded_rectangle(4, y, 312, ROW_H - 2, 6))
|
|
165
|
+
state = s.get("state", "")
|
|
166
|
+
chip(state, 12, y + 8, 13, flash and s.get("id") == view.alert_id)
|
|
167
|
+
if state == "box":
|
|
168
|
+
right, rpen = model.fmt_age(s.get("age_s", 0)), amber
|
|
169
|
+
else:
|
|
170
|
+
right, rpen = STATE_WORD.get(state, ""), dim
|
|
171
|
+
rw = text_w(right, 12)
|
|
172
|
+
text(right, 308 - rw, y + 9, 12, rpen if not stale else faint)
|
|
173
|
+
max_w = 308 - rw - 10 - 34
|
|
174
|
+
live = s.get("focusable", True) and not stale
|
|
175
|
+
title, folder = s.get("title"), str(s.get("label", ""))
|
|
176
|
+
if title and title.strip().lower() != folder.strip().lower():
|
|
177
|
+
text(fit(title, 14, max_w), 34, y + 2, 14, white if live else dim)
|
|
178
|
+
text(fit(folder, 11, max_w), 34, y + 17, 11, sub if live else faint)
|
|
179
|
+
else:
|
|
180
|
+
text(fit(title or folder, 14, max_w), 34, y + 8, 14, white if live else dim)
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def draw_footer(stale):
|
|
184
|
+
screen.pen = red if stale else panel
|
|
185
|
+
screen.shape(shape.rectangle(0, FOOT_Y + 2, 320, 240 - FOOT_Y - 2))
|
|
186
|
+
if stale:
|
|
187
|
+
msg = "No connection to pit wall"
|
|
188
|
+
text(msg, 160 - text_w(msg, 12) / 2, FOOT_Y + 5, 12, white)
|
|
189
|
+
return
|
|
190
|
+
x = 8
|
|
191
|
+
for key, what in (("SELECT", "focus"), ("RIGHT", "next box box"), ("LEFT", "top")):
|
|
192
|
+
text(key, x, FOOT_Y + 5, 12, amber)
|
|
193
|
+
x += text_w(key, 12) + 4
|
|
194
|
+
text(what, x, FOOT_Y + 5, 12, white)
|
|
195
|
+
x += text_w(what, 12) + 12
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def update():
|
|
199
|
+
global last_hello_ms
|
|
200
|
+
now = badge.ticks
|
|
201
|
+
for m in usb.read():
|
|
202
|
+
handle(m, "usb", now)
|
|
203
|
+
if ble is not None:
|
|
204
|
+
for m in ble.read():
|
|
205
|
+
handle(m, "ble", now)
|
|
206
|
+
|
|
207
|
+
if badge.pressed(BUTTON_UP):
|
|
208
|
+
view.move(-1)
|
|
209
|
+
elif badge.pressed(BUTTON_DOWN):
|
|
210
|
+
view.move(1)
|
|
211
|
+
elif badge.pressed(BUTTON_LEFT):
|
|
212
|
+
view.move(-len(view.sessions))
|
|
213
|
+
elif badge.pressed(BUTTON_SELECT):
|
|
214
|
+
sid = view.selected_id()
|
|
215
|
+
if sid:
|
|
216
|
+
send({"type": "focus", "id": sid}, now)
|
|
217
|
+
elif badge.pressed(BUTTON_RIGHT):
|
|
218
|
+
send({"type": "focus_next_waiting"}, now)
|
|
219
|
+
|
|
220
|
+
stale = view.stale(now)
|
|
221
|
+
if stale and now - last_hello_ms > 5000:
|
|
222
|
+
usb.send(hello("usb")) # the helper may have missed our first hello
|
|
223
|
+
last_hello_ms = now
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
badge.caselights((0.5 + 0.5 * math.sin(now / 120)) if view.alerting(now) else 0)
|
|
227
|
+
except Exception:
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
if FONT is not None:
|
|
231
|
+
screen.font = FONT
|
|
232
|
+
screen.pen = bg
|
|
233
|
+
screen.clear()
|
|
234
|
+
draw_header(now)
|
|
235
|
+
draw_rows(now, stale)
|
|
236
|
+
draw_footer(stale)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
run(update)
|
|
Binary file
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# pitwall badge: USB serial and Bluetooth LE links.
|
|
2
|
+
import json
|
|
3
|
+
import os
|
|
4
|
+
import select
|
|
5
|
+
import sys
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
import bluetooth
|
|
9
|
+
import model
|
|
10
|
+
import seal
|
|
11
|
+
|
|
12
|
+
KEYS_FILE = "/state/pitwall_keys.json"
|
|
13
|
+
|
|
14
|
+
_SERVICE = bluetooth.UUID("6f1c0001-9b2a-4c55-8f3e-70697477616c")
|
|
15
|
+
_RX = (bluetooth.UUID("6f1c0002-9b2a-4c55-8f3e-70697477616c"), 0x0008 | 0x0004) # write, write-no-response
|
|
16
|
+
_TX = (bluetooth.UUID("6f1c0003-9b2a-4c55-8f3e-70697477616c"), 0x0010) # notify
|
|
17
|
+
|
|
18
|
+
_IRQ_CENTRAL_CONNECT = 1
|
|
19
|
+
_IRQ_CENTRAL_DISCONNECT = 2
|
|
20
|
+
_IRQ_GATTS_WRITE = 3
|
|
21
|
+
_IRQ_MTU_EXCHANGED = 21
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _line(msg):
|
|
25
|
+
m = dict(msg)
|
|
26
|
+
m["v"] = 1
|
|
27
|
+
return json.dumps(m).encode() + b"\n"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Usb:
|
|
31
|
+
transport = "usb"
|
|
32
|
+
|
|
33
|
+
def __init__(self):
|
|
34
|
+
self.poll = select.poll()
|
|
35
|
+
self.poll.register(sys.stdin, select.POLLIN)
|
|
36
|
+
self.dec = model.Decoder()
|
|
37
|
+
self.stdin = getattr(sys.stdin, "buffer", sys.stdin)
|
|
38
|
+
|
|
39
|
+
def read(self):
|
|
40
|
+
data = b""
|
|
41
|
+
while len(data) < 2048 and self.poll.poll(0):
|
|
42
|
+
c = self.stdin.read(1)
|
|
43
|
+
if not c:
|
|
44
|
+
break
|
|
45
|
+
data += c if isinstance(c, bytes) else c.encode()
|
|
46
|
+
return self.dec.feed(data) if data else []
|
|
47
|
+
|
|
48
|
+
def send(self, msg):
|
|
49
|
+
try:
|
|
50
|
+
sys.stdout.write(_line(msg).decode())
|
|
51
|
+
except Exception:
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def load_keys():
|
|
56
|
+
try:
|
|
57
|
+
with open(KEYS_FILE) as f:
|
|
58
|
+
return {h: bytes.fromhex(k) for h, k in json.load(f).items()}
|
|
59
|
+
except Exception:
|
|
60
|
+
return {}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def save_keys(keys):
|
|
64
|
+
try:
|
|
65
|
+
with open(KEYS_FILE, "w") as f:
|
|
66
|
+
json.dump({h: k.hex() for h, k in keys.items()}, f)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
print("pitwall: could not save keys", e)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _adv(adv_type, value):
|
|
72
|
+
return bytes((len(value) + 1, adv_type)) + value
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class Ble:
|
|
76
|
+
transport = "ble"
|
|
77
|
+
|
|
78
|
+
def __init__(self, uid, hello):
|
|
79
|
+
self.uid = uid
|
|
80
|
+
self.hello = hello
|
|
81
|
+
self.name = "pitwall-" + uid[:8]
|
|
82
|
+
self.keys = load_keys()
|
|
83
|
+
self.ble = bluetooth.BLE()
|
|
84
|
+
self.ble.active(True)
|
|
85
|
+
try:
|
|
86
|
+
self.ble.config(gap_name=self.name, mtu=247)
|
|
87
|
+
except Exception:
|
|
88
|
+
self.ble.config(gap_name=self.name)
|
|
89
|
+
self.ble.irq(self._irq)
|
|
90
|
+
((self.h_tx, self.h_rx),) = self.ble.gatts_register_services(((_SERVICE, (_TX, _RX)),))
|
|
91
|
+
self.ble.gatts_set_buffer(self.h_rx, 512, True)
|
|
92
|
+
self.conn = None
|
|
93
|
+
self.mtu = 23
|
|
94
|
+
self.inbox = b""
|
|
95
|
+
self._reset()
|
|
96
|
+
self._advertise()
|
|
97
|
+
|
|
98
|
+
def _reset(self):
|
|
99
|
+
self.dec = model.Decoder()
|
|
100
|
+
self.auth = seal.DeviceAuth(self.uid, self.keys, os.urandom)
|
|
101
|
+
self.sealer = None
|
|
102
|
+
self.opener = None
|
|
103
|
+
|
|
104
|
+
def add_key(self, host, key):
|
|
105
|
+
"""Called when the helper sends this Mac's key over USB."""
|
|
106
|
+
if self.keys.get(host) != key:
|
|
107
|
+
self.keys[host] = key
|
|
108
|
+
save_keys(self.keys)
|
|
109
|
+
|
|
110
|
+
@property
|
|
111
|
+
def authed(self):
|
|
112
|
+
return self.sealer is not None
|
|
113
|
+
|
|
114
|
+
def _advertise(self):
|
|
115
|
+
flags = _adv(0x01, b"\x06")
|
|
116
|
+
uuid = _adv(0x07, bytes(reversed(bytes.fromhex("6f1c00019b2a4c558f3e70697477616c"))))
|
|
117
|
+
name = _adv(0x09, self.name.encode())
|
|
118
|
+
try:
|
|
119
|
+
self.ble.gap_advertise(200_000, adv_data=flags + uuid, resp_data=name)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print("pitwall: advertise failed", e)
|
|
122
|
+
|
|
123
|
+
def _irq(self, event, data):
|
|
124
|
+
if event == _IRQ_CENTRAL_CONNECT:
|
|
125
|
+
self.conn = data[0]
|
|
126
|
+
self._reset()
|
|
127
|
+
elif event == _IRQ_CENTRAL_DISCONNECT:
|
|
128
|
+
self.conn = None
|
|
129
|
+
self._reset()
|
|
130
|
+
self._advertise()
|
|
131
|
+
elif event == _IRQ_GATTS_WRITE:
|
|
132
|
+
self.inbox += self.ble.gatts_read(self.h_rx)
|
|
133
|
+
elif event == _IRQ_MTU_EXCHANGED:
|
|
134
|
+
self.mtu = data[1]
|
|
135
|
+
|
|
136
|
+
def _notify(self, data):
|
|
137
|
+
if self.conn is None:
|
|
138
|
+
return
|
|
139
|
+
size = max(20, self.mtu - 3)
|
|
140
|
+
for i in range(0, len(data), size):
|
|
141
|
+
for _ in range(20):
|
|
142
|
+
try:
|
|
143
|
+
self.ble.gatts_notify(self.conn, self.h_tx, data[i:i + size])
|
|
144
|
+
break
|
|
145
|
+
except OSError:
|
|
146
|
+
time.sleep_ms(5)
|
|
147
|
+
|
|
148
|
+
def _raw(self, msg):
|
|
149
|
+
self._notify(_line(msg))
|
|
150
|
+
|
|
151
|
+
def send(self, msg):
|
|
152
|
+
if self.sealer is not None:
|
|
153
|
+
self._notify(_line(self.sealer.seal(_line(msg).rstrip(b"\n"))))
|
|
154
|
+
|
|
155
|
+
def read(self):
|
|
156
|
+
"""Returns app messages (only after auth). Handles the handshake itself."""
|
|
157
|
+
if not self.inbox:
|
|
158
|
+
return []
|
|
159
|
+
data, self.inbox = self.inbox, b""
|
|
160
|
+
out = []
|
|
161
|
+
for m in self.dec.feed(data):
|
|
162
|
+
kind = m.get("type")
|
|
163
|
+
if kind == "sealed":
|
|
164
|
+
if self.opener is not None:
|
|
165
|
+
line = self.opener.open(m)
|
|
166
|
+
if line is not None:
|
|
167
|
+
out += model.Decoder().feed(line + b"\n")
|
|
168
|
+
elif kind == "hello" and not self.authed:
|
|
169
|
+
self._raw(self.hello)
|
|
170
|
+
self._raw(self.auth.challenge())
|
|
171
|
+
elif kind == "auth" and not self.authed:
|
|
172
|
+
dn = self.auth.nonce
|
|
173
|
+
reply, host, skey = self.auth.handle(m)
|
|
174
|
+
self._raw(reply)
|
|
175
|
+
if host:
|
|
176
|
+
hn = bytes.fromhex(m["hn"])
|
|
177
|
+
self.sealer = seal.Sealer(skey, dn + hn, "d2h")
|
|
178
|
+
self.opener = seal.Opener(skey, dn + hn, "h2d")
|
|
179
|
+
return out
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# pitwall badge: pure state (no hardware). Runs on MicroPython and CPython (tests).
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
STALE_MS = 15000
|
|
5
|
+
ALERT_MS = 3000
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class Decoder:
|
|
9
|
+
"""Bytes in, protocol messages out. Skips anything that isn't a v1 JSON object."""
|
|
10
|
+
|
|
11
|
+
def __init__(self):
|
|
12
|
+
self.buf = b""
|
|
13
|
+
|
|
14
|
+
def feed(self, data):
|
|
15
|
+
self.buf += data
|
|
16
|
+
out = []
|
|
17
|
+
while b"\n" in self.buf:
|
|
18
|
+
line, self.buf = self.buf.split(b"\n", 1)
|
|
19
|
+
line = line.strip()
|
|
20
|
+
if not line:
|
|
21
|
+
continue
|
|
22
|
+
try:
|
|
23
|
+
msg = json.loads(line.decode("utf-8"))
|
|
24
|
+
except Exception:
|
|
25
|
+
continue
|
|
26
|
+
if isinstance(msg, dict) and msg.get("v") == 1:
|
|
27
|
+
out.append(msg)
|
|
28
|
+
if len(self.buf) > 16384:
|
|
29
|
+
self.buf = b""
|
|
30
|
+
return out
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def fmt_age(s):
|
|
34
|
+
s = max(0, int(s))
|
|
35
|
+
if s < 60:
|
|
36
|
+
return "%ds" % s
|
|
37
|
+
if s < 3600:
|
|
38
|
+
return "%dm" % (s // 60)
|
|
39
|
+
return "%dh%02d" % (s // 3600, (s % 3600) // 60)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class View:
|
|
43
|
+
def __init__(self):
|
|
44
|
+
self.sessions = []
|
|
45
|
+
self.counts = {"box": 0, "red": 0, "done": 0, "green": 0}
|
|
46
|
+
self.cursor = 0
|
|
47
|
+
self.top = 0
|
|
48
|
+
self.last_board_ms = None
|
|
49
|
+
self.alert_ms = None
|
|
50
|
+
self.alert_id = None
|
|
51
|
+
|
|
52
|
+
def on_message(self, msg, now_ms):
|
|
53
|
+
kind = msg.get("type")
|
|
54
|
+
if kind == "board":
|
|
55
|
+
keep = self.selected_id()
|
|
56
|
+
self.sessions = msg.get("sessions") or []
|
|
57
|
+
self.counts = msg.get("counts") or self.counts
|
|
58
|
+
self.last_board_ms = now_ms
|
|
59
|
+
ids = [s.get("id") for s in self.sessions]
|
|
60
|
+
if keep in ids:
|
|
61
|
+
self.cursor = ids.index(keep)
|
|
62
|
+
self._clamp()
|
|
63
|
+
elif kind == "alert":
|
|
64
|
+
self.alert_ms = now_ms
|
|
65
|
+
self.alert_id = msg.get("id")
|
|
66
|
+
|
|
67
|
+
def _clamp(self):
|
|
68
|
+
n = len(self.sessions)
|
|
69
|
+
self.cursor = 0 if n == 0 else max(0, min(self.cursor, n - 1))
|
|
70
|
+
|
|
71
|
+
def move(self, delta):
|
|
72
|
+
self.cursor += delta
|
|
73
|
+
self._clamp()
|
|
74
|
+
|
|
75
|
+
def selected_id(self):
|
|
76
|
+
if not self.sessions:
|
|
77
|
+
return None
|
|
78
|
+
return self.sessions[self.cursor].get("id")
|
|
79
|
+
|
|
80
|
+
def rows(self, max_rows=8):
|
|
81
|
+
n = len(self.sessions)
|
|
82
|
+
if n == 0:
|
|
83
|
+
return []
|
|
84
|
+
if self.cursor < self.top:
|
|
85
|
+
self.top = self.cursor
|
|
86
|
+
elif self.cursor >= self.top + max_rows:
|
|
87
|
+
self.top = self.cursor - max_rows + 1
|
|
88
|
+
self.top = max(0, min(self.top, max(0, n - max_rows)))
|
|
89
|
+
out = []
|
|
90
|
+
for i in range(self.top, min(n, self.top + max_rows)):
|
|
91
|
+
out.append((i, self.sessions[i], i == self.cursor))
|
|
92
|
+
return out
|
|
93
|
+
|
|
94
|
+
def stale(self, now_ms):
|
|
95
|
+
return self.last_board_ms is None or now_ms - self.last_board_ms > STALE_MS
|
|
96
|
+
|
|
97
|
+
def alerting(self, now_ms):
|
|
98
|
+
return self.alert_ms is not None and now_ms - self.alert_ms < ALERT_MS
|