gini-toolkit 6.0.1.dev0__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 (278) hide show
  1. gini/__init__.py +12 -0
  2. gini/__main__.py +107 -0
  3. gini/_version.py +24 -0
  4. gini/agent/__init__.py +17 -0
  5. gini/agent/agent_gamemaster.py +140 -0
  6. gini/agent/api.py +291 -0
  7. gini/agent/ask.py +123 -0
  8. gini/agent/authoring.py +72 -0
  9. gini/agent/blackboard.py +114 -0
  10. gini/agent/contracts.py +142 -0
  11. gini/agent/domains.py +91 -0
  12. gini/agent/embed.py +123 -0
  13. gini/agent/gamemaster.py +256 -0
  14. gini/agent/kb.py +148 -0
  15. gini/agent/lesson_resolver.py +261 -0
  16. gini/agent/llm/__init__.py +5 -0
  17. gini/agent/llm/backend.py +43 -0
  18. gini/agent/llm/fake.py +25 -0
  19. gini/agent/llm/ollama.py +206 -0
  20. gini/agent/loop.py +258 -0
  21. gini/agent/mcp_server.py +86 -0
  22. gini/agent/meaning.py +225 -0
  23. gini/agent/mission.py +210 -0
  24. gini/agent/mission_controller.py +208 -0
  25. gini/agent/narration.py +116 -0
  26. gini/agent/notifier.py +86 -0
  27. gini/agent/personas.py +79 -0
  28. gini/agent/reasoning.py +172 -0
  29. gini/agent/recall.py +248 -0
  30. gini/agent/session.py +79 -0
  31. gini/agent/teaching_center.py +482 -0
  32. gini/agent/tools/__init__.py +3 -0
  33. gini/agent/tools/registry.py +193 -0
  34. gini/agent/twin/__init__.py +28 -0
  35. gini/agent/twin/authoring.py +71 -0
  36. gini/agent/twin/contracts.py +54 -0
  37. gini/agent/twin/dialectic.py +189 -0
  38. gini/agent/twin/harness.py +93 -0
  39. gini/agent/twin/justify.py +156 -0
  40. gini/agent/twin/learner.py +64 -0
  41. gini/agent/twin/mission.py +60 -0
  42. gini/agent/twin/os_coach.py +79 -0
  43. gini/agent/twin/salience.py +30 -0
  44. gini/agent/understand.py +250 -0
  45. gini/agent/verifiers.py +106 -0
  46. gini/agent/wizard.py +178 -0
  47. gini/agent/xv6_pack.py +74 -0
  48. gini/app/__init__.py +3 -0
  49. gini/app/context.py +368 -0
  50. gini/app/paths.py +121 -0
  51. gini/data/README.md +21 -0
  52. gini/domain/__init__.py +9 -0
  53. gini/domain/assembly.py +209 -0
  54. gini/domain/authoring.py +353 -0
  55. gini/domain/blueprints.py +5 -0
  56. gini/domain/capabilities.py +177 -0
  57. gini/domain/catalog.py +85 -0
  58. gini/domain/certify.py +201 -0
  59. gini/domain/compose.py +413 -0
  60. gini/domain/composition.py +88 -0
  61. gini/domain/concepts.py +383 -0
  62. gini/domain/connection_rules.py +269 -0
  63. gini/domain/constraints.py +153 -0
  64. gini/domain/content.py +59 -0
  65. gini/domain/cpu_journey.py +89 -0
  66. gini/domain/devices.py +747 -0
  67. gini/domain/diagnose.py +201 -0
  68. gini/domain/element_guide.py +327 -0
  69. gini/domain/explain.py +90 -0
  70. gini/domain/fingerprint.py +201 -0
  71. gini/domain/firewall.py +34 -0
  72. gini/domain/flowlog.py +61 -0
  73. gini/domain/flowtable.py +179 -0
  74. gini/domain/fragment_yaml.py +230 -0
  75. gini/domain/fragments.py +169 -0
  76. gini/domain/games/__init__.py +2 -0
  77. gini/domain/games/paging_games.py +119 -0
  78. gini/domain/games/policy_game.py +86 -0
  79. gini/domain/games/process_game.py +48 -0
  80. gini/domain/games/thrash_game.py +75 -0
  81. gini/domain/games/translate_game.py +60 -0
  82. gini/domain/games/trap_game.py +86 -0
  83. gini/domain/grader.py +155 -0
  84. gini/domain/grouping.py +67 -0
  85. gini/domain/legality.py +103 -0
  86. gini/domain/lesson.py +241 -0
  87. gini/domain/lexicon.py +150 -0
  88. gini/domain/machine_state.py +410 -0
  89. gini/domain/missions/networking/basic-lan.yaml +32 -0
  90. gini/domain/missions/networking/cache-in-front.yaml +23 -0
  91. gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
  92. gini/domain/missions/networking/drive-load.yaml +20 -0
  93. gini/domain/missions/networking/fix-the-address.yaml +75 -0
  94. gini/domain/missions/networking/fix-the-lan.yaml +43 -0
  95. gini/domain/missions/networking/inspect-flows.yaml +16 -0
  96. gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
  97. gini/domain/missions/networking/least-privilege.yaml +21 -0
  98. gini/domain/missions/networking/load-balanced-web.yaml +29 -0
  99. gini/domain/missions/networking/observe-it.yaml +24 -0
  100. gini/domain/missions/networking/put-in-vpc.yaml +30 -0
  101. gini/domain/missions/networking/reachability-boundary.yaml +56 -0
  102. gini/domain/missions/networking/sdn-reactive.yaml +35 -0
  103. gini/domain/missions/networking/send-request.yaml +19 -0
  104. gini/domain/missions/networking/serverless-api.yaml +25 -0
  105. gini/domain/missions/networking/service-chain.yaml +33 -0
  106. gini/domain/missions/os/lottery-fix.yaml +19 -0
  107. gini/domain/missions/os/priority-fix.yaml +24 -0
  108. gini/domain/missions.py +111 -0
  109. gini/domain/modulechain.py +36 -0
  110. gini/domain/objectives.py +488 -0
  111. gini/domain/os_zoo.py +79 -0
  112. gini/domain/paging_sim.py +141 -0
  113. gini/domain/pricing.py +199 -0
  114. gini/domain/probes.py +226 -0
  115. gini/domain/profile.py +142 -0
  116. gini/domain/recipes.py +738 -0
  117. gini/domain/riders.py +309 -0
  118. gini/domain/router_modules.py +224 -0
  119. gini/domain/routetable.py +67 -0
  120. gini/domain/scoring.py +76 -0
  121. gini/domain/staging.py +122 -0
  122. gini/domain/syscall_builder.py +144 -0
  123. gini/domain/topic_cloud.py +62 -0
  124. gini/domain/topology.py +213 -0
  125. gini/domain/vocabulary.py +51 -0
  126. gini/domain/xv6.py +808 -0
  127. gini/domain/xv6_fs.py +250 -0
  128. gini/domain/xv6_runner.py +113 -0
  129. gini/domain/xv6_vm.py +385 -0
  130. gini/gloader.py +17 -0
  131. gini/runtime/__init__.py +18 -0
  132. gini/runtime/cloudfabric_agent.py +370 -0
  133. gini/runtime/console.py +68 -0
  134. gini/runtime/control.py +70 -0
  135. gini/runtime/frame.py +138 -0
  136. gini/runtime/gbridge.py +638 -0
  137. gini/runtime/grouter.py +223 -0
  138. gini/runtime/hostsim.py +90 -0
  139. gini/runtime/shuttle.py +348 -0
  140. gini/runtime/switch.py +109 -0
  141. gini/runtime/transport.py +77 -0
  142. gini/runtime/xv6_bridge.py +312 -0
  143. gini/server/__init__.py +22 -0
  144. gini/server/__main__.py +74 -0
  145. gini/server/app.py +140 -0
  146. gini/server/auth.py +82 -0
  147. gini/server/policy.py +57 -0
  148. gini/server/session.py +23 -0
  149. gini/services/__init__.py +15 -0
  150. gini/services/boardflash.py +248 -0
  151. gini/services/boardsetup.py +374 -0
  152. gini/services/cloud_catalog.py +143 -0
  153. gini/services/compiler.py +1858 -0
  154. gini/services/discovery.py +324 -0
  155. gini/services/gloader.py +183 -0
  156. gini/services/orchestrator.py +1460 -0
  157. gini/services/persistence.py +28 -0
  158. gini/services/probe_runner.py +149 -0
  159. gini/services/project.py +217 -0
  160. gini/services/remote.py +93 -0
  161. gini/services/rider_runner.py +96 -0
  162. gini/services/rider_session.py +171 -0
  163. gini/services/shadow_store.py +52 -0
  164. gini/services/terminal.py +45 -0
  165. gini/setup/__init__.py +17 -0
  166. gini/setup/cli.py +109 -0
  167. gini/setup/images.py +33 -0
  168. gini/setup/marker.py +43 -0
  169. gini/setup/runtime.py +69 -0
  170. gini/ui/__init__.py +3 -0
  171. gini/ui/assets/app_icon.icns +0 -0
  172. gini/ui/assets/app_icon.ico +0 -0
  173. gini/ui/assets/app_icon.png +0 -0
  174. gini/ui/assets/app_icon_1024.png +0 -0
  175. gini/ui/assets/cue/_w.txt +1 -0
  176. gini/ui/assets/cue/ai.png +0 -0
  177. gini/ui/assets/cue/canvas.png +0 -0
  178. gini/ui/assets/cue/cloud.png +0 -0
  179. gini/ui/assets/cue/cost.png +0 -0
  180. gini/ui/assets/cue/dark/ai.png +0 -0
  181. gini/ui/assets/cue/dark/canvas.png +0 -0
  182. gini/ui/assets/cue/dark/cloud.png +0 -0
  183. gini/ui/assets/cue/dark/cost.png +0 -0
  184. gini/ui/assets/cue/dark/metrics.png +0 -0
  185. gini/ui/assets/cue/dark/router.png +0 -0
  186. gini/ui/assets/cue/dark/run.png +0 -0
  187. gini/ui/assets/cue/dark/serverless.png +0 -0
  188. gini/ui/assets/cue/dark/settings.png +0 -0
  189. gini/ui/assets/cue/dark/welcome.png +0 -0
  190. gini/ui/assets/cue/dark/wizard.png +0 -0
  191. gini/ui/assets/cue/ginibrand/ai.png +0 -0
  192. gini/ui/assets/cue/ginibrand/canvas.png +0 -0
  193. gini/ui/assets/cue/ginibrand/cloud.png +0 -0
  194. gini/ui/assets/cue/ginibrand/cost.png +0 -0
  195. gini/ui/assets/cue/ginibrand/metrics.png +0 -0
  196. gini/ui/assets/cue/ginibrand/router.png +0 -0
  197. gini/ui/assets/cue/ginibrand/run.png +0 -0
  198. gini/ui/assets/cue/ginibrand/serverless.png +0 -0
  199. gini/ui/assets/cue/ginibrand/settings.png +0 -0
  200. gini/ui/assets/cue/ginibrand/welcome.png +0 -0
  201. gini/ui/assets/cue/ginibrand/wizard.png +0 -0
  202. gini/ui/assets/cue/highcontrast/ai.png +0 -0
  203. gini/ui/assets/cue/highcontrast/canvas.png +0 -0
  204. gini/ui/assets/cue/highcontrast/cloud.png +0 -0
  205. gini/ui/assets/cue/highcontrast/cost.png +0 -0
  206. gini/ui/assets/cue/highcontrast/metrics.png +0 -0
  207. gini/ui/assets/cue/highcontrast/router.png +0 -0
  208. gini/ui/assets/cue/highcontrast/run.png +0 -0
  209. gini/ui/assets/cue/highcontrast/serverless.png +0 -0
  210. gini/ui/assets/cue/highcontrast/settings.png +0 -0
  211. gini/ui/assets/cue/highcontrast/welcome.png +0 -0
  212. gini/ui/assets/cue/highcontrast/wizard.png +0 -0
  213. gini/ui/assets/cue/light/ai.png +0 -0
  214. gini/ui/assets/cue/light/canvas.png +0 -0
  215. gini/ui/assets/cue/light/cloud.png +0 -0
  216. gini/ui/assets/cue/light/cost.png +0 -0
  217. gini/ui/assets/cue/light/metrics.png +0 -0
  218. gini/ui/assets/cue/light/router.png +0 -0
  219. gini/ui/assets/cue/light/run.png +0 -0
  220. gini/ui/assets/cue/light/serverless.png +0 -0
  221. gini/ui/assets/cue/light/settings.png +0 -0
  222. gini/ui/assets/cue/light/welcome.png +0 -0
  223. gini/ui/assets/cue/light/wizard.png +0 -0
  224. gini/ui/assets/cue/metrics.png +0 -0
  225. gini/ui/assets/cue/router.png +0 -0
  226. gini/ui/assets/cue/run.png +0 -0
  227. gini/ui/assets/cue/serverless.png +0 -0
  228. gini/ui/assets/cue/settings.png +0 -0
  229. gini/ui/assets/cue/welcome.png +0 -0
  230. gini/ui/assets/cue/wizard.png +0 -0
  231. gini/ui/assistant.py +2111 -0
  232. gini/ui/author_dialog.py +184 -0
  233. gini/ui/board_dialog.py +247 -0
  234. gini/ui/branding.py +21 -0
  235. gini/ui/canvas.py +2007 -0
  236. gini/ui/chat_panel.py +7 -0
  237. gini/ui/cpu_journey.py +212 -0
  238. gini/ui/cpu_lab.py +306 -0
  239. gini/ui/cue_cards.py +214 -0
  240. gini/ui/dashboard.py +222 -0
  241. gini/ui/diagnose_game.py +336 -0
  242. gini/ui/fingerprint_lab.py +219 -0
  243. gini/ui/flash_dialog.py +244 -0
  244. gini/ui/flow_layout.py +63 -0
  245. gini/ui/fragment_manager.py +1415 -0
  246. gini/ui/game_catalog.py +184 -0
  247. gini/ui/game_renderers.py +340 -0
  248. gini/ui/games_lab.py +90 -0
  249. gini/ui/inspector.py +1055 -0
  250. gini/ui/live_metrics.py +130 -0
  251. gini/ui/machine_lab.py +1412 -0
  252. gini/ui/main_window.py +3153 -0
  253. gini/ui/memory_lab.py +371 -0
  254. gini/ui/mission_panel.py +302 -0
  255. gini/ui/mode_indicator.py +227 -0
  256. gini/ui/palette.py +112 -0
  257. gini/ui/peripherals.py +218 -0
  258. gini/ui/process_tree.py +130 -0
  259. gini/ui/reset_dialog.py +179 -0
  260. gini/ui/router_lab.py +776 -0
  261. gini/ui/run_button.py +183 -0
  262. gini/ui/settings_dialog.py +234 -0
  263. gini/ui/signin_dialog.py +111 -0
  264. gini/ui/storage_lab.py +219 -0
  265. gini/ui/syscall_builder.py +235 -0
  266. gini/ui/syscall_lab.py +152 -0
  267. gini/ui/theme/__init__.py +5 -0
  268. gini/ui/theme/icons.py +145 -0
  269. gini/ui/theme/manager.py +291 -0
  270. gini/ui/theme/tokens.py +194 -0
  271. gini/ui/trap_lab.py +270 -0
  272. gini/ui/worker_host.py +102 -0
  273. gini/ui/zoo_lab.py +112 -0
  274. gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
  275. gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
  276. gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
  277. gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
  278. gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,638 @@
1
+ """gbridge relay: attach real GINI32 (ESP32) boards to the emulated fabric.
2
+
3
+ A GINI32 board is a real radio sitting on the physical LAN; the fabric is a set of
4
+ UDP endpoints on Docker's `gini` bridge. The board cannot reach those endpoints
5
+ directly (router containers publish no ports, and on macOS Docker runs in a VM), and
6
+ the C gRouter only ever replies to the peer address it was configured with, so a
7
+ board on DHCP could not be addressed anyway.
8
+
9
+ This relay closes both gaps. It is one container on the `gini` network with ONE
10
+ published UDP port. Boards send to that port on the host's LAN address; the relay
11
+ learns each board's address from its own traffic and shuttles frames to the router
12
+ interface that the board's canvas element is wired to.
13
+
14
+ phone --802.11--> [board: gBridge] --G32/UDP--> host:5555 --> [relay] --eth/UDP--> [gRouter tun]
15
+ \\________ physical LAN ________/ \\__ docker gini net __/
16
+
17
+ Framing. On the *fabric* hop the payload is a bare Ethernet frame, exactly as every
18
+ other GINI link (see transport.Port) -- nothing here changes that contract. On the
19
+ *board* hop each datagram carries a fixed 24-byte header so that one published port
20
+ can serve many boards and so a board can announce itself before it has traffic:
21
+
22
+ off 0 magic 3 b"G32"
23
+ off 3 version 1 = 1
24
+ off 4 type 1 HELLO | HELLO_ACK | FRAME | KEEPALIVE
25
+ off 5 rsv 3 zero
26
+ off 8 board 16 board id, NUL-padded ASCII
27
+ off 24 payload .. (FRAME: the Ethernet frame; HELLO: optional ASCII info)
28
+
29
+ Fixed offsets keep the ESP32 side trivial. The relay is deliberately permissive on
30
+ ingress (any source address may speak for a board it names) and strict on egress
31
+ (frames only go to a board that has checked in) -- the same asymmetry the gRouter's
32
+ own tun_recvfrom uses, for the same reason: boards move, and dropping on a mismatch
33
+ silently breaks the link.
34
+
35
+ Run: GBRIDGE_CONFIG='{"listen_port":5555,"boards":[...]}' python -m dataplane.gbridge
36
+ """
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import os
41
+ import socket
42
+ import sys
43
+ import threading
44
+ import time
45
+
46
+ from .control import maybe_start
47
+ from .transport import Port
48
+
49
+ MAGIC = b"G32"
50
+ VERSION = 1
51
+ HDR_LEN = 24
52
+ ID_LEN = 16
53
+
54
+ T_HELLO = 0x01
55
+ T_HELLO_ACK = 0x02
56
+ T_FRAME = 0x03
57
+ T_KEEPALIVE = 0x04
58
+ # --- claiming: a board belongs to exactly one laptop ------------------------- #
59
+ T_CLAIM = 0x05 # laptop -> board: "you are mine"; payload: owner=<laptop id>
60
+ T_CLAIM_ACK = 0x06 # board -> laptop: the claim outcome; payload: owner=<id> [busy=1]
61
+ T_RELEASE = 0x07 # laptop -> board: "you are free again"
62
+ T_BLINK = 0x08 # laptop -> board: flash the LED so a human can find it
63
+
64
+ _TYPE_NAME = {T_HELLO: "HELLO", T_HELLO_ACK: "HELLO_ACK",
65
+ T_FRAME: "FRAME", T_KEEPALIVE: "KEEPALIVE",
66
+ T_CLAIM: "CLAIM", T_CLAIM_ACK: "CLAIM_ACK",
67
+ T_RELEASE: "RELEASE", T_BLINK: "BLINK"}
68
+
69
+ # The board's keepalive period (gbridge_config.h: GB_KEEPALIVE_MS). Also the yardstick
70
+ # for "is this datagram late?" — the board decides the real cadence, and nothing breaks
71
+ # if the two drift apart.
72
+ BOARD_KEEPALIVE_S = 5.0
73
+
74
+ # How many keepalives may go missing before we call a board offline.
75
+ #
76
+ # Expressed as a MULTIPLE rather than a flat number of seconds, so the relationship to
77
+ # the board's cadence cannot silently rot if either is retuned. Three missed hellos is
78
+ # the usual convention for exactly this trade-off (OSPF's dead interval is 4x hello,
79
+ # RIP's timeout 6x): enough to ride out the two-in-a-row losses that a busy 2.4 GHz
80
+ # channel produces, few enough that unplugging a board is noticed while the student is
81
+ # still looking at the screen.
82
+ #
83
+ # This was 30 s — SIX missed keepalives — which meant pulling a board's power left it
84
+ # reading "connected" for over half a minute. Nothing needed that much slack; it was
85
+ # just a round number chosen before the keepalive interval existed.
86
+ OFFLINE_GRACE = 3.2 # 3 missed keepalives, plus a little jitter
87
+ OFFLINE_AFTER = BOARD_KEEPALIVE_S * OFFLINE_GRACE # 16 s
88
+
89
+
90
+ def encode(msg_type: int, board_id: str, payload: bytes = b"") -> bytes:
91
+ """Wrap a payload in the board-hop header."""
92
+ bid = board_id.encode("ascii", "ignore")[:ID_LEN]
93
+ return (MAGIC + bytes([VERSION, msg_type, 0, 0, 0])
94
+ + bid.ljust(ID_LEN, b"\x00") + payload)
95
+
96
+
97
+ def decode(data: bytes) -> tuple[int, str, bytes] | None:
98
+ """Parse a board-hop datagram -> (type, board_id, payload), or None if not ours."""
99
+ if len(data) < HDR_LEN or data[:3] != MAGIC or data[3] != VERSION:
100
+ return None
101
+ msg_type = data[4]
102
+ board_id = data[8:8 + ID_LEN].rstrip(b"\x00").decode("ascii", "ignore")
103
+ return msg_type, board_id, data[HDR_LEN:]
104
+
105
+
106
+ class BoardLink:
107
+ """One canvas GINI32 element: its fabric port plus wherever the real board is."""
108
+
109
+ def __init__(self, cfg: dict) -> None:
110
+ self.board_id: str = cfg["board_id"]
111
+ self.name: str = cfg.get("name", self.board_id)
112
+ self.port = Port.from_cfg(cfg["fabric"], name=self.board_id)
113
+ # The canvas is the source of truth for the board's fabric-side identity; it is
114
+ # handed to the board in the HELLO_ACK so the firmware needs no network config.
115
+ self.ip: str = cfg.get("ip", "")
116
+ self.mask: str = cfg.get("mask", "255.255.255.0")
117
+ self.gw: str = cfg.get("gw", "")
118
+ self.mac: str = cfg.get("mac", "")
119
+ self.mtu: int = int(cfg.get("mtu", 1400))
120
+ self.mode: str = cfg.get("mode", "nat") # nat | routed — see netcfg()
121
+ self.label: str = cfg.get("label", self.board_id) # the canvas element's name
122
+ self.physical_subnet: str = cfg.get("physical_subnet", "")
123
+ self.ap_ssid: str = cfg.get("ap_ssid", "")
124
+ self.ap_pass: str = cfg.get("ap_pass", "")
125
+ # Resolver for devices on the board's radio; "" when the canvas has no Internet
126
+ # element. Rides the HELLO_ACK like everything else, so drawing or deleting the
127
+ # Internet element reaches a RUNNING board on its next keepalive — no reflash,
128
+ # no rejoin of the topology.
129
+ self.dns: str = cfg.get("dns", "")
130
+ # --- reported BY the board (telemetry in each keepalive) --------------- #
131
+ self.channel: int = 0 # forced by the uplink in APSTA; observed only
132
+ self.rssi: int = 0 # uplink signal strength, dBm
133
+ self.uplink: str = "" # the lab Wi-Fi the board joined
134
+ self.clients: list[dict] = [] # devices on this board's hotspot right now
135
+ self.addr: tuple[str, int] | None = None # learned from the board's own traffic
136
+ self.last_seen: float = 0.0
137
+ self.rx = 0 # frames board -> fabric
138
+ self.tx = 0 # frames fabric -> board
139
+ self.dropped = 0 # fabric -> board while the board was unknown
140
+ # --- evidence for "why is this flaky?", cheap enough to always collect ----
141
+ # The board keepalives every 5s, so the arrival pattern is a free heartbeat.
142
+ # These three separate the candidate causes instead of leaving us to guess:
143
+ # worst_gap_s long silences => the board or the radio went away (RSSI, power)
144
+ # addr_changes the source we learned for this board moved. Docker's published
145
+ # port is a stateful translation, so a re-map here breaks the
146
+ # RETURN path until the board speaks again — invisible from the
147
+ # board, which sees its own transmits succeed.
148
+ # late datagrams arriving more than 2x the keepalive interval apart
149
+ self.worst_gap_s = 0.0
150
+ self.addr_changes = 0
151
+ self.late = 0
152
+
153
+ def netcfg(self) -> bytes:
154
+ """The board's fabric-side settings, as the HELLO_ACK payload.
155
+
156
+ `mode` matters to the firmware, not just to the compiler: in `nat` the board
157
+ translates its devices onto `ip`, while in `routed` it must forward them
158
+ untouched so the emulated side can address them directly. Sending it here
159
+ keeps the canvas the single source of truth for that decision too.
160
+ """
161
+ parts = [f"ip={self.ip}", f"mask={self.mask}", f"gw={self.gw}",
162
+ f"mac={self.mac}", f"mtu={self.mtu}", f"mode={self.mode}"]
163
+ # The hotspot is a canvas decision too: the board raises whatever we name
164
+ # here, so a lab can be renamed without touching hardware. `apnet` is the
165
+ # subnet it serves — the board takes .1 and hands out the rest by DHCP.
166
+ if self.physical_subnet:
167
+ parts.append(f"apnet={self.physical_subnet}")
168
+ if self.ap_ssid:
169
+ parts.append(f"apssid={self.ap_ssid}")
170
+ if self.ap_pass:
171
+ parts.append(f"appass={self.ap_pass}")
172
+ # ALWAYS sent, even empty. "dns=" with no value is the instruction to stop
173
+ # offering a resolver — which is what deleting the Internet element means. If it
174
+ # were omitted when empty, a board told about DNS once would keep handing it out
175
+ # forever, promising name resolution through a topology that no longer has a way
176
+ # out. Absent and empty must not mean the same thing here.
177
+ parts.append(f"dns={self.dns}")
178
+ return " ".join(parts).encode("ascii")
179
+
180
+ def note_telemetry(self, payload: bytes) -> None:
181
+ """Absorb what the board reports in a keepalive.
182
+
183
+ The board sends its FULL client list every time rather than deltas, so a
184
+ missed datagram cannot leave us with a phantom device on the canvas — the
185
+ next keepalive is authoritative.
186
+ Format: ch=6 rssi=-71 up=lab-wifi c=aa:bb:cc:dd:ee:ff/10.0.9.2 c=...
187
+ """
188
+ clients: list[dict] = []
189
+ for tok in payload.decode("ascii", "ignore").split():
190
+ key, _, val = tok.partition("=")
191
+ if key == "ch" and val.isdigit():
192
+ self.channel = int(val)
193
+ elif key == "rssi":
194
+ try:
195
+ self.rssi = int(val)
196
+ except ValueError:
197
+ pass
198
+ elif key == "up":
199
+ self.uplink = val
200
+ elif key == "c" and "/" in val:
201
+ mac, _, ip = val.partition("/")
202
+ clients.append({"mac": mac, "ip": ip})
203
+ if any(t.startswith("c=") or t.startswith("sta=")
204
+ for t in payload.decode("ascii", "ignore").split()):
205
+ self.clients = clients # only replace when the board actually reported
206
+
207
+ @property
208
+ def online(self) -> bool:
209
+ return self.addr is not None and (time.time() - self.last_seen) < OFFLINE_AFTER
210
+
211
+ def seen(self, addr: tuple[str, int]) -> bool:
212
+ """Record that we heard from the board. Returns True if its address moved."""
213
+ moved = self.addr is not None and self.addr != addr
214
+ now = time.time()
215
+ # The board is on a fixed 5s keepalive, so the arrival pattern measures the
216
+ # whole path for free. Record the shape of it rather than only the last event:
217
+ # a fault that happened two minutes ago leaves no trace in `last_seen`, and by
218
+ # the time anyone looks, the link is healthy again and the evidence is gone.
219
+ if self.last_seen:
220
+ gap = now - self.last_seen
221
+ if gap > self.worst_gap_s:
222
+ self.worst_gap_s = gap
223
+ if gap > 2 * BOARD_KEEPALIVE_S:
224
+ self.late += 1
225
+ if moved:
226
+ self.addr_changes += 1
227
+ self.addr = addr
228
+ self.last_seen = now
229
+ return moved
230
+
231
+
232
+ class SeenBoard:
233
+ """A board that has spoken to us but is not (yet) wired to a canvas element.
234
+
235
+ Kept so gBuilder can offer it for claiming. Boards owned by ANOTHER laptop are
236
+ never recorded — they are not ours to show, and listing them would invite exactly
237
+ the cross-claiming the design exists to prevent.
238
+ """
239
+
240
+ def __init__(self, name: str, mac: str, owner: str) -> None:
241
+ self.name = name
242
+ self.mac = mac
243
+ self.owner = owner # "" = unclaimed and available
244
+ self.addr: tuple[str, int] | None = None
245
+ self.last_seen = 0.0
246
+
247
+ @property
248
+ def online(self) -> bool:
249
+ return (time.time() - self.last_seen) < OFFLINE_AFTER
250
+
251
+ def as_dict(self) -> dict:
252
+ return {"name": self.name, "mac": self.mac, "owner": self.owner,
253
+ "online": self.online, "last_seen": self.last_seen,
254
+ "claimed": bool(self.owner)}
255
+
256
+
257
+ class GBridge:
258
+ def __init__(self, cfg: dict) -> None:
259
+ self.name = cfg.get("name", "gbridge")
260
+ self.listen_port = int(cfg.get("listen_port", 5555))
261
+ # Who we are to a board. A board that has been claimed by a different laptop
262
+ # ignores us entirely, and we ignore it — that is the whole point.
263
+ self.laptop_id: str = cfg.get("laptop_id", "")
264
+ self.seen: dict[str, SeenBoard] = {} # board name -> availability
265
+ self._pending: dict[str, str] = {} # board name -> "claim" | "release" | "blink"
266
+ self.log = bool(cfg.get("log", False))
267
+ self.links: dict[str, BoardLink] = {}
268
+ for b in cfg.get("boards", []):
269
+ link = BoardLink(b)
270
+ self.links[link.board_id] = link
271
+ self.unknown = 0 # datagrams naming a board that is not on the canvas
272
+ self.malformed = 0 # datagrams that are not G32 at all
273
+ # Boards heard on the network but claimed by a DIFFERENT laptop: board -> owner.
274
+ # Deliberately not offered for use, but reported, so "I can see it and you
275
+ # can't" has an explanation instead of looking like broken hardware.
276
+ self.foreign: dict[str, str] = {}
277
+ self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
278
+ self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
279
+ self.sock.bind(("0.0.0.0", self.listen_port))
280
+ self.sock.setblocking(False)
281
+ self._ctrl = maybe_start(self.name, self._control, f"gbridge {self.name}")
282
+
283
+ # ---------------- control console (`boards`, `stats`) ---------------- #
284
+
285
+ def _control(self, cmd: str) -> str:
286
+ cmd = (cmd or "").strip().lower()
287
+ if cmd in ("help", "?", "h"):
288
+ return "commands: boards, stats, help, exit"
289
+ if cmd in ("boards", "board", "ls"):
290
+ if not self.links:
291
+ return "(no GINI32 elements on the canvas)"
292
+ rows = []
293
+ for l in self.links.values():
294
+ where = f"{l.addr[0]}:{l.addr[1]}" if l.addr else "-"
295
+ age = f"{time.time() - l.last_seen:.0f}s ago" if l.last_seen else "never"
296
+ rows.append(f" {l.board_id:<16} {'online ' if l.online else 'OFFLINE'} "
297
+ f"{where:<22} last seen {age}")
298
+ return "\n".join(rows)
299
+ if cmd in ("stats", "counters"):
300
+ rows = [f" listening on udp/{self.listen_port}",
301
+ f" unknown board id: {self.unknown} malformed: {self.malformed}"]
302
+ for l in self.links.values():
303
+ rows.append(f" {l.board_id:<16} rx={l.rx} tx={l.tx} dropped={l.dropped}")
304
+ return "\n".join(rows)
305
+ return f"unknown command: {cmd} (try 'help')"
306
+
307
+ def status(self) -> dict:
308
+ """Machine-readable board table (gBuilder polls this to show board health)."""
309
+ return {
310
+ "listen_port": self.listen_port,
311
+ "laptop_id": self.laptop_id,
312
+ # Every board we are allowed to see: ours, plus any that are unclaimed.
313
+ # Boards owned by another laptop never appear here.
314
+ "available": self.available(),
315
+ # Heard, but owned by another laptop. The UI needs this to tell "nothing is
316
+ # out there" apart from "it is out there and not yours".
317
+ "foreign": [{"board_id": b, "owner": o} for b, o in self.foreign.items()],
318
+ "boards": [
319
+ {"board_id": l.board_id, "name": l.name, "label": l.label,
320
+ "online": l.online,
321
+ "addr": f"{l.addr[0]}:{l.addr[1]}" if l.addr else None,
322
+ "last_seen": l.last_seen, "rx": l.rx, "tx": l.tx, "dropped": l.dropped,
323
+ # path health, cumulative for the run — see BoardLink.seen()
324
+ "worst_gap_s": round(l.worst_gap_s, 1), "late": l.late,
325
+ "addr_changes": l.addr_changes,
326
+ "ip": l.ip, "mode": l.mode, "physical_subnet": l.physical_subnet,
327
+ "ap_ssid": l.ap_ssid,
328
+ # observed from the hardware
329
+ "channel": l.channel, "rssi": l.rssi, "uplink": l.uplink,
330
+ "clients": list(l.clients)}
331
+ for l in self.links.values()
332
+ ],
333
+ }
334
+
335
+ # ---------------- data path ---------------- #
336
+
337
+ @staticmethod
338
+ def _kv(payload: bytes) -> dict:
339
+ out = {}
340
+ for tok in payload.decode("ascii", "ignore").split():
341
+ k, _, v = tok.partition("=")
342
+ if k:
343
+ out[k] = v
344
+ return out
345
+
346
+ def _note_seen(self, board_id: str, payload: bytes,
347
+ addr: tuple[str, int]) -> str | None:
348
+ """Record a board's availability. Returns its owner, or None if not ours to see.
349
+
350
+ A board owned by another laptop is deliberately NOT recorded: we must not offer
351
+ it for claiming, and we must not answer it.
352
+ """
353
+ kv = self._kv(payload)
354
+ owner = kv.get("owner", "")
355
+ if owner and self.laptop_id and owner != self.laptop_id:
356
+ # Someone else's board: we neither answer it nor list it. But staying out
357
+ # of it must not mean pretending it is not there — a board orphaned by an
358
+ # owner id that no longer exists is otherwise indistinguishable from a dead
359
+ # board, and the only way out (USB `unpair`) is the one nobody thinks to
360
+ # try. Count it, and say so once per board.
361
+ if board_id not in self.foreign:
362
+ print(f"[{self.name}] {board_id} is on the air but claimed by "
363
+ f"{owner!r}, not us ({self.laptop_id!r}) — ignoring it. "
364
+ f"Free it with: gini32 unpair, then type `unpair`",
365
+ file=sys.stderr, flush=True)
366
+ self.foreign[board_id] = owner
367
+ return None
368
+ s = self.seen.get(board_id)
369
+ if s is None:
370
+ s = self.seen[board_id] = SeenBoard(board_id, kv.get("mac", ""), owner)
371
+ print(f"[{self.name}] board {board_id} "
372
+ f"{'is available to claim' if not owner else 'checked in (ours)'}"
373
+ f" ({kv.get('mac', '?')})", file=sys.stderr, flush=True)
374
+ s.owner = owner
375
+ if kv.get("mac"):
376
+ s.mac = kv["mac"]
377
+ s.addr = addr
378
+ s.last_seen = time.time()
379
+ return owner
380
+
381
+ def _from_board(self, data: bytes, addr: tuple[str, int]) -> None:
382
+ parsed = decode(data)
383
+ if parsed is None:
384
+ self.malformed += 1
385
+ return
386
+ msg_type, board_id, payload = parsed
387
+
388
+ # Availability + ownership come first: they decide whether this board is even
389
+ # ours to talk to, regardless of whether the canvas has a role for it.
390
+ if msg_type in (T_HELLO, T_KEEPALIVE, T_CLAIM_ACK):
391
+ owner = self._note_seen(board_id, payload, addr)
392
+ if owner is None:
393
+ return # claimed by another laptop — silence
394
+ if msg_type == T_CLAIM_ACK:
395
+ kv = self._kv(payload)
396
+ if kv.get("busy"):
397
+ print(f"[{self.name}] {board_id} refused: already claimed by "
398
+ f"{kv.get('owner', 'another laptop')}", file=sys.stderr, flush=True)
399
+ else:
400
+ print(f"[{self.name}] {board_id} is now claimed by this laptop",
401
+ file=sys.stderr, flush=True)
402
+ return
403
+ # Anything queued by the UI (claim / release / blink) rides the next contact,
404
+ # because that is the moment we know where the board actually is.
405
+ want = self._pending.pop(board_id, None)
406
+ if want == "claim":
407
+ self._send_to(addr, T_CLAIM, board_id, f"owner={self.laptop_id}".encode())
408
+ return
409
+ if want == "release":
410
+ self._send_to(addr, T_RELEASE, board_id, b"")
411
+ self.seen.pop(board_id, None)
412
+ return
413
+ if want == "blink":
414
+ self._send_to(addr, T_BLINK, board_id, b"")
415
+
416
+ if not owner and self.laptop_id:
417
+ # An unclaimed board that this canvas has a ROLE for is claimed by
418
+ # USING it — drawing the element and pressing Run is the intent, and
419
+ # demanding a separate click would be ceremony. Names are baked and
420
+ # visible in the air, so only the laptop whose canvas names this board
421
+ # reaches here. An unclaimed board with no role stays merely visible,
422
+ # waiting to be adopted from the Inspector.
423
+ if board_id in self.links:
424
+ self._send_to(addr, T_CLAIM, board_id,
425
+ f"owner={self.laptop_id}".encode())
426
+ else:
427
+ return
428
+ # With no laptop identity configured, claiming is simply off and every
429
+ # board is served as before — a single-board bench should not need it.
430
+
431
+ link = self.links.get(board_id)
432
+ if link is None:
433
+ self.unknown += 1
434
+ if self.log:
435
+ print(f"[{self.name}] datagram from {addr[0]} names unknown board "
436
+ f"{board_id!r}; is it on the canvas?", file=sys.stderr)
437
+ return
438
+
439
+ moved = link.seen(addr)
440
+ if moved or msg_type == T_HELLO:
441
+ print(f"[{self.name}] board {board_id} at {addr[0]}:{addr[1]}"
442
+ f"{' (moved)' if moved else ''}", file=sys.stderr, flush=True)
443
+
444
+ if msg_type == T_KEEPALIVE and payload:
445
+ before = {c["mac"] for c in link.clients}
446
+ link.note_telemetry(payload)
447
+ after = {c["mac"] for c in link.clients}
448
+ for mac in after - before:
449
+ ip = next((c["ip"] for c in link.clients if c["mac"] == mac), "?")
450
+ print(f"[{self.name}] {link.board_id}: device joined {mac} ({ip})",
451
+ file=sys.stderr, flush=True)
452
+ for mac in before - after:
453
+ print(f"[{self.name}] {link.board_id}: device left {mac}",
454
+ file=sys.stderr, flush=True)
455
+
456
+ if msg_type in (T_HELLO, T_KEEPALIVE):
457
+ # Hand the board the fabric-side identity the canvas assigned it. A
458
+ # KEEPALIVE is answered for two reasons: it is the board's only proof
459
+ # that we are still here (an unanswered one makes it declare the link
460
+ # dead and re-run discovery), and replying with the current netcfg means
461
+ # a board picks up canvas edits without anyone touching the hardware.
462
+ self._to_board(link, T_HELLO_ACK, link.netcfg())
463
+ elif msg_type == T_FRAME and payload:
464
+ if link.rx == 0:
465
+ print(f"[{self.name}] first frame board->fabric from {board_id} "
466
+ f"({len(payload)}B) -> {link.port.peer_host}:{link.port.peer_port}",
467
+ file=sys.stderr, flush=True)
468
+ link.rx += 1
469
+ link.port.send(payload) # bare Ethernet onto the fabric
470
+
471
+ def _send_to(self, addr: tuple[str, int], msg_type: int, board_id: str,
472
+ payload: bytes) -> None:
473
+ """Send to a raw address — used before a board has a canvas role."""
474
+ try:
475
+ self.sock.sendto(encode(msg_type, board_id, payload), addr)
476
+ except OSError:
477
+ pass
478
+
479
+ # ---------------- claiming (called by gBuilder) ---------------- #
480
+
481
+ def available(self) -> list[dict]:
482
+ """Boards this laptop may claim, plus the ones it already owns."""
483
+ return [s.as_dict() for s in self.seen.values()]
484
+
485
+ def claim(self, board_name: str) -> bool:
486
+ """Queue a claim. It is sent on the board's next contact, which is also the
487
+ moment we know its current address — boards move."""
488
+ if not self.laptop_id:
489
+ return False
490
+ s = self.seen.get(board_name)
491
+ if s is None:
492
+ return False
493
+ if s.owner and s.owner != self.laptop_id:
494
+ return False # not ours to take
495
+ self._pending[board_name] = "claim"
496
+ return True
497
+
498
+ def release(self, board_name: str) -> bool:
499
+ s = self.seen.get(board_name)
500
+ if s is None or (s.owner and s.owner != self.laptop_id):
501
+ return False
502
+ self._pending[board_name] = "release"
503
+ return True
504
+
505
+ def blink(self, board_name: str) -> bool:
506
+ """Flash a board's LED so a human can tell which physical object it is."""
507
+ if board_name not in self.seen:
508
+ return False
509
+ self._pending[board_name] = "blink"
510
+ return True
511
+
512
+ def _to_board(self, link: BoardLink, msg_type: int, payload: bytes) -> None:
513
+ if link.addr is None:
514
+ link.dropped += 1
515
+ return
516
+ try:
517
+ self.sock.sendto(encode(msg_type, link.board_id, payload), link.addr)
518
+ if msg_type == T_FRAME:
519
+ link.tx += 1
520
+ except OSError:
521
+ link.dropped += 1
522
+
523
+ def _from_fabric(self, link: BoardLink, frame: bytes) -> None:
524
+ # The single most valuable line when a board "sees nothing": it proves the
525
+ # emulated side is actually sending, and separates "the router never tried"
526
+ # from "the board never got it".
527
+ if link.tx == 0 and link.dropped == 0:
528
+ where = f"{link.addr[0]}:{link.addr[1]}" if link.addr else "NOWHERE (board unknown)"
529
+ print(f"[{self.name}] first frame fabric->board for {link.board_id} "
530
+ f"({len(frame)}B, eth type 0x{frame[12]:02x}{frame[13]:02x}) -> {where}",
531
+ file=sys.stderr, flush=True)
532
+ self._to_board(link, T_FRAME, frame)
533
+
534
+ def run(self) -> None:
535
+ import selectors
536
+ print(f"[{self.name}] up on udp/{self.listen_port}, "
537
+ f"{len(self.links)} board(s): {', '.join(self.links) or '-'}",
538
+ file=sys.stderr, flush=True)
539
+ sel = selectors.DefaultSelector()
540
+ sel.register(self.sock, selectors.EVENT_READ, None)
541
+ for link in self.links.values():
542
+ sel.register(link.port.sock, selectors.EVENT_READ, link)
543
+ last_report = 0.0
544
+ last_counts: tuple = ()
545
+ while True:
546
+ # Periodic counters, so `docker compose logs gbridge` alone is enough to
547
+ # tell a live link from a dead one. Only prints when something changed.
548
+ now = time.time()
549
+ if now - last_report > 10:
550
+ counts = tuple((l.board_id, l.rx, l.tx, l.dropped, l.online)
551
+ for l in self.links.values())
552
+ if counts != last_counts:
553
+ for bid, rx, tx, dr, on in counts:
554
+ print(f"[{self.name}] {bid}: {'online' if on else 'OFFLINE'} "
555
+ f"rx={rx} tx={tx} dropped={dr}", file=sys.stderr, flush=True)
556
+ last_counts = counts
557
+ last_report = now
558
+ for key, _ in sel.select(timeout=0.5):
559
+ link: BoardLink | None = key.data
560
+ if link is None: # the published board-facing socket
561
+ while True:
562
+ try:
563
+ data, addr = self.sock.recvfrom(65535)
564
+ except BlockingIOError:
565
+ break
566
+ self._from_board(data, addr)
567
+ else: # a fabric port
568
+ while True:
569
+ frame = link.port.recv()
570
+ if frame is None:
571
+ break
572
+ self._from_fabric(link, frame)
573
+
574
+
575
+ def _serve_status(relay: "GBridge", port: int) -> None:
576
+ """Expose status() over HTTP so gBuilder can show real board state.
577
+
578
+ Deliberately tiny and read-only. gBuilder reaches this through
579
+ Orchestrator.board_status(), so if the relay later moves out of the container
580
+ the UI does not change — only where that method looks.
581
+ """
582
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
583
+
584
+ class Handler(BaseHTTPRequestHandler):
585
+ def _reply(self, obj) -> None:
586
+ body = json.dumps(obj).encode()
587
+ self.send_response(200)
588
+ self.send_header("Content-Type", "application/json")
589
+ self.send_header("Content-Length", str(len(body)))
590
+ self.end_headers()
591
+ self.wfile.write(body)
592
+
593
+ def do_GET(self): # noqa: N802
594
+ self._reply(relay.status())
595
+
596
+ def do_POST(self): # noqa: N802
597
+ """/claim, /release, /blink — the three things a human does to a board.
598
+
599
+ Each only queues: the action is delivered on the board's next contact,
600
+ which is the moment we actually know where it is.
601
+ """
602
+ path = self.path.strip("/").lower()
603
+ n = int(self.headers.get("Content-Length") or 0)
604
+ try:
605
+ arg = json.loads(self.rfile.read(n) or b"{}")
606
+ except ValueError:
607
+ arg = {}
608
+ board = str(arg.get("board", ""))
609
+ fn = {"claim": relay.claim, "release": relay.release,
610
+ "blink": relay.blink}.get(path)
611
+ if fn is None:
612
+ self._reply({"ok": False, "error": f"unknown action {path!r}"})
613
+ return
614
+ self._reply({"ok": bool(fn(board)), "board": board, "action": path})
615
+
616
+ def log_message(self, *a): # keep the log for boards
617
+ pass
618
+
619
+ try:
620
+ srv = ThreadingHTTPServer(("0.0.0.0", port), Handler)
621
+ except OSError as e:
622
+ print(f"[{relay.name}] status endpoint unavailable on {port}: {e}",
623
+ file=sys.stderr, flush=True)
624
+ return
625
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
626
+
627
+
628
+ def main() -> None:
629
+ cfg = json.loads(os.environ["GBRIDGE_CONFIG"])
630
+ relay = GBridge(cfg)
631
+ status_port = int(cfg.get("status_port", 0))
632
+ if status_port:
633
+ _serve_status(relay, status_port)
634
+ relay.run()
635
+
636
+
637
+ if __name__ == "__main__":
638
+ main()