Tangram bridge: stream MiniSky state to a tangram map over Redis pub/sub.
This plugin makes a running MiniSky process act as an external simulator
for tangram. It talks to
tangram exclusively through Redis, using tangram's stable channel
convention (see docs/architecture/channel.md in the tangram repo):
to:<channel>:<event> -- published by us, pushed to the browser by
tangram's Channel service over WebSocket.
from:<channel>:<event> -- pushed by the browser, re-published to
Redis by the Channel service, consumed by us.
Wire contract (all published payloads are JSON):
to:<channel>:new-data: {"aircraft": [...], "count": n, "siminfo": {...}}
with per-aircraft fields in aviation units (altitude ft, speeds kt,
vertical rate fpm) under jet1090-style names.
to:<channel>:console: {"lines": [...]} -- echoed simulator output.
from:<channel>:command: {"command": "OP"} -- a stack command to run.
All Redis I/O happens on a background thread so the simulation loop never
blocks on the network, and so commands are still received while the
simulation is paused (plugin update hooks only fire in the OP state; the
command stack itself is processed in every state).
Config (optional, under [plugins.tangram] in the MiniSky user config file):
redis_url: Redis connection URL (default redis://127.0.0.1:6379).
channel: channel/topic name (default minisky).
max_hz: wall-clock cap on snapshot publish rate (default 5).
Debug the transport without any frontend:
redis-cli psubscribe "to:*"
redis-cli publish "from:minisky:command" '{"command": "ECHO hello"}'
HEARTBEAT_SECS
module-attribute
TangramConfig
Bases: BaseModel
Validated [plugins.tangram] configuration.
model_config
class-attribute
instance-attribute
model_config = ConfigDict(extra='forbid', frozen=True)
redis_url
class-attribute
instance-attribute
redis_url: str = 'redis://127.0.0.1:6379'
channel
class-attribute
instance-attribute
max_hz
class-attribute
instance-attribute
TangramSimInfo
Bases: TypedDict
Simulation status block of the new-data wire payload.
simutc
instance-attribute
state_name
instance-attribute
scenname
instance-attribute
nconf_cur
instance-attribute
nlos_cur
instance-attribute
TangramAircraft
Bases: TypedDict
One aircraft in the new-data wire payload (jet1090-style fields).
callsign
instance-attribute
typecode
instance-attribute
latitude
instance-attribute
longitude
instance-attribute
altitude
instance-attribute
groundspeed
instance-attribute
vertical_rate
instance-attribute
inconf
instance-attribute
timestamp
instance-attribute
TangramPayload
Bases: TypedDict
Full to:<channel>:new-data wire payload.
aircraft
instance-attribute
siminfo
instance-attribute
TangramBridge
Own Redis I/O and bridge it to a plugin runtime.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253 | def __init__(
self,
# we assume the redis url has no password and is safe to log
redis_url: str,
channel: str,
max_hz: float,
redis_factory: Callable[[str], Any] | None = None,
) -> None:
self.redis_url = redis_url
self.channel = channel
self.min_interval = 1.0 / max_hz if max_hz > 0 else 0.0
self.redis_factory = redis_factory
self.connected = False
self.published = 0
self.last_error = ""
self._snapshot_builder: Callable[[], Snapshot] | None = None
self._status_builder: Callable[[], PluginStatus] | None = None
self._stack_command: Callable[[str], None] | None = None
self._last_build = 0.0
self._last_payload: TangramPayload | None = None
self._snapshots: queue.Queue[TangramPayload] = queue.Queue(maxsize=4)
self._console: deque[str] = deque(maxlen=200)
self._stop = threading.Event()
self._thread: threading.Thread | None = None
self.ready = threading.Event()
|
redis_url
instance-attribute
channel
instance-attribute
min_interval
instance-attribute
min_interval = 1.0 / max_hz if max_hz > 0 else 0.0
redis_factory
instance-attribute
redis_factory = redis_factory
connected
instance-attribute
published
instance-attribute
last_error
instance-attribute
start
Bind runtime capabilities and start the Redis thread.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276 | def start(self, runtime: PluginRuntime) -> Result[str, str]:
"""Bind runtime capabilities and start the Redis thread."""
try:
if self.redis_factory is None:
import redis
redis_factory: Callable[[str], Any] = redis.Redis.from_url
self.redis_factory = redis_factory
except ImportError:
return Err(
"TANGRAM plugin needs the redis package; run `just sync` from the "
"MiniSky repository root"
)
self._snapshot_builder = runtime.snapshot
self._status_builder = runtime.status
self._stack_command = runtime.stack_command
self._stop.clear()
self.ready.clear()
self._thread = threading.Thread(target=self._run, name="tangram-bridge", daemon=True)
self._thread.start()
return Ok(f"Tangram bridge publishing to to:{self.channel}:* at {self.redis_url}")
|
stop
Stop Redis I/O and release runtime callbacks.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
278
279
280
281
282
283
284
285
286
287
288
289
290 | def stop(self) -> None:
"""Stop Redis I/O and release runtime callbacks."""
self._stop.set()
# TODO(abraham): use finite redis timeouts, close client/pubsub resources,
# and retain ownership when the thread does not stop
if self._thread is not None:
self._thread.join(timeout=2.0)
self._thread = None
self.connected = False
self.ready.clear()
self._snapshot_builder = None
self._status_builder = None
self._stack_command = None
|
status
See stack command:
TANGRAM
Show the status of the tangram Redis bridge.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
292
293
294
295
296
297
298
299
300
301
302 | @command(name="TANGRAM")
def status(self) -> Result[str, str]:
"""Show the status of the tangram Redis bridge."""
status = "connected" if self.connected else "disconnected"
text = (
f"Tangram bridge: {status} to {self.redis_url}\n"
f"Channel: to:{self.channel}:new-data ({self.published} messages published)"
)
if self.last_error:
text += f"\nLast error: {self.last_error}"
return Ok(text)
|
tick
Build and enqueue a rate-capped snapshot while operating.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
304
305
306
307
308
309
310
311
312
313
314 | @hook("update")
def tick(self) -> None:
"""Build and enqueue a rate-capped snapshot while operating."""
snapshot_builder = self._snapshot_builder
if snapshot_builder is None:
return
now = time.monotonic()
if now - self._last_build < self.min_interval:
return
self._last_build = now
self._enqueue(convert_snapshot(snapshot_builder()))
|
reset
Push an empty payload so the frontend clears the map.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
316
317
318
319
320
321
322
323 | @hook("reset")
def reset(self) -> None:
"""Push an empty payload so the frontend clears the map."""
snapshot_builder = self._snapshot_builder
if snapshot_builder is None:
return
self._last_payload = None
self._enqueue(convert_snapshot(snapshot_builder()))
|
capture_console
capture_console(text: str) -> None
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
| def capture_console(self, text: str) -> None:
if text:
self._console.extend(text.splitlines())
|
convert_snapshot
Convert a MiniSky SI-unit snapshot into the tangram wire payload.
Aircraft come out with jet1090-style field names and aviation units
(altitude in ft, speeds in kt, vertical rate in fpm). Pure function so
it can be unit-tested without a running simulator.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197 | def convert_snapshot(snapshot: Snapshot) -> TangramPayload:
"""Convert a MiniSky SI-unit snapshot into the tangram wire payload.
Aircraft come out with jet1090-style field names and aviation units
(altitude in ft, speeds in kt, vertical rate in fpm). Pure function so
it can be unit-tested without a running simulator.
"""
siminfo = snapshot["siminfo"]
acdata = snapshot["acdata"]
state = siminfo["state"]
simutc = siminfo["simutc"]
try:
utc = datetime.fromisoformat(simutc)
except ValueError:
utc = None
if utc is not None and utc.tzinfo is None:
# sim.utc is UTC by definition; never let a naive string be read as local time.
utc = utc.replace(tzinfo=UTC)
timestamp = utc.timestamp() if utc is not None else None
out_siminfo: TangramSimInfo = {
"simt": siminfo["simt"],
"simdt": siminfo["simdt"],
"simutc": simutc,
"speed": siminfo["speed"],
"ntraf": siminfo["ntraf"],
"state": state,
"state_name": _state_name(state),
"scenname": siminfo["scenname"],
"nconf_cur": acdata["nconf_cur"],
"nlos_cur": acdata["nlos_cur"],
}
aircraft: list[TangramAircraft] = []
for i, callsign in enumerate(acdata["callsign"]):
altitude_ft: q.PressureAltitudeFt[float] = q.m_to_ft(acdata["alt"][i])
groundspeed_kt: q.GroundSpeedKt[float] = q.mps_to_kt(acdata["gs"][i])
tas_kt: q.TrueAirspeedKt[float] = q.mps_to_kt(acdata["tas"][i])
ias_kt: q.CalibratedAirspeedKt[float] = q.mps_to_kt(acdata["cas"][i])
vertical_rate_fpm: q.VerticalRateFpm[float] = q.mps_to_fpm(acdata["vs"][i])
aircraft.append(
{
"id": callsign,
"callsign": callsign,
"typecode": acdata["typecode"][i],
"latitude": acdata["lat"][i],
"longitude": acdata["lon"][i],
"altitude": round(altitude_ft),
"groundspeed": round(groundspeed_kt, 1),
"tas": round(tas_kt, 1),
"ias": round(ias_kt, 1),
"vertical_rate": round(vertical_rate_fpm),
"track": acdata["trk"][i],
"inconf": acdata["inconf"][i],
"timestamp": timestamp,
}
)
return {"aircraft": aircraft, "count": len(aircraft), "siminfo": out_siminfo}
|
extract_command
Extract the stack command from a from:<channel>:command payload.
Accepts the JSON envelope pushed by the tangram frontend
({"command": "..."}) and, for convenience when testing with
redis-cli, a bare string.
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221 | def extract_command(payload: str | bytes) -> str | None:
"""Extract the stack command from a `from:<channel>:command` payload.
Accepts the JSON envelope pushed by the tangram frontend
(`{"command": "..."}`) and, for convenience when testing with
redis-cli, a bare string.
"""
if isinstance(payload, bytes):
payload = payload.decode("utf-8", errors="replace")
text = payload.strip()
if not text:
return None
try:
data = json.loads(text)
except ValueError:
return text
if isinstance(data, dict):
cmd = data.get("command")
return str(cmd).strip() or None if cmd is not None else None
if isinstance(data, str):
return data.strip() or None
return None
|
build
Source code in packages/minisky-tangram/src/minisky_tangram/__init__.py
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450 | def build(context: PluginContext[TangramConfig]) -> PluginSpec:
bridge = context.mount(
TangramBridge(
redis_url=context.config.redis_url,
channel=context.config.channel,
max_hz=context.config.max_hz,
)
)
@asynccontextmanager
async def lifespan(runtime: PluginRuntime) -> AsyncGenerator[None]:
runtime.subscribe_console(bridge.capture_console)
match bridge.start(runtime):
case Ok(message):
runtime.echo(message)
case Err(message):
runtime.echo(message)
raise RuntimeError(message)
try:
yield
finally:
bridge.stop()
return context.finish(lifespan=lifespan)
|