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.
- gini/__init__.py +12 -0
- gini/__main__.py +107 -0
- gini/_version.py +24 -0
- gini/agent/__init__.py +17 -0
- gini/agent/agent_gamemaster.py +140 -0
- gini/agent/api.py +291 -0
- gini/agent/ask.py +123 -0
- gini/agent/authoring.py +72 -0
- gini/agent/blackboard.py +114 -0
- gini/agent/contracts.py +142 -0
- gini/agent/domains.py +91 -0
- gini/agent/embed.py +123 -0
- gini/agent/gamemaster.py +256 -0
- gini/agent/kb.py +148 -0
- gini/agent/lesson_resolver.py +261 -0
- gini/agent/llm/__init__.py +5 -0
- gini/agent/llm/backend.py +43 -0
- gini/agent/llm/fake.py +25 -0
- gini/agent/llm/ollama.py +206 -0
- gini/agent/loop.py +258 -0
- gini/agent/mcp_server.py +86 -0
- gini/agent/meaning.py +225 -0
- gini/agent/mission.py +210 -0
- gini/agent/mission_controller.py +208 -0
- gini/agent/narration.py +116 -0
- gini/agent/notifier.py +86 -0
- gini/agent/personas.py +79 -0
- gini/agent/reasoning.py +172 -0
- gini/agent/recall.py +248 -0
- gini/agent/session.py +79 -0
- gini/agent/teaching_center.py +482 -0
- gini/agent/tools/__init__.py +3 -0
- gini/agent/tools/registry.py +193 -0
- gini/agent/twin/__init__.py +28 -0
- gini/agent/twin/authoring.py +71 -0
- gini/agent/twin/contracts.py +54 -0
- gini/agent/twin/dialectic.py +189 -0
- gini/agent/twin/harness.py +93 -0
- gini/agent/twin/justify.py +156 -0
- gini/agent/twin/learner.py +64 -0
- gini/agent/twin/mission.py +60 -0
- gini/agent/twin/os_coach.py +79 -0
- gini/agent/twin/salience.py +30 -0
- gini/agent/understand.py +250 -0
- gini/agent/verifiers.py +106 -0
- gini/agent/wizard.py +178 -0
- gini/agent/xv6_pack.py +74 -0
- gini/app/__init__.py +3 -0
- gini/app/context.py +368 -0
- gini/app/paths.py +121 -0
- gini/data/README.md +21 -0
- gini/domain/__init__.py +9 -0
- gini/domain/assembly.py +209 -0
- gini/domain/authoring.py +353 -0
- gini/domain/blueprints.py +5 -0
- gini/domain/capabilities.py +177 -0
- gini/domain/catalog.py +85 -0
- gini/domain/certify.py +201 -0
- gini/domain/compose.py +413 -0
- gini/domain/composition.py +88 -0
- gini/domain/concepts.py +383 -0
- gini/domain/connection_rules.py +269 -0
- gini/domain/constraints.py +153 -0
- gini/domain/content.py +59 -0
- gini/domain/cpu_journey.py +89 -0
- gini/domain/devices.py +747 -0
- gini/domain/diagnose.py +201 -0
- gini/domain/element_guide.py +327 -0
- gini/domain/explain.py +90 -0
- gini/domain/fingerprint.py +201 -0
- gini/domain/firewall.py +34 -0
- gini/domain/flowlog.py +61 -0
- gini/domain/flowtable.py +179 -0
- gini/domain/fragment_yaml.py +230 -0
- gini/domain/fragments.py +169 -0
- gini/domain/games/__init__.py +2 -0
- gini/domain/games/paging_games.py +119 -0
- gini/domain/games/policy_game.py +86 -0
- gini/domain/games/process_game.py +48 -0
- gini/domain/games/thrash_game.py +75 -0
- gini/domain/games/translate_game.py +60 -0
- gini/domain/games/trap_game.py +86 -0
- gini/domain/grader.py +155 -0
- gini/domain/grouping.py +67 -0
- gini/domain/legality.py +103 -0
- gini/domain/lesson.py +241 -0
- gini/domain/lexicon.py +150 -0
- gini/domain/machine_state.py +410 -0
- gini/domain/missions/networking/basic-lan.yaml +32 -0
- gini/domain/missions/networking/cache-in-front.yaml +23 -0
- gini/domain/missions/networking/decouple-with-queue.yaml +31 -0
- gini/domain/missions/networking/drive-load.yaml +20 -0
- gini/domain/missions/networking/fix-the-address.yaml +75 -0
- gini/domain/missions/networking/fix-the-lan.yaml +43 -0
- gini/domain/missions/networking/inspect-flows.yaml +16 -0
- gini/domain/missions/networking/k8s-autoscale.yaml +27 -0
- gini/domain/missions/networking/least-privilege.yaml +21 -0
- gini/domain/missions/networking/load-balanced-web.yaml +29 -0
- gini/domain/missions/networking/observe-it.yaml +24 -0
- gini/domain/missions/networking/put-in-vpc.yaml +30 -0
- gini/domain/missions/networking/reachability-boundary.yaml +56 -0
- gini/domain/missions/networking/sdn-reactive.yaml +35 -0
- gini/domain/missions/networking/send-request.yaml +19 -0
- gini/domain/missions/networking/serverless-api.yaml +25 -0
- gini/domain/missions/networking/service-chain.yaml +33 -0
- gini/domain/missions/os/lottery-fix.yaml +19 -0
- gini/domain/missions/os/priority-fix.yaml +24 -0
- gini/domain/missions.py +111 -0
- gini/domain/modulechain.py +36 -0
- gini/domain/objectives.py +488 -0
- gini/domain/os_zoo.py +79 -0
- gini/domain/paging_sim.py +141 -0
- gini/domain/pricing.py +199 -0
- gini/domain/probes.py +226 -0
- gini/domain/profile.py +142 -0
- gini/domain/recipes.py +738 -0
- gini/domain/riders.py +309 -0
- gini/domain/router_modules.py +224 -0
- gini/domain/routetable.py +67 -0
- gini/domain/scoring.py +76 -0
- gini/domain/staging.py +122 -0
- gini/domain/syscall_builder.py +144 -0
- gini/domain/topic_cloud.py +62 -0
- gini/domain/topology.py +213 -0
- gini/domain/vocabulary.py +51 -0
- gini/domain/xv6.py +808 -0
- gini/domain/xv6_fs.py +250 -0
- gini/domain/xv6_runner.py +113 -0
- gini/domain/xv6_vm.py +385 -0
- gini/gloader.py +17 -0
- gini/runtime/__init__.py +18 -0
- gini/runtime/cloudfabric_agent.py +370 -0
- gini/runtime/console.py +68 -0
- gini/runtime/control.py +70 -0
- gini/runtime/frame.py +138 -0
- gini/runtime/gbridge.py +638 -0
- gini/runtime/grouter.py +223 -0
- gini/runtime/hostsim.py +90 -0
- gini/runtime/shuttle.py +348 -0
- gini/runtime/switch.py +109 -0
- gini/runtime/transport.py +77 -0
- gini/runtime/xv6_bridge.py +312 -0
- gini/server/__init__.py +22 -0
- gini/server/__main__.py +74 -0
- gini/server/app.py +140 -0
- gini/server/auth.py +82 -0
- gini/server/policy.py +57 -0
- gini/server/session.py +23 -0
- gini/services/__init__.py +15 -0
- gini/services/boardflash.py +248 -0
- gini/services/boardsetup.py +374 -0
- gini/services/cloud_catalog.py +143 -0
- gini/services/compiler.py +1858 -0
- gini/services/discovery.py +324 -0
- gini/services/gloader.py +183 -0
- gini/services/orchestrator.py +1460 -0
- gini/services/persistence.py +28 -0
- gini/services/probe_runner.py +149 -0
- gini/services/project.py +217 -0
- gini/services/remote.py +93 -0
- gini/services/rider_runner.py +96 -0
- gini/services/rider_session.py +171 -0
- gini/services/shadow_store.py +52 -0
- gini/services/terminal.py +45 -0
- gini/setup/__init__.py +17 -0
- gini/setup/cli.py +109 -0
- gini/setup/images.py +33 -0
- gini/setup/marker.py +43 -0
- gini/setup/runtime.py +69 -0
- gini/ui/__init__.py +3 -0
- gini/ui/assets/app_icon.icns +0 -0
- gini/ui/assets/app_icon.ico +0 -0
- gini/ui/assets/app_icon.png +0 -0
- gini/ui/assets/app_icon_1024.png +0 -0
- gini/ui/assets/cue/_w.txt +1 -0
- gini/ui/assets/cue/ai.png +0 -0
- gini/ui/assets/cue/canvas.png +0 -0
- gini/ui/assets/cue/cloud.png +0 -0
- gini/ui/assets/cue/cost.png +0 -0
- gini/ui/assets/cue/dark/ai.png +0 -0
- gini/ui/assets/cue/dark/canvas.png +0 -0
- gini/ui/assets/cue/dark/cloud.png +0 -0
- gini/ui/assets/cue/dark/cost.png +0 -0
- gini/ui/assets/cue/dark/metrics.png +0 -0
- gini/ui/assets/cue/dark/router.png +0 -0
- gini/ui/assets/cue/dark/run.png +0 -0
- gini/ui/assets/cue/dark/serverless.png +0 -0
- gini/ui/assets/cue/dark/settings.png +0 -0
- gini/ui/assets/cue/dark/welcome.png +0 -0
- gini/ui/assets/cue/dark/wizard.png +0 -0
- gini/ui/assets/cue/ginibrand/ai.png +0 -0
- gini/ui/assets/cue/ginibrand/canvas.png +0 -0
- gini/ui/assets/cue/ginibrand/cloud.png +0 -0
- gini/ui/assets/cue/ginibrand/cost.png +0 -0
- gini/ui/assets/cue/ginibrand/metrics.png +0 -0
- gini/ui/assets/cue/ginibrand/router.png +0 -0
- gini/ui/assets/cue/ginibrand/run.png +0 -0
- gini/ui/assets/cue/ginibrand/serverless.png +0 -0
- gini/ui/assets/cue/ginibrand/settings.png +0 -0
- gini/ui/assets/cue/ginibrand/welcome.png +0 -0
- gini/ui/assets/cue/ginibrand/wizard.png +0 -0
- gini/ui/assets/cue/highcontrast/ai.png +0 -0
- gini/ui/assets/cue/highcontrast/canvas.png +0 -0
- gini/ui/assets/cue/highcontrast/cloud.png +0 -0
- gini/ui/assets/cue/highcontrast/cost.png +0 -0
- gini/ui/assets/cue/highcontrast/metrics.png +0 -0
- gini/ui/assets/cue/highcontrast/router.png +0 -0
- gini/ui/assets/cue/highcontrast/run.png +0 -0
- gini/ui/assets/cue/highcontrast/serverless.png +0 -0
- gini/ui/assets/cue/highcontrast/settings.png +0 -0
- gini/ui/assets/cue/highcontrast/welcome.png +0 -0
- gini/ui/assets/cue/highcontrast/wizard.png +0 -0
- gini/ui/assets/cue/light/ai.png +0 -0
- gini/ui/assets/cue/light/canvas.png +0 -0
- gini/ui/assets/cue/light/cloud.png +0 -0
- gini/ui/assets/cue/light/cost.png +0 -0
- gini/ui/assets/cue/light/metrics.png +0 -0
- gini/ui/assets/cue/light/router.png +0 -0
- gini/ui/assets/cue/light/run.png +0 -0
- gini/ui/assets/cue/light/serverless.png +0 -0
- gini/ui/assets/cue/light/settings.png +0 -0
- gini/ui/assets/cue/light/welcome.png +0 -0
- gini/ui/assets/cue/light/wizard.png +0 -0
- gini/ui/assets/cue/metrics.png +0 -0
- gini/ui/assets/cue/router.png +0 -0
- gini/ui/assets/cue/run.png +0 -0
- gini/ui/assets/cue/serverless.png +0 -0
- gini/ui/assets/cue/settings.png +0 -0
- gini/ui/assets/cue/welcome.png +0 -0
- gini/ui/assets/cue/wizard.png +0 -0
- gini/ui/assistant.py +2111 -0
- gini/ui/author_dialog.py +184 -0
- gini/ui/board_dialog.py +247 -0
- gini/ui/branding.py +21 -0
- gini/ui/canvas.py +2007 -0
- gini/ui/chat_panel.py +7 -0
- gini/ui/cpu_journey.py +212 -0
- gini/ui/cpu_lab.py +306 -0
- gini/ui/cue_cards.py +214 -0
- gini/ui/dashboard.py +222 -0
- gini/ui/diagnose_game.py +336 -0
- gini/ui/fingerprint_lab.py +219 -0
- gini/ui/flash_dialog.py +244 -0
- gini/ui/flow_layout.py +63 -0
- gini/ui/fragment_manager.py +1415 -0
- gini/ui/game_catalog.py +184 -0
- gini/ui/game_renderers.py +340 -0
- gini/ui/games_lab.py +90 -0
- gini/ui/inspector.py +1055 -0
- gini/ui/live_metrics.py +130 -0
- gini/ui/machine_lab.py +1412 -0
- gini/ui/main_window.py +3153 -0
- gini/ui/memory_lab.py +371 -0
- gini/ui/mission_panel.py +302 -0
- gini/ui/mode_indicator.py +227 -0
- gini/ui/palette.py +112 -0
- gini/ui/peripherals.py +218 -0
- gini/ui/process_tree.py +130 -0
- gini/ui/reset_dialog.py +179 -0
- gini/ui/router_lab.py +776 -0
- gini/ui/run_button.py +183 -0
- gini/ui/settings_dialog.py +234 -0
- gini/ui/signin_dialog.py +111 -0
- gini/ui/storage_lab.py +219 -0
- gini/ui/syscall_builder.py +235 -0
- gini/ui/syscall_lab.py +152 -0
- gini/ui/theme/__init__.py +5 -0
- gini/ui/theme/icons.py +145 -0
- gini/ui/theme/manager.py +291 -0
- gini/ui/theme/tokens.py +194 -0
- gini/ui/trap_lab.py +270 -0
- gini/ui/worker_host.py +102 -0
- gini/ui/zoo_lab.py +112 -0
- gini_toolkit-6.0.1.dev0.dist-info/METADATA +77 -0
- gini_toolkit-6.0.1.dev0.dist-info/RECORD +278 -0
- gini_toolkit-6.0.1.dev0.dist-info/WHEEL +5 -0
- gini_toolkit-6.0.1.dev0.dist-info/entry_points.txt +3 -0
- gini_toolkit-6.0.1.dev0.dist-info/top_level.txt +1 -0
gini/runtime/gbridge.py
ADDED
|
@@ -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()
|