EAS Station™ Theory of Operation
EAS Station™ documentation
EAS Station™ Theory of Operation
The EAS Station™ platform orchestrates NOAA and IPAWS Common Alerting Protocol (CAP) messages from ingestion to FCC-compliant broadcast and verification. This document explains the end-to-end data flow, highlights the subsystems that participate in each phase, and provides historical context for the Specific Area Message Encoding (SAME) protocol that anchors the audio workflow.
System Architecture Overview
EAS Station™ uses a separated service architecture with complete hardware isolation for reliability and fault tolerance:
Service Responsibilities
| Service | Hardware Access | Purpose |
|---|---|---|
| eas-station-web | None (read-only /dev for SMART) | Web UI, API, configuration (Gunicorn) |
| eas-station-poller | None | Unified NOAA + IPAWS CAP feed polling (poller/cap_poller.py --continuous) — a single process alternates both feeds, not two separate services |
| eas-station-sdr | USB (/dev/bus/usb) |
SoapySDR capture (sdr_hardware_service.py) |
| eas-station-demod | None | FM/AM demodulation (services.demod), reads raw samples from eas-station-sdr over Redis |
| eas-station-audio | None | SAME FSK decode, EAS monitor, Icecast streaming, and the audio-forwarding pipeline (eas_monitoring_service.py) |
| eas-station-hardware.target | GPIO, I2C (/dev/gpiomem, /dev/i2c-1) |
Bundles 5 per-subsystem services — eas-station-network, -zigbee, -gps, -displays, -gpio (ports 5101–5105) — relay control and displays (OLED/VFD/LED) |
High-Level Data Flow
Each node references an actual module, package, or service in the repository so operators and developers can trace the implementation.
Pipeline Stages
1. Ingestion & Validation
The CAP polling system runs as a single unified eas-station-poller.service, alternating both feeds each cycle (not two separate services):
- Pollers (
poller/cap_poller.py) fetch CAP 1.2 feeds from NOAA Weather Service and FEMA IPAWS on configurable intervals (default 120 seconds, configured at Settings → Poller and persisted inpoller_settings.poll_interval_sec) - Schema Enforcement validates XML against CAP schema and normalises polygons, circles, and SAME location codes
- Deduplication (
app_core/alerts.py) compares CAP identifiers, message types, and sent timestamps - Configuration for runtime settings (polling, EAS broadcast, notifications, application logging) lives in dedicated database tables editable from the admin UI; only boot-time infrastructure (
SECRET_KEY,DATABASE_URL, hostnames, paths) is read from the persistent/opt/eas-station/.envfile - Combined feed-loss alarm (
app_core/system_health.py): the two feeds are tracked independently within the single unified poller process — each feed's own last-success timestamp is compared againstpoller_settings.feed_stall_threshold_sec, since a working NOAA fetch says nothing about whether the separate IPAWS fetch is also succeeding. A live NOAA feed must never mask a dead IPAWS feed (or vice versa), so the Alert Feeds card on/system_healthsurfaces both staleness values, and the compliance alert only fires when both feeds have stalled past the threshold. This is a liveness check on top of the per-message deduplication above, not a replacement for it.
2. Persistence & Spatial Context
- Database runs PostgreSQL 17 with PostGIS 3.5 extension
- ORM Models (
app_core/models.py) describe alerts, boundaries, receivers, audio sources, displays - Spatial Processing uses PostGIS
ST_Intersectsfor geographic matching
3. Operator Experience
- Flask Web Application (
webapp/) provides Bootstrap 5 responsive interface - Setup Wizard (
/setup) manages ALL configuration—no hardcoded environment variables - Settings Pages (
/settings/*) expose:- Environment variables (
/settings/environment) - Location settings, Audio/SDR configuration
- Hardware (GPIO, OLED, VFD, LED signs)
- IPAWS/NOAA feed configuration
- Environment variables (
- System Health (
app_core/system_health.py) monitors CPU, memory, SDR state, audio pipeline
4. Broadcast Orchestration
Broadcast can be triggered automatically (zero intervention) or manually by an operator:
Automatic Forwarding (v2.52.0+):
- CAP alerts (
poller/cap_poller.py) — after saving a new alert, callsauto_forward_cap_alert()which triggersEASBroadcaster.handle_alert()for full SAME + audio + GPIO broadcast - OTA alerts (
app_core/audio/alert_forwarding.py) — when a FIPS-matched OTA alert is received, callsauto_forward_ota_alert()through the same broadcast pipeline - Gated-alerts hold-off timer (v2.158.0+, optional) — when enabled, alerts that are not Immediate urgency / Extreme severity are held in a
gated_alertsqueue instead of broadcasting immediately; an operator can approve them early or cancel them, or a background scheduler auto-releases them once the configured hold-off timer expires. Seedocs/guides/GATED_ALERTS.md. - Cross-source deduplication (
app_core/audio/auto_forward.py) — checkseas_messagesandmanual_eas_activationstables within a 15-minute window for same event code + overlapping FIPS codes to prevent duplicate broadcasts when the same alert arrives via IPAWS + NOAA + OTA - Originator resolution (ECIG §3.4.1.1) —
build_same_header()(app_utils/eas.py, ~line 1417) uses the incoming CAP alert's ownEAS-ORGparameter when present and a recognised value; the station's configured originator (eas_settings.originator) is only a fallback for alerts that don't specify one, not an override of the source - Cross-process Icecast injection (v3.10.3+) —
EASBroadcaster.handle_alert()(app_utils/eas.py) pushes the generated audio into the live Icecast air-chain viaapp_core/audio/eas_stream_injector.py. That module's_controlleris only registered insideeas-station-audio.service, sohandle_alert()checkshas_controller()first: when true (a live OTA relay decoded inside the audio service) it callsinject_eas_audio()directly, in-process; when false (every CAP/IPAWS auto-forward fromeas-station-poller.service, and every gated-alert "Approve" fromeas-station-web.service) it instead publishesAudioCommandPublisher.inject_raw_eas_audio()over Redis, askingeas-station-audio— the process that actually owns the runningIcecastStreamerthreads — to perform the injection. The outcome is recorded on theEASMessagerow (metadata_payload['icecast_injected']);CAPPoller.retry_failed_icecast_injections()re-sends a failed injection (up to 3 attempts, once per poll cycle) via the same resend command the manual "Resend" button uses, so a transient failure (the audio service briefly down) doesn't silently and permanently lose the broadcast.
ECIG V1.0 compliance gates (applied by auto_forward_cap_alert in order; first failure short-circuits with a reason citing the spec section):
| Order | Section | Check |
|---|---|---|
| 1 | §3.1 | <status> MUST be Actual; Test / Exercise / Draft suppressed |
| 2 | §3.2 | <scope> MUST be Public |
| 3 | §3.8 | <msgType> MUST be Alert or Update; Cancel/Ack/Error suppressed |
| 4 | §3.3 | <expires> MUST be in the future and after <sent> |
| 5 | §3.4.1.7 | If EAS-Must-Carry=True, the event-code allowlist and default RWT suppression are bypassed for this alert (steps 6-7 are skipped). Location filter and dedupe still apply. |
| 6 | — | Operator's forwarded_event_codes allowlist (RWT also requires explicit opt-in) |
| 7 | VTEC | VTEC_SKIP_ACTIONS (CON/ROU/COR) and VTEC_TERMINAL_ACTIONS (CAN/EXP) suppressed; UPG bypasses dedupe |
| 8 | — | Gated-alerts hold-off timer (optional; skipped for Immediate urgency / Extreme severity) — see above |
| 9 | — | Cross-source dedupe (event code + FIPS within 15 min) |
Audio / text rendering also follows the guide: _fetch_embedded_audio enforces ECIG §3.5.1 fetch timeouts (120 s downloadable, 30 s streaming) and _normalize_text_for_tts converts the §3.5.2 / §3.5.4 *** text-deletion marker into an audible sentence pause for every TTS backend.
Manual Path:
- Workflow UI (
webapp/eas/) guides operators through alert selection and SAME header preview - SAME Generator (
app_utils/eas.py,app_utils/eas_fsk.py) creates FCC-compliant 520⅔ baud FSK audio - Hardware Integration via the isolated
eas-station-gpioservice (part ofeas-station-hardware.target) for GPIO relay control
Aborting a broadcast in progress: every playback path — RWT (app_core/rwt_scheduler.py::_drive_rwt_airchain()), manual "Send" (webapp/eas/workflow.py), resend (scripts/resend_eas_broadcast.py), and live/forwarded alerts (app_utils.eas.EASBroadcaster) — launches its player through app_utils/eas.py::play_broadcast_audio() (a thin wrapper around _run_command(), which the live/forwarded path calls directly since it lives in the same module). That single chokepoint publishes the playback subprocess's PID to Redis (eas:broadcast_pid) for the duration of the call, clearing it on completion, and — critically — also publishes the isolated EOM tone-burst WAV for whatever's currently playing (eas:broadcast_eom_wav, base64-encoded since the Redis client decodes every value as UTF-8 and raw audio bytes are not valid UTF-8).
app_core/audio/gpio_input_actions.py::abort_current_broadcast() — reachable from a physical GPIO Dump / Abort Broadcast input (a sustained 3-second hold, so a momentary bump can never abort a live broadcast) or from the full-screen browser countdown overlay's on-screen "Hold to Abort Broadcast" button (POST /api/broadcast/abort, webapp/routes/broadcast_control.py, gated on the eas.cancel permission, requiring the same 3-second press-and-hold gesture client-side as its safety equivalent) — stops the broadcast on both surfaces it can reach: a local playback subprocess, if the station has one configured (reads the published PID and sends SIGTERM, escalating to SIGKILL after a grace period if needed), and audio already queued into the live Icecast air-chain (app_core/audio/eas_stream_injector.py::abort_injected_audio(), called over the audio-service's Redis command channel — a separate pipeline the local kill never touches, and the only one that exists at all on an Icecast-only station with no local player configured). But per 47 CFR 11.61(a), an EAS message must always end with an EOM burst — a broadcast must never simply go silent — so abort does not stop at cutting the message: it synchronously plays the isolated EOM audio it read from Redis locally (via the configured audio player) and injects that same EOM burst into the Icecast air-chain (via abort_injected_audio()), so both a local monitor and stream listeners hear a compliant sign-off instead of a hard cut to dead air. Only once both attempts have completed (successfully or not — an unrecoverable failure such as no audio player configured must not leave the relay stuck forever) does it call the same clear_broadcast_active() a normal broadcast completion already uses, which is what causes the GPIO subprocess to drop the transmitter relay on its next poll. An operator-forced abort writes an entry to the tamper-evident audit ledger (AuditAction.EAS_CANCELLATION), including eom_sent: true/false, injected_audio_cleared (how many queued Icecast chunks were purged), and the operator identity (the logged-in username for a web-triggered abort, gpio-input for a physical button), so a compliance review can see who ended the broadcast and whether the EOM burst was actually sent — the same class of event a normal broadcast completion does not need to record, since nothing was cut short.
Countdown overlay phase awareness: set_broadcast_active() additionally accepts header_seconds/eom_seconds — elapsed-time thresholds marking the end of the SAME header burst and the start of the EOM burst — computed by each of the four playback paths from the WAV segment durations they already have on hand (folding any pre-/post-alert chime duration into the adjacent phase, since chimes play immediately before the header or after the EOM). The browser overlay (templates/base.html) uses these to show which phase the broadcast is currently in — Sending Header, Narration, or Sending EOM — falling back to a plain phase-less countdown when a caller doesn't supply them (both default to 0.0).
5. Audio Processing & SDR Monitoring
SDR hardware capture, FM/AM demodulation, and SAME decode/Icecast streaming are three separate systemd services (split apart so a demodulation crash can't take Icecast output down with it):
- Real-Time Streaming Decoder (
app_core/audio/streaming_same_decoder.py) — <200ms latency, <5% CPU - Audio Source Manager (
app_core/audio/source_manager.py) — multi-source with automatic failover - Icecast Integration streams demodulated audio for remote monitoring, and is also the injection point for generated EAS broadcast audio (see the Cross-process Icecast injection note above)
6. Verification & Compliance
- SDR Capture via SoapySDR drivers (
app_core/radio/drivers.py) - Alert Verification supports WAV/MP3 uploads and automated SDR captures
- Compliance Dashboard reconciles alerts for FCC reporting
SAME Protocol Deep Dive
The Specific Area Message Encoding protocol is the broadcast payload EAS Station™ produces for on-air activation. Key characteristics:
- Encoding Format – ASCII characters transmitted with 520⅔ baud frequency-shift keying (FSK) using mark and space tones at 2083.3 Hz and 1562.5 Hz. The generator in
app_utils/eas.pyhonours this cadence and injects the mandated three-header burst sequence (Preamble, ZCZC, message body, End of Message). - Message Structure – SAME headers follow
ZCZC-ORG-EEE-PSSCCC+TTTT-JJJHHMM-LLLLLLLL-. EAS Station™ assembles each component from CAP payloads: ORG fromsenderName, EEE from the CAP event code, PSSCCC from matched FIPS/SAME codes, TTTT for duration, andLLLLLLLLfor the station identifier configured in the admin UI.- EEE resolution –
app_utils/eas.py::build_same_header()callsresolve_event_code(), which first tries the CAP<eventCode>block'sSAME-valueName candidates (_collect_event_code_candidates()) and only falls back to matching the plain-Englisheventfield against the registry (app_utils/event_codes.py) when noeventCodeis present. NWS'sapi.weather.govCAP-JSON feed includeseventCodenatively; the IPAWS-OPEN XML feed does not parse itself —poller/cap_poller.py::CAPPoller._extract_cap_event_codes()extracts it from the raw CAP 1.2<info>block (same<valueName>/<value>shape as<parameter>). Both shapes normalise to{"SAME": [...], ...}inproperties['eventCode'].app_core/audio/auto_forward.py::_resolve_event_code()(used for theforwarded_event_codesallowlist gate, separate from SAME-header generation) delegates to the same resolver so the two paths cannot disagree about an alert's event code.
- EEE resolution –
- Attention Signal – After the third header, the attention signal is generated using simultaneous 853 Hz and 960 Hz sine waves for a configurable duration (defaults defined in
app_utils/eas.py). - End of Message – The
NNNNEOM triplet terminates the activation. The workflow enforces the three-EOM rule and logs playout with timestamps inapp_core/eas_storage.py.
📑 Cross-Reference: Sections 4.1–4.3 of the DASDEC3 Version 5.1 Software User’s Guide describe identical header, audio, and relay sequencing. Keep
docs/resources/vendor/Version 5.1 Software_Users Guide_R1.0 5-31-23.pdfopen when editing this document so the nomenclature stays aligned.
Historical Background
- 1994 Rollout – The FCC adopted SAME to replace the two-tone Attention Signal, enabling geographically targeted alerts and automated receiver activation.
- 2002 IPAWS Integration – FEMA’s Integrated Public Alert and Warning System standardised CAP 1.2 feeds, which EAS Station™ ingests via dedicated pollers.
- Ongoing Enforcement – FCC Enforcement Bureau cases such as the 2015 iHeartMedia consent decree (The Bobby Bones Show) and the 2014 Olympus Has Fallen trailer settlement demonstrate the penalties for misuse. The
/aboutpage links to the official notices to reinforce best practices.
Raspberry Pi Platform Evolution
EAS Station™’s quest to deliver a software-first encoder/decoder is tightly coupled with the Raspberry Pi roadmap:
- Model B (2012): Early tests proved a $35 board could poll CAP feeds and render SAME tones with USB DACs, albeit with limited concurrency.
- Pi 3 (2016): Integrated Wi-Fi and quad-core CPUs enabled simultaneous NOAA/IPAWS polling and text-to-speech without overruns.
- Pi 4 (2020): Gigabit Ethernet and USB 3.0 stabilised dual-SDR capture alongside GPIO relay control, unlocking continuous lab deployments.
- Pi 5 (2023): PCIe 2.0 storage, LPDDR4X memory, and the BCM2712 SoC provided the horsepower for SDR verification, compliance analytics, and narration on a single board—the reference build documented in
README.md. - Pi 5 Production Runs (2024+): Hardened kits with UPS-backed power, relay breakouts, and CM4-based carrier boards were documented alongside vendor references (
docs/resources/vendor/QSG_DASDEC-G3_R5.1.docx,docs/resources/vendor/D,GrobSystems,ADJ06182024A.pdf) to mirror field requirements captured in the DASDEC3 manual.
The reference stack—Pi 5 (8 GB), balanced audio HAT, dual SDR receivers, NVMe storage, GPIO relay bank, and UPS-backed power—totals ~$585 USD in 2025. Equivalent DASDEC3 racks list for $5,000–$7,000 USD, illustrating the leverage gained by investing in software quality rather than proprietary hardware.
Operational Checklist
When deploying or evaluating the system:
- Verify CAP Connectivity – Confirm polling logs in
logs/show successful fetches and schema validation. - Map Boundaries – Populate counties and polygons through the admin interface (
/settings/geo) or import via the CLI tools intools/. - Configure Broadcast Outputs – Set the station identifier, text-to-speech provider, GPIO pinout, and LED sign parameters in
/settings. - Exercise the Workflow – Use
/eas/workflowto run a Required Weekly Test (RWT) and inspect stored WAV files understatic/audio/. - Validate Verification Loop – Upload the generated WAV to the decoder lab to confirm headers decode as issued.
Refer back to this document whenever you need a grounded explanation of what happens between CAP ingestion and verified broadcast.
Last Updated: 2026-09-17 Related Documents: System Architecture, Data Flow Sequences, Diagrams Index
This document is served from docs/architecture/THEORY_OF_OPERATION.md.md in the EAS Station™ installation.