Skip to content

minisky_tangram

minisky_tangram

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

HEARTBEAT_SECS: DurationS[float] = 1.0

plugin module-attribute

plugin = Plugin(build=build, config_class=TangramConfig)

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

channel: str = 'minisky'

max_hz class-attribute instance-attribute

max_hz: FrequencyHz[float] = 5.0

TangramSimInfo

Bases: TypedDict

Simulation status block of the new-data wire payload.

simt instance-attribute

simdt instance-attribute

simdt: DurationS[float]

simutc instance-attribute

simutc: str

speed instance-attribute

speed: float

ntraf instance-attribute

ntraf: int

state instance-attribute

state: int

state_name instance-attribute

state_name: str

scenname instance-attribute

scenname: str

nconf_cur instance-attribute

nconf_cur: int

nlos_cur instance-attribute

nlos_cur: int

TangramAircraft

Bases: TypedDict

One aircraft in the new-data wire payload (jet1090-style fields).

id instance-attribute

id: str

callsign instance-attribute

callsign: str

typecode instance-attribute

typecode: str

latitude instance-attribute

latitude: LatitudeDeg[float]

longitude instance-attribute

longitude: LongitudeDeg[float]

altitude instance-attribute

groundspeed instance-attribute

groundspeed: GroundSpeedKt[float]

tas instance-attribute

ias instance-attribute

vertical_rate instance-attribute

vertical_rate: VerticalRateFpm[int]

track instance-attribute

inconf instance-attribute

inconf: bool

timestamp instance-attribute

timestamp: WallClockTimeS[float] | None

TangramPayload

Bases: TypedDict

Full to:<channel>:new-data wire payload.

aircraft instance-attribute

aircraft: list[TangramAircraft]

count instance-attribute

count: int

siminfo instance-attribute

siminfo: TangramSimInfo

TangramBridge

TangramBridge(redis_url: str, channel: str, max_hz: float, redis_factory: Callable[[str], Any] | None = None)

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

redis_url = redis_url

channel instance-attribute

channel = channel

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

connected = False

published instance-attribute

published = 0

last_error instance-attribute

last_error = ''

ready instance-attribute

ready = threading.Event()

start

start(runtime: PluginRuntime) -> Result[str, str]

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() -> None

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

status() -> Result[str, str]

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

tick() -> None

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

reset() -> None

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
325
326
327
def capture_console(self, text: str) -> None:
    if text:
        self._console.extend(text.splitlines())

convert_snapshot

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.

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_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.

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)