Shared Data Models โ
The shared package contains the Pydantic data models, Redis key name functions, and other common primitives used by all SkyFollower components. Keeping these in one place ensures that the message envelope format, Redis key naming, and fault-tolerance behavior stay consistent across the receiver, processor, archive processor, data runners, and UI backend. Components that run in their own containers install the package at build time via a relative path reference in their requirements.txt.
Contents โ
models.pyโ Pydantic models for the RabbitMQ message envelope (InboundMessage), in-flight telemetry (Position,Velocity), Redis enrichment records (AircraftRecord,OperatorRecord,AirportRecord), and the completed flight record published to theskyfollower-archivequeue (CompletedFlight). Also exportsgenerate_flight_id()which returns a UUID-v7 string used as the_idof each archived flight.flight_path.pyโbuild_flight_path(), which interpolates a flight's missing altitude values across itspositions[]and builds the resulting 3D GeoJSONLineStringFeature(returnsNonefor fewer than 2 positions). Nothing persists this โ it's always rebuilt on demand frompositions[], so archive-processor, management-ui's flight-view feature, and any future moving-map feature all call this instead of each keeping their own copy of the interpolation logic.config.pyโload_config(), which builds a component's configuration from environment variables and returns the nested dictionary shape every component already consumed. A component names the blocks it reads (load_config("redis", "mqtt", "runner")), so no container is handed credentials it never uses, and a startup failure reports every missing or malformed variable at once rather than one per restart. Per-block helpers (mqtt_config(),redis_config(),rabbitmq_config(),s3_config(), โฆ) define each block's shape once.DATA_DIRlives here too, as a constant: every compose file fixes the data directory at/app/datavia its bind mount, so it was never a per-deployment value.index_cache.pyโINDEX_CACHE_DIR,local_index_path(),write_local_index(), anddelete_local_index(): the local mirror of a per-flight Parquet index row, shared betweenarchive-processor(writer) andarchive-compaction(reader) via a bind mount both containers point at the same host directory (seedocker-compose.archive.yaml). Letsarchive-compactionread a row back from local disk instead of re-downloading it from S3 days later. See each component's own README for how it's wired in.ha_discovery.pyโ Builders for the Home Assistant MQTT discovery payloads every component publishes.build_ha_device()returns the shareddeviceblock (manufacturer,sw_versionfrom the build-timeVERSION/GIT_COMMITenv vars, etc.) so it stays identical across every call site.build_ha_update_entity()returns the discovery config for anupdateentity โ running image version versus the latest tag published to the registry โ nested under a component's existing device by passing that device block through. It is an indicator only: nocommand_topic, nopayload_install, noINSTALLsupported feature, so Home Assistant shows a newer version but offers no upgrade button. Itsstate_topiccarries a JSON payload withinstalled_versionandlatest_version, which Home Assistant reads natively without a value template.version_check.pyโget_latest_ghcr_tag(image), which returns the newest calendar-versioned (YYYY.MM.BB) tag ofghcr.io/BrentIO/<image>orNone. Handles GHCR's Registry v2 bearer-token requirement (an anonymous pull token is fetched first and memoised in-process with a short TTL, so one poll pass over every component's image reuses a single token), compares tags as integer(year, month, build)tuples rather than strings (a single-digit month or build sorts wrong lexically), and degrades toNoneon any network error, non-200, malformed JSON, or rate-limit response without ever raising.core-healthcalls it on a slow loop (GHCR_VERSION_CHECK_INTERVAL_SECONDSintiming.py) to feed theupdateentities above.mqtt_register.pyโpublish_register(client, device), called by every MQTT-enabled component right after it builds its owndeviceblock viabuild_ha_device(). Publishes one retainedSkyFollower/register/{device['ids']}message โ a SkyFollower-native topic, not part of thehomeassistant/discovery namespace โ carrying the component's bare GHCR image name (read from theCOMPONENT_IMAGEenvironment variable, baked in at build time exactly the wayVERSION/GIT_COMMITalready are: every Dockerfile declaresARG IMAGE=unknown/ENV COMPONENT_IMAGE=$IMAGE, andbuild-container-images.yamlpasses anIMAGEbuild-arg ofskyfollower-followed by that workflow's ownmatrix.namevalue โ the exact name its owndiscover-imagesjob already computed) and thedeviceblock itself, verbatim. This is what letscore-healthbuild its "update available" registry (seeha_discovery.pyabove) by reading two fields straight off the wire, with no table anywhere mapping a component's identity to its image name. A no-op whenCOMPONENT_IMAGEis unset or still the Dockerfile default of"unknown"(a local/manual build with no--build-arg IMAGE=...).redis_keys.pyโ Functions (not string constants) for every Redis key used in the system. Using functions makes key parameters explicit and allows the type checker to catch typos.enrichment.pyโ Aircraft/registrant/operator enrichment helpers used by both the message processor (enrich once per flight) and the archive processor (backfill empty fields at write time):merge_aircraft.luaresult post-processing, the operator-lookup skip rules, and the leaf-levelfill_empty_leaves().rabbitmq_topology.pyโ The ADS-B exchange's name, type and arguments, plus the helpers that declare it and bind a message processor's queue to it. The receiver and the message processor both declare this topology on every connect and must agree on it exactly, since RabbitMQ answers a redeclaration with differing arguments with a channel-level error. Defining it once turns that agreement into a guarantee.fallback_queue.pyโFallbackQueue, the SQLite-backed local retry queue every component that talks to an external dependency (RabbitMQ, S3) uses when that dependency is unreachable. Below a per-row retry threshold (default 5), a failure behaves like a plain outage โ stop the drain pass, retry from the top next time. At the threshold, the row is judged permanently poison: it's written out as a standalone JSON file under{dirname(db_path)}/dead_letters/{table_name}/(size-capped at 100MB, oldest-evicted-first) for an operator to inspect or discard out-of-band, and the drain pass continues past it instead of stopping โ so one permanently-failing item can no longer silently block everything queued behind it forever. A row also can't accumulate retries faster than once permin_retry_interval_seconds(default 30), no matter how often a caller invokesdrain()โ otherwise a caller whose own retry trigger fires in rapid bursts (e.g. a flapping connection reconnecting every few seconds) could dead-letter a row within seconds, even though it was never actually poison. See each component's own README (Fault Tolerance section) for how it's wired in.drain()runs the whole backlog in one pass;drain_one()exposes the same per-row step (retry accounting, dead-lettering, cooldown, oldest-first) one row at a time, for a caller that needs to interleave higher-priority work between rows;drain_batch(process_fn, max_batch)isdrain_one()extended to a bounded batch โ same semantics, oneDELETE/commitfor the whole succeeded prefix, stopping at the first failure or cooling-down row. The receiver's publisher thread usesdrain_batch()to keep live traffic ahead of backlog drain (re-checking the live queue between batches) while catching a large post-outage backlog up far faster than one commit per row.put_many()inserts a whole list under a single commit, for a caller batching a burst of queued writes (the receiver's overflow-writer thread) rather than paying a commit per message.The connection runs
journal_mode=WALwithsynchronous=NORMAL: a commit no longer fsyncs on every call, soput()/put_many()are cheap enough to sit on a hot path during an outage. A plain process crash (SIGKILL, OOM,docker stop) still loses nothing โ the WAL replays intact on reopen; only a host power loss or kernel panic can drop the last few committed rows, the same trade the message processor's WAL active store already makes.A third category sits between "recoverable outage" and "poison": a dependency deliberately absent in this deployment (e.g. a message processor publishing completed flights with
mandatory=Trueagainst anskyfollower-archivequeue no operator declared because this environment runs no archive processor). A caller passes the exception type(s) that signal this vianon_poison_exceptionsโFallbackQueuestays broker-agnostic and never imports the caller's library. A row failing only with those types is retried forever and never dead-lettered: losing it would discard legitimate primary data, and it drains automatically if the dependency later appears. To keep the retryable table from growing without bound in that case, a caller can also opt intoretryable_max_bytes, a ring-buffer cap on the plainqueuetable with the same oldest-first eviction the dead-letter directory uses (logged as a capacity eviction, not a poison classification).
Usage โ
from shared.models import InboundMessage, CompletedFlight, AircraftRecord
# Parse a message received from RabbitMQ
msg = InboundMessage.model_validate_json(body)
print(msg.icao_hex, msg.source)from shared.redis_keys import aircraft_simple_key, config_rules_key
# Build a Redis key for a specific aircraft
key = aircraft_simple_key("A8AE7F") # โ "aircraft:simple:A8AE7F"
# Get the key for the active rules configuration
rules_key = config_rules_key() # โ "config:rules"