pitwall-agents 0.1.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.
Files changed (67) hide show
  1. pitwall_agents-0.1.0/.github/workflows/release.yml +34 -0
  2. pitwall_agents-0.1.0/.gitignore +8 -0
  3. pitwall_agents-0.1.0/LICENSE +21 -0
  4. pitwall_agents-0.1.0/PKG-INFO +137 -0
  5. pitwall_agents-0.1.0/README.md +114 -0
  6. pitwall_agents-0.1.0/badge/pitwall/__init__.py +239 -0
  7. pitwall_agents-0.1.0/badge/pitwall/icon.png +0 -0
  8. pitwall_agents-0.1.0/badge/pitwall/links.py +179 -0
  9. pitwall_agents-0.1.0/badge/pitwall/model.py +98 -0
  10. pitwall_agents-0.1.0/docs/protocol.md +131 -0
  11. pitwall_agents-0.1.0/pyproject.toml +44 -0
  12. pitwall_agents-0.1.0/skills/pitwall/SKILL.md +58 -0
  13. pitwall_agents-0.1.0/skills/pitwall-device/SKILL.md +30 -0
  14. pitwall_agents-0.1.0/src/pitwall/__init__.py +2 -0
  15. pitwall_agents-0.1.0/src/pitwall/agents/__init__.py +1 -0
  16. pitwall_agents-0.1.0/src/pitwall/agents/claude_code.py +50 -0
  17. pitwall_agents-0.1.0/src/pitwall/agents/claude_settings.py +119 -0
  18. pitwall_agents-0.1.0/src/pitwall/board.py +112 -0
  19. pitwall_agents-0.1.0/src/pitwall/cli.py +146 -0
  20. pitwall_agents-0.1.0/src/pitwall/config.py +102 -0
  21. pitwall_agents-0.1.0/src/pitwall/hook.py +43 -0
  22. pitwall_agents-0.1.0/src/pitwall/hub.py +140 -0
  23. pitwall_agents-0.1.0/src/pitwall/install_badge.py +66 -0
  24. pitwall_agents-0.1.0/src/pitwall/links/__init__.py +1 -0
  25. pitwall_agents-0.1.0/src/pitwall/links/ble.py +186 -0
  26. pitwall_agents-0.1.0/src/pitwall/links/usb.py +99 -0
  27. pitwall_agents-0.1.0/src/pitwall/pairing.py +60 -0
  28. pitwall_agents-0.1.0/src/pitwall/paths.py +24 -0
  29. pitwall_agents-0.1.0/src/pitwall/protocol.py +86 -0
  30. pitwall_agents-0.1.0/src/pitwall/render.py +36 -0
  31. pitwall_agents-0.1.0/src/pitwall/seal.py +132 -0
  32. pitwall_agents-0.1.0/src/pitwall/server.py +148 -0
  33. pitwall_agents-0.1.0/src/pitwall/service.py +90 -0
  34. pitwall_agents-0.1.0/src/pitwall/setup.py +121 -0
  35. pitwall_agents-0.1.0/src/pitwall/sim.py +88 -0
  36. pitwall_agents-0.1.0/src/pitwall/terminals/__init__.py +13 -0
  37. pitwall_agents-0.1.0/src/pitwall/terminals/iterm2.py +84 -0
  38. pitwall_agents-0.1.0/tests/conftest.py +35 -0
  39. pitwall_agents-0.1.0/tests/fakes.py +31 -0
  40. pitwall_agents-0.1.0/tests/fixtures/claude_code/Notification_idle_prompt.json +8 -0
  41. pitwall_agents-0.1.0/tests/fixtures/claude_code/Notification_permission_prompt.json +8 -0
  42. pitwall_agents-0.1.0/tests/fixtures/claude_code/PermissionRequest.json +14 -0
  43. pitwall_agents-0.1.0/tests/fixtures/claude_code/PostToolUse.json +16 -0
  44. pitwall_agents-0.1.0/tests/fixtures/claude_code/PreToolUse.json +14 -0
  45. pitwall_agents-0.1.0/tests/fixtures/claude_code/SessionEnd.json +8 -0
  46. pitwall_agents-0.1.0/tests/fixtures/claude_code/SessionStart.json +7 -0
  47. pitwall_agents-0.1.0/tests/fixtures/claude_code/Stop.json +15 -0
  48. pitwall_agents-0.1.0/tests/fixtures/claude_code/StopFailure.json +7 -0
  49. pitwall_agents-0.1.0/tests/fixtures/claude_code/UserPromptSubmit.json +9 -0
  50. pitwall_agents-0.1.0/tests/test_badge_model.py +75 -0
  51. pitwall_agents-0.1.0/tests/test_ble_link.py +200 -0
  52. pitwall_agents-0.1.0/tests/test_board.py +123 -0
  53. pitwall_agents-0.1.0/tests/test_claude_code.py +54 -0
  54. pitwall_agents-0.1.0/tests/test_claude_settings.py +108 -0
  55. pitwall_agents-0.1.0/tests/test_cli.py +88 -0
  56. pitwall_agents-0.1.0/tests/test_config.py +52 -0
  57. pitwall_agents-0.1.0/tests/test_hook.py +62 -0
  58. pitwall_agents-0.1.0/tests/test_hub.py +178 -0
  59. pitwall_agents-0.1.0/tests/test_install_badge.py +65 -0
  60. pitwall_agents-0.1.0/tests/test_iterm2.py +66 -0
  61. pitwall_agents-0.1.0/tests/test_pairing.py +93 -0
  62. pitwall_agents-0.1.0/tests/test_protocol.py +42 -0
  63. pitwall_agents-0.1.0/tests/test_server.py +70 -0
  64. pitwall_agents-0.1.0/tests/test_service.py +59 -0
  65. pitwall_agents-0.1.0/tests/test_setup.py +65 -0
  66. pitwall_agents-0.1.0/tests/test_sim.py +19 -0
  67. pitwall_agents-0.1.0/tests/test_usb_link.py +151 -0
@@ -0,0 +1,34 @@
1
+ name: release
2
+
3
+ # Publishes to PyPI when a version tag (v*) is pushed, or when run by hand.
4
+ on:
5
+ push:
6
+ tags: ["v*"]
7
+ workflow_dispatch:
8
+
9
+ permissions:
10
+ contents: read
11
+
12
+ jobs:
13
+ publish:
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v4
17
+ - uses: astral-sh/setup-uv@v6
18
+
19
+ - name: Test
20
+ run: uv run --extra dev pytest -q
21
+
22
+ - name: Tag matches the package version
23
+ if: startsWith(github.ref, 'refs/tags/')
24
+ run: |
25
+ version=$(python3 -c "import tomllib; print(tomllib.load(open('pyproject.toml','rb'))['project']['version'])")
26
+ test "v${version}" = "${GITHUB_REF_NAME}" || { echo "tag ${GITHUB_REF_NAME} != v${version}"; exit 1; }
27
+
28
+ - name: Build
29
+ run: uv build
30
+
31
+ - name: Publish to PyPI
32
+ run: uv publish
33
+ env:
34
+ UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_TOKEN }}
@@ -0,0 +1,8 @@
1
+ .superpowers/
2
+ __pycache__/
3
+ *.pyc
4
+ .venv/
5
+ dist/
6
+ *.egg-info/
7
+ .pytest_cache/
8
+ spike/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siddhant Sinha
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.5
2
+ Name: pitwall-agents
3
+ Version: 0.1.0
4
+ Summary: A pit wall for your coding agents: see every session's state on a desk device and jump to the right terminal tab.
5
+ Project-URL: Homepage, https://github.com/siddhant573/pitwall
6
+ Author: Siddhant Sinha
7
+ License-Expression: MIT
8
+ License-File: LICENSE
9
+ Keywords: badge,bluetooth,claude-code,coding-agents,github-universe,iterm2
10
+ Classifier: Environment :: Console
11
+ Classifier: Operating System :: MacOS
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Topic :: Software Development
14
+ Requires-Python: >=3.10
15
+ Requires-Dist: bleak>=0.22
16
+ Requires-Dist: mpremote>=1.24
17
+ Requires-Dist: pyserial>=3.5
18
+ Requires-Dist: tomli>=2; python_version < '3.11'
19
+ Provides-Extra: dev
20
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
21
+ Requires-Dist: pytest>=8; extra == 'dev'
22
+ Description-Content-Type: text/markdown
23
+
24
+ # pitwall 🏁
25
+
26
+ **A pit wall for your coding agents.** See every agent session's state on a device on your desk, and jump to the right terminal tab with one button.
27
+
28
+ If you run 15–20 Claude Code sessions in iTerm tabs, you know the loop: cycle through tabs, find the one waiting on a permission prompt, approve it, repeat. pitwall shows you which session needs you, so you can stop hunting for it.
29
+
30
+ The first device is the **GitHub Universe 2026 badge** (Pimoroni Badger 2350). The protocol is open, so a Stream Deck, a macro keypad or anything else can join (see [Write your own device](#write-your-own-device)).
31
+
32
+ <!-- docs/media/demo.gif goes here -->
33
+
34
+ ## The flags
35
+
36
+ | | State | Means |
37
+ |---|---|---|
38
+ | 🟢 | **Green flag** | the agent is working |
39
+ | 🟡 | **Box box** | the agent needs you: a permission prompt or a question |
40
+ | 🏁 | **Chequered flag** | the agent finished its turn |
41
+ | 🔴 | **Red flag** | the agent's turn failed |
42
+
43
+ The sessions waiting longest for you are always at the top.
44
+
45
+ ## Install (macOS)
46
+
47
+ ```bash
48
+ pipx install pitwall-agents
49
+ pitwall setup
50
+ ```
51
+
52
+ `pitwall setup` finds your badge, your agents and your terminal, shows what it will do, and after one "yes":
53
+
54
+ 1. writes `~/.pitwall/config.toml`
55
+ 2. adds pitwall's hooks to Claude Code (`~/.claude/settings.json`, with a backup)
56
+ 3. installs the Pitwall app on the badge (plug it in over USB with a data cable)
57
+ 4. keeps the helper running in the background with a LaunchAgent (starts at login)
58
+
59
+ Then open **Pitwall** from the badge's home menu. Your Claude Code sessions show up on their next prompt or tool call.
60
+
61
+ **Bluetooth is automatic.** While the badge is plugged in, the helper gives it a random key over the cable. Unplug it and it keeps working over Bluetooth; plug it back in and it switches to USB.
62
+
63
+ **macOS asks once** to let pitwall control iTerm2 (to switch tabs) and use Bluetooth. Allow both.
64
+
65
+ No badge? `pitwall sim` shows the same board in a terminal tab, and `pitwall status` prints it once.
66
+
67
+ ## Configure
68
+
69
+ `~/.pitwall/config.toml`:
70
+
71
+ ```toml
72
+ terminal = "iterm2"
73
+ agents = ["claude-code"]
74
+
75
+ [devices.badger2350] # GitHub Universe 2026 badge (Pimoroni Badger 2350)
76
+ transports = ["usb", "ble"]
77
+ ```
78
+
79
+ For one run, `pitwall serve --device badger2350:usb` overrides the devices (repeat `--device` for several).
80
+
81
+ | Command | Does |
82
+ |---|---|
83
+ | `pitwall setup` | detect and install everything |
84
+ | `pitwall service install \| uninstall \| status \| restart` | the background LaunchAgent |
85
+ | `pitwall status` | print the board |
86
+ | `pitwall install claude-code` / `pitwall uninstall claude-code` | add or remove the hooks |
87
+ | `pitwall install badge` | (re)install the badge app |
88
+ | `pitwall serve [-v] [--device …]` | run the helper in the foreground |
89
+ | `pitwall sim` | a simulated device in the terminal |
90
+
91
+ Logs: `~/.pitwall/serve.log`.
92
+
93
+ ## Badge buttons
94
+
95
+ | Button | Does |
96
+ |---|---|
97
+ | UP / DOWN | move the cursor |
98
+ | SELECT | jump to the selected session's tab |
99
+ | RIGHT | jump to the session that's waited longest for you (🟡) |
100
+ | LEFT | back to the top |
101
+
102
+ The badge's lights pulse when a session goes 🟡. If it loses the helper, it says so and dims the last board.
103
+
104
+ ## What pitwall does *not* do
105
+
106
+ - **It never acts on your agents.** Devices only show status and switch tabs. There's no approving or prompting from a button.
107
+ - **It never reads your prompts or code.** The hook sends only the session id, the folder name and the terminal tab. Over Bluetooth, even that is encrypted after pairing.
108
+ - **No cloud, no accounts, no network calls.** Everything stays on your Mac and your desk.
109
+ - **It never slows Claude down.** The hook exits immediately (about 50 ms) even if the helper isn't running.
110
+
111
+ ## Write your own device
112
+
113
+ Devices speak a tiny JSON-lines protocol. Connect to `~/.pitwall/devices.sock`, say `hello`, and you'll get a `board` every time something changes. Send `focus` to switch tabs. Everything is in **[docs/protocol.md](docs/protocol.md)**, and no pitwall code needs reading. Other agents can report sessions the same way, through `~/.pitwall/pitwall.sock`.
114
+
115
+ ## Roadmap
116
+
117
+ - More devices: Stream Deck (one key per session), Logitech MX Creative Keypad
118
+ - More agents: Codex, Copilot CLI, Gemini CLI
119
+ - More terminals: Ghostty, WezTerm, tmux
120
+
121
+ ## Skills for your agent
122
+
123
+ The repo ships two [Claude Code skills](skills/): `pitwall` (set up, check, troubleshoot) and `pitwall-device` (build a new device on the protocol). Copy them into `~/.claude/skills/` to use them.
124
+
125
+ ## Uninstall
126
+
127
+ ```bash
128
+ pitwall service uninstall
129
+ pitwall uninstall claude-code
130
+ pipx uninstall pitwall-agents
131
+ ```
132
+
133
+ On the badge, delete `apps/pitwall` while it's in disk mode.
134
+
135
+ ## License
136
+
137
+ MIT © Siddhant Sinha
@@ -0,0 +1,114 @@
1
+ # pitwall 🏁
2
+
3
+ **A pit wall for your coding agents.** See every agent session's state on a device on your desk, and jump to the right terminal tab with one button.
4
+
5
+ If you run 15–20 Claude Code sessions in iTerm tabs, you know the loop: cycle through tabs, find the one waiting on a permission prompt, approve it, repeat. pitwall shows you which session needs you, so you can stop hunting for it.
6
+
7
+ The first device is the **GitHub Universe 2026 badge** (Pimoroni Badger 2350). The protocol is open, so a Stream Deck, a macro keypad or anything else can join (see [Write your own device](#write-your-own-device)).
8
+
9
+ <!-- docs/media/demo.gif goes here -->
10
+
11
+ ## The flags
12
+
13
+ | | State | Means |
14
+ |---|---|---|
15
+ | 🟢 | **Green flag** | the agent is working |
16
+ | 🟡 | **Box box** | the agent needs you: a permission prompt or a question |
17
+ | 🏁 | **Chequered flag** | the agent finished its turn |
18
+ | 🔴 | **Red flag** | the agent's turn failed |
19
+
20
+ The sessions waiting longest for you are always at the top.
21
+
22
+ ## Install (macOS)
23
+
24
+ ```bash
25
+ pipx install pitwall-agents
26
+ pitwall setup
27
+ ```
28
+
29
+ `pitwall setup` finds your badge, your agents and your terminal, shows what it will do, and after one "yes":
30
+
31
+ 1. writes `~/.pitwall/config.toml`
32
+ 2. adds pitwall's hooks to Claude Code (`~/.claude/settings.json`, with a backup)
33
+ 3. installs the Pitwall app on the badge (plug it in over USB with a data cable)
34
+ 4. keeps the helper running in the background with a LaunchAgent (starts at login)
35
+
36
+ Then open **Pitwall** from the badge's home menu. Your Claude Code sessions show up on their next prompt or tool call.
37
+
38
+ **Bluetooth is automatic.** While the badge is plugged in, the helper gives it a random key over the cable. Unplug it and it keeps working over Bluetooth; plug it back in and it switches to USB.
39
+
40
+ **macOS asks once** to let pitwall control iTerm2 (to switch tabs) and use Bluetooth. Allow both.
41
+
42
+ No badge? `pitwall sim` shows the same board in a terminal tab, and `pitwall status` prints it once.
43
+
44
+ ## Configure
45
+
46
+ `~/.pitwall/config.toml`:
47
+
48
+ ```toml
49
+ terminal = "iterm2"
50
+ agents = ["claude-code"]
51
+
52
+ [devices.badger2350] # GitHub Universe 2026 badge (Pimoroni Badger 2350)
53
+ transports = ["usb", "ble"]
54
+ ```
55
+
56
+ For one run, `pitwall serve --device badger2350:usb` overrides the devices (repeat `--device` for several).
57
+
58
+ | Command | Does |
59
+ |---|---|
60
+ | `pitwall setup` | detect and install everything |
61
+ | `pitwall service install \| uninstall \| status \| restart` | the background LaunchAgent |
62
+ | `pitwall status` | print the board |
63
+ | `pitwall install claude-code` / `pitwall uninstall claude-code` | add or remove the hooks |
64
+ | `pitwall install badge` | (re)install the badge app |
65
+ | `pitwall serve [-v] [--device …]` | run the helper in the foreground |
66
+ | `pitwall sim` | a simulated device in the terminal |
67
+
68
+ Logs: `~/.pitwall/serve.log`.
69
+
70
+ ## Badge buttons
71
+
72
+ | Button | Does |
73
+ |---|---|
74
+ | UP / DOWN | move the cursor |
75
+ | SELECT | jump to the selected session's tab |
76
+ | RIGHT | jump to the session that's waited longest for you (🟡) |
77
+ | LEFT | back to the top |
78
+
79
+ The badge's lights pulse when a session goes 🟡. If it loses the helper, it says so and dims the last board.
80
+
81
+ ## What pitwall does *not* do
82
+
83
+ - **It never acts on your agents.** Devices only show status and switch tabs. There's no approving or prompting from a button.
84
+ - **It never reads your prompts or code.** The hook sends only the session id, the folder name and the terminal tab. Over Bluetooth, even that is encrypted after pairing.
85
+ - **No cloud, no accounts, no network calls.** Everything stays on your Mac and your desk.
86
+ - **It never slows Claude down.** The hook exits immediately (about 50 ms) even if the helper isn't running.
87
+
88
+ ## Write your own device
89
+
90
+ Devices speak a tiny JSON-lines protocol. Connect to `~/.pitwall/devices.sock`, say `hello`, and you'll get a `board` every time something changes. Send `focus` to switch tabs. Everything is in **[docs/protocol.md](docs/protocol.md)**, and no pitwall code needs reading. Other agents can report sessions the same way, through `~/.pitwall/pitwall.sock`.
91
+
92
+ ## Roadmap
93
+
94
+ - More devices: Stream Deck (one key per session), Logitech MX Creative Keypad
95
+ - More agents: Codex, Copilot CLI, Gemini CLI
96
+ - More terminals: Ghostty, WezTerm, tmux
97
+
98
+ ## Skills for your agent
99
+
100
+ The repo ships two [Claude Code skills](skills/): `pitwall` (set up, check, troubleshoot) and `pitwall-device` (build a new device on the protocol). Copy them into `~/.claude/skills/` to use them.
101
+
102
+ ## Uninstall
103
+
104
+ ```bash
105
+ pitwall service uninstall
106
+ pitwall uninstall claude-code
107
+ pipx uninstall pitwall-agents
108
+ ```
109
+
110
+ On the badge, delete `apps/pitwall` while it's in disk mode.
111
+
112
+ ## License
113
+
114
+ MIT © Siddhant Sinha
@@ -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)