diff --git a/fire-poller/config.py b/fire-poller/config.py new file mode 100644 index 0000000..86ef501 --- /dev/null +++ b/fire-poller/config.py @@ -0,0 +1,68 @@ +# ============================================================================= +# config.py — All settings live here. +# +# HOW TO USE: +# - Change values in this file only. Never hard-code IPs or ports elsewhere. +# - Every developer reads settings by importing this module: +# from config import BUILDINGS, POLL_INTERVAL_S, LOG_LEVEL +# ============================================================================= + +import logging + +# --------------------------------------------------------------------------- +# Logging +# --------------------------------------------------------------------------- +LOG_LEVEL = logging.INFO # Change to logging.DEBUG for verbose output +LOG_FORMAT = "%(asctime)s %(levelname)-8s %(message)s" + +# --------------------------------------------------------------------------- +# Modbus connection +# --------------------------------------------------------------------------- +MODBUS_PORT = 502 # Standard Modbus TCP port (simulator uses 5020+) +MODBUS_UNIT_ID = 1 # Slave/unit address on the STM32 +CONNECT_TIMEOUT = 3.0 # Seconds to wait for TCP handshake +READ_TIMEOUT = 2.0 # Seconds to wait for a register response + +# --------------------------------------------------------------------------- +# Polling behaviour +# --------------------------------------------------------------------------- +POLL_INTERVAL_S = 1.0 # How often each building is polled (seconds) +HEARTBEAT_STALE_S = 5.0 # Seconds without heartbeat change → OFFLINE +FAILURE_THRESHOLD = 5 # Consecutive failures → mark building OFFLINE +RECOVERY_TIMEOUT_S = 30.0 # Seconds in OFFLINE state before retry + +# --------------------------------------------------------------------------- +# Buildings list (id, host, port) +# Add or remove rows here when buildings change. +# +# For the simulator: +# host = "127.0.0.1", ports start at 5020 +# For real STM32 panels: +# host = panel IP, port = 502 +# --------------------------------------------------------------------------- +BUILDINGS = [ + {"id": "BLDG-001", "host": "127.0.0.1", "port": 5020}, + {"id": "BLDG-002", "host": "127.0.0.1", "port": 5021}, + {"id": "BLDG-003", "host": "127.0.0.1", "port": 5022}, + # ... add up to 100 buildings +] + +# --------------------------------------------------------------------------- +# Modbus register offsets (must match STM32_MODBUS_REGISTER_MAP.md) +# --------------------------------------------------------------------------- +REG_FIRE = 0 # 30001 — 0=Normal, 1=Alarm, 2=Fault +REG_SUPERVISORY = 1 # 30002 — 0=Normal, 1=Trouble +REG_POWER = 2 # 30003 — 0=Mains, 1=Battery, 2=Fail +REG_HEARTBEAT_HIGH = 3 # 30004 — heartbeat MSW +REG_HEARTBEAT_LOW = 4 # 30005 — heartbeat LSW +REG_UPTIME_HIGH = 5 # 30006 — uptime seconds MSW +REG_UPTIME_LOW = 6 # 30007 — uptime seconds LSW +REG_FW_VERSION = 7 # 30008 — 0xMMNN firmware version +NUM_REGISTERS = 8 # how many registers to read per poll + +# --------------------------------------------------------------------------- +# Human-readable labels (used in log messages and the future UI) +# --------------------------------------------------------------------------- +FIRE_LABELS = {0: "NORMAL", 1: "ALARM", 2: "FAULT", 3: "DISABLED"} +POWER_LABELS = {0: "MAINS", 1: "BATTERY", 2: "FAIL", 3: "CHARGING"} +SUPER_LABELS = {0: "NORMAL", 1: "TROUBLE", 2: "SILENCED"} diff --git a/fire-poller/main.py b/fire-poller/main.py new file mode 100644 index 0000000..e1496a0 --- /dev/null +++ b/fire-poller/main.py @@ -0,0 +1,152 @@ +# ============================================================================= +# main.py — Wires everything together and runs the polling loop. +# +# WHAT THIS FILE DOES: +# 1. Sets up logging. +# 2. Creates one BuildingState object per building (from config.BUILDINGS). +# 3. Every POLL_INTERVAL_S seconds, polls every building concurrently +# using asyncio. +# 4. Logs every alarm, fault, power change, or offline event. +# 5. (Placeholder) Shows where to add a database write or NATS publish. +# +# HOW TO RUN: +# pip install pymodbus fastapi uvicorn +# python main.py +# +# HOW TO RUN AGAINST THE SIMULATOR: +# # Terminal 1 — start the simulator +# fire-sim --buildings 3 --base-port 5020 --api-port 9090 +# +# # Terminal 2 — run the poller +# python main.py +# ============================================================================= + +import asyncio +import logging +import signal + +import config +from poller import BuildingState, poll_building + +# --------------------------------------------------------------------------- +# Logging setup (one line — readable in terminal, easy to redirect to a file) +# --------------------------------------------------------------------------- +logging.basicConfig(level=config.LOG_LEVEL, format=config.LOG_FORMAT) +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Async poll loop for ONE building +# --------------------------------------------------------------------------- + +async def poll_loop(state: BuildingState) -> None: + """ + Infinite loop for one building. + Polls on every POLL_INTERVAL_S tick, independently of all others. + """ + log.info("[%s] Poll loop started on %s:%d", state.building_id, state.host, state.port) + + while True: + try: + # poll_building() is synchronous (uses pymodbus sync client). + # run_in_executor() stops it from blocking the event loop, + # so all 100 buildings really do poll at the same time. + loop = asyncio.get_running_loop() + result = await loop.run_in_executor(None, poll_building, state) + + if result: + _handle_result(result) + + except asyncio.CancelledError: + log.info("[%s] Poll loop stopped.", state.building_id) + break + + except Exception as exc: + # Safety net — loop must never die silently + log.exception("[%s] Unexpected error: %s", state.building_id, exc) + + await asyncio.sleep(config.POLL_INTERVAL_S) + + +# --------------------------------------------------------------------------- +# Result handler — called after every successful poll +# --------------------------------------------------------------------------- + +def _handle_result(result: dict) -> None: + """ + Decide what to do with each reading. + + Right now it just logs noteworthy events. + Later you will add: + _save_to_database(result) + _publish_to_nats(result) + """ + bid = result["building_id"] + + # ── Log transitions ───────────────────────────────────────────────── # + if result["fire_changed"]: + log.warning("[%s] >>> FIRE: %s", bid, result["fire_label"]) + + if result["power_changed"]: + log.warning("[%s] >>> POWER: %s", bid, result["power_label"]) + + if result["heartbeat_frozen"]: + log.error("[%s] >>> HEARTBEAT FROZEN — MCU may be hung", bid) + + # ── Periodic status line (every 10 polls, approx 10 s) ────────────── # + # (Replace with a real dashboard later.) + hb = result["heartbeat"] + if hb % 10 == 0: + log.info( + "[%s] status | fire=%-8s power=%-7s hb=%d uptime=%ds lat=%.1fms", + bid, + result["fire_label"], + result["power_label"], + hb, + result["uptime_s"], + result["latency_ms"], + ) + + # ── TODO: save to database ────────────────────────────────────────── # + # _save_to_database(result) + + # ── TODO: publish to message broker (NATS / Kafka) ───────────────── # + # await publisher.publish(result) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +async def main() -> None: + # Build one state object per building + states = [ + BuildingState(b["id"], b["host"], b["port"]) + for b in config.BUILDINGS + ] + + log.info("Starting fire poller — %d building(s)", len(states)) + + # Launch all poll loops concurrently + tasks = [asyncio.create_task(poll_loop(s), name=s.building_id) for s in states] + + # Graceful shutdown on Ctrl-C or SIGTERM + stop = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop.set) + except NotImplementedError: + pass # Windows doesn't support add_signal_handler + + await stop.wait() + + log.info("Shutting down — cancelling poll loops...") + for task in tasks: + task.cancel() + await asyncio.gather(*tasks, return_exceptions=True) + log.info("Shutdown complete.") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/fire-poller/poller.py b/fire-poller/poller.py new file mode 100644 index 0000000..12f9c85 --- /dev/null +++ b/fire-poller/poller.py @@ -0,0 +1,297 @@ +# ============================================================================= +# poller.py — Reads one building's Modbus registers and detects changes. +# +# WHAT THIS FILE DOES: +# - Connects to one STM32 panel over Modbus TCP. +# - Reads the 8 status registers defined in config.py. +# - Compares the result to the previous reading to spot transitions +# (e.g. fire went from NORMAL → ALARM). +# - Returns a plain dict with the decoded values. +# - Tracks consecutive failures and marks a building OFFLINE when +# the failure threshold is crossed. +# +# WHAT THIS FILE DOES NOT DO: +# - It does not store data in a database (that is main.py's job). +# - It does not run a loop (main.py calls poll_building() on a timer). +# - It does not know about other buildings. +# +# HOW TO EXTEND: +# - Add a new register? Add its offset to config.py, read it in +# _decode_registers(), return it in the dict. +# - Add a new state transition? Add an if-block inside detect_changes(). +# ============================================================================= + +import logging +import time + +from pymodbus.client import ModbusTcpClient + +import config + +log = logging.getLogger(__name__) + + +# --------------------------------------------------------------------------- +# Building state tracker +# Each building gets one instance, created once by main.py at startup. +# --------------------------------------------------------------------------- + +class BuildingState: + """ + Holds the last-known state of one building so we can detect changes. + + Attributes + ---------- + building_id : str — e.g. "BLDG-042" + host : str — IP address of the STM32 + port : int — Modbus TCP port + fire : int — last fire register value + power : int — last power register value + heartbeat : int — last combined 32-bit heartbeat + hb_last_moved : float — monotonic time when heartbeat last changed + failures : int — consecutive poll failures + offline : bool — True when failure_threshold reached + offline_since : float — monotonic time when building went offline + """ + + def __init__(self, building_id: str, host: str, port: int) -> None: + self.building_id = building_id + self.host = host + self.port = port + + # Previous register values (None = first poll, no comparison yet) + self.fire = None + self.power = None + self.heartbeat = None + + # Heartbeat staleness tracking + self.hb_last_moved = None # monotonic timestamp + + # Failure / offline tracking + self.failures = 0 + self.offline = False + self.offline_since = None + + +# --------------------------------------------------------------------------- +# Main polling function — one call = one building, one Modbus read +# --------------------------------------------------------------------------- + +def poll_building(state: BuildingState): + """ + Poll one building and return a result dict. + + Parameters + ---------- + state : BuildingState + Carries previous values for change detection. + Updated in-place by this function. + + Returns + ------- + dict + Decoded register values + change flags. + Example:: + + { + "building_id": "BLDG-001", + "fire": 1, # register value + "fire_label": "ALARM", # human-readable + "power": 0, + "power_label": "MAINS", + "supervisory": 0, + "heartbeat": 1042, + "uptime_s": 300, + "fw_version": "v1.0", + "latency_ms": 3.4, + "fire_changed": True, # True if fire state changed this poll + "power_changed": False, + "heartbeat_frozen": False, # True if MCU heartbeat has stopped + "offline": False, + } + + None + If the poll failed (connection refused, timeout, etc.). + The caller should check ``state.offline`` to see if the building + has been marked offline. + """ + # ── 1. Skip immediately if we know the building is offline ───────── # + if state.offline: + # Wait for recovery timeout before trying again + if time.monotonic() - state.offline_since < config.RECOVERY_TIMEOUT_S: + return None + + # Recovery window open — try one probe + log.info("[%s] Trying recovery probe...", state.building_id) + + # ── 2. Open Modbus TCP connection ─────────────────────────────────── # + client = ModbusTcpClient( + host=state.host, + port=state.port, + timeout=config.READ_TIMEOUT, + ) + + try: + if not client.connect(): + _record_failure(state, reason="connect_failed") + return None + + # ── 3. Read registers ─────────────────────────────────────────── # + t0 = time.monotonic() + response = client.read_input_registers( + address=0, + count=config.NUM_REGISTERS, + slave=config.MODBUS_UNIT_ID, + ) + latency_ms = (time.monotonic() - t0) * 1000.0 + + if response.isError(): + log.warning("[%s] Modbus error: %s", state.building_id, response) + _record_failure(state, reason="modbus_error") + return None + + # ── 4. Decode registers ───────────────────────────────────────── # + result = _decode_registers(response.registers, state, latency_ms) + + # ── 5. Detect state changes ───────────────────────────────────── # + result = _detect_changes(result, state) + + # ── 6. Reset failure counter on success ───────────────────────── # + if state.offline: + log.info("[%s] BUILDING BACK ONLINE after %.0fs", + state.building_id, + time.monotonic() - state.offline_since) + + state.failures = 0 + state.offline = False + + # Save current values for next poll's comparison + state.fire = result["fire"] + state.power = result["power"] + + return result + + except Exception as exc: + log.error("[%s] Unexpected error: %s", state.building_id, exc) + _record_failure(state, reason="exception") + return None + + finally: + client.close() + + +# --------------------------------------------------------------------------- +# Private helpers +# --------------------------------------------------------------------------- + +def _decode_registers(regs: list, state: BuildingState, latency_ms: float) -> dict: + """ + Convert raw register values into a plain, readable dict. + + All the register-offset arithmetic is in this one function so no other + code has to import config.REG_* constants directly. + """ + fire = regs[config.REG_FIRE] + supervisory = regs[config.REG_SUPERVISORY] + power = regs[config.REG_POWER] + hb_high = regs[config.REG_HEARTBEAT_HIGH] + hb_low = regs[config.REG_HEARTBEAT_LOW] + up_high = regs[config.REG_UPTIME_HIGH] + up_low = regs[config.REG_UPTIME_LOW] + fw = regs[config.REG_FW_VERSION] + + heartbeat = (hb_high << 16) | hb_low + uptime_s = (up_high << 16) | up_low + fw_str = f"v{(fw >> 8) & 0xFF}.{fw & 0xFF}" + + return { + "building_id": state.building_id, + "fire": fire, + "fire_label": config.FIRE_LABELS.get(fire, f"UNKNOWN({fire})"), + "supervisory": supervisory, + "supervisory_label":config.SUPER_LABELS.get(supervisory, f"UNKNOWN({supervisory})"), + "power": power, + "power_label": config.POWER_LABELS.get(power, f"UNKNOWN({power})"), + "heartbeat": heartbeat, + "uptime_s": uptime_s, + "fw_version": fw_str, + "latency_ms": round(latency_ms, 1), + # change flags filled in by _detect_changes() + "fire_changed": False, + "power_changed": False, + "heartbeat_frozen": False, + "offline": False, + } + + +def _detect_changes(result: dict, state: BuildingState) -> dict: + """ + Compare the current reading to the previous one. + Set the change-flag fields in the result dict. + + TO ADD A NEW DETECTION: + 1. Add an if-block below. + 2. Set the flag key in result. + 3. Add a log line. + """ + now = time.monotonic() + + # ── Fire state change ─────────────────────────────────────────────── # + if state.fire is not None and result["fire"] != state.fire: + result["fire_changed"] = True + log.warning( + "[%s] FIRE STATE CHANGED: %s → %s", + result["building_id"], + config.FIRE_LABELS.get(state.fire, state.fire), + result["fire_label"], + ) + + # ── Power state change ────────────────────────────────────────────── # + if state.power is not None and result["power"] != state.power: + result["power_changed"] = True + log.warning( + "[%s] POWER STATE CHANGED: %s → %s", + result["building_id"], + config.POWER_LABELS.get(state.power, state.power), + result["power_label"], + ) + + # ── Heartbeat staleness (frozen MCU) ──────────────────────────────── # + hb = result["heartbeat"] + if state.heartbeat is None: + # First reading — just record it + state.heartbeat = hb + state.hb_last_moved = now + elif hb != state.heartbeat: + # Counter advanced — healthy + state.heartbeat = hb + state.hb_last_moved = now + else: + # Counter did not move — check how long + stale_for = now - state.hb_last_moved + if stale_for >= config.HEARTBEAT_STALE_S: + result["heartbeat_frozen"] = True + log.warning( + "[%s] HEARTBEAT FROZEN for %.0fs (MCU may be hung)", + result["building_id"], + stale_for, + ) + + return result + + +def _record_failure(state: BuildingState, reason: str) -> None: + """Increment the failure counter; mark building OFFLINE if threshold reached.""" + state.failures += 1 + log.warning( + "[%s] Poll failed (%s) — consecutive failures: %d/%d", + state.building_id, reason, state.failures, config.FAILURE_THRESHOLD, + ) + + if state.failures >= config.FAILURE_THRESHOLD and not state.offline: + state.offline = True + state.offline_since = time.monotonic() + log.error( + "[%s] BUILDING OFFLINE — %d consecutive failures.", + state.building_id, state.failures, + ) diff --git a/fire-sim/.dockerignore b/fire-sim/.dockerignore new file mode 100644 index 0000000..b09c5bb --- /dev/null +++ b/fire-sim/.dockerignore @@ -0,0 +1,13 @@ +__pycache__ +*.pyc +*.pyo +.venv +venv +.env +.git +.gitignore +.pytest_cache +.mypy_cache +*.egg-info +build +dist diff --git a/fire-sim/.gitignore b/fire-sim/.gitignore new file mode 100644 index 0000000..cc070f4 --- /dev/null +++ b/fire-sim/.gitignore @@ -0,0 +1,11 @@ +__pycache__/ +*.py[cod] +*.egg-info/ +.venv/ +venv/ +.env +.pytest_cache/ +.mypy_cache/ +build/ +dist/ +manifest.json diff --git a/fire-sim/Dockerfile b/fire-sim/Dockerfile new file mode 100644 index 0000000..d0d335c --- /dev/null +++ b/fire-sim/Dockerfile @@ -0,0 +1,18 @@ +FROM python:3.12-slim + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 \ + PIP_DISABLE_PIP_VERSION_CHECK=1 + +WORKDIR /app + +COPY pyproject.toml ./ +COPY src ./src + +RUN pip install --no-cache-dir . + +# Modbus servers + control API +EXPOSE 5020-5119 8080 + +ENTRYPOINT ["fire-sim"] +CMD ["--buildings", "100", "--host", "0.0.0.0", "--base-port", "5020", "--api-port", "8080"] diff --git a/fire-sim/README.md b/fire-sim/README.md new file mode 100644 index 0000000..50a6cb8 --- /dev/null +++ b/fire-sim/README.md @@ -0,0 +1,135 @@ +# fire-sim + +Modbus TCP simulator that mimics 100 (or N) STM32-backed fire panels on a +single host, so you can develop and load-test the central poller / time-series +pipeline / dashboard without 100 real buildings. + +Each simulated building runs its own Modbus TCP server on its own port, +exposes the same input-register map as the real firmware, ticks a heartbeat +counter once per second, and can be told to fail in realistic ways +(alarm, fault, MCU freeze, network offline). + +--- + +## Register map (Modbus input registers, FC=4) + +| 1-based | 0-based | Name | Notes | +|--------:|--------:|-------------------|----------------------------------------| +| 30001 | 0 | Fire state | 0=normal, 1=alarm, 2=fault | +| 30002 | 1 | Supervisory state | 0=normal, 1=trouble | +| 30003 | 2 | Power state | 0=mains, 1=battery, 2=fail | +| 30004 | 3 | Heartbeat low 16 | Increments every 1 s (when not frozen) | +| 30005 | 4 | Firmware version | e.g. 0x0100 = v1.0 | +| 30006 | 5 | Uptime low 16 | seconds | +| 30007 | 6 | Uptime high 16 | seconds | +| 30008 | 7 | Heartbeat high 16 | combined: 32-bit counter | + +--- + +## Quickstart (local) + +```bash +cd fire-sim +python -m venv .venv && source .venv/bin/activate +pip install -e . + +# Start 100 simulated panels (ports 5020-5119) + control API on :8080 +fire-sim --buildings 100 --base-port 5020 --api-port 8080 +``` + +In another terminal, poll all 100 with the bundled load-test client: + +```bash +fire-sim-poll --buildings 100 --rounds 5 --interval 1.0 +# [round 1/5] elapsed=0.18s ok=100 fail=0 p50=2.1ms p95=4.7ms p99=6.2ms max=8.0ms +``` + +--- + +## Docker + +```bash +docker compose up --build +# control API: http://localhost:8080 +# Modbus panels: tcp://localhost:5020 .. tcp://localhost:5119 +``` + +--- + +## Driving scenarios via the control API + +```bash +# Trigger a fire alarm on building 42 +curl -X POST localhost:8080/buildings/BLDG-042/fire \ + -H 'content-type: application/json' \ + -d '{"state":"alarm"}' + +# Take a building offline (server stops listening) +curl -X POST localhost:8080/buildings/BLDG-007/offline \ + -H 'content-type: application/json' \ + -d '{"enabled":true}' + +# Freeze an MCU (server up, heartbeat stops) +curl -X POST localhost:8080/buildings/BLDG-013/frozen \ + -H 'content-type: application/json' \ + -d '{"enabled":true}' + +# Alarm storm: 25 random alarms at once +curl -X POST localhost:8080/scenarios/storm \ + -H 'content-type: application/json' -d '{"count":25}' + +# Toggle continuous chaos mode (random failures + auto-recovery) +curl -X POST localhost:8080/scenarios/chaos \ + -H 'content-type: application/json' -d '{"enabled":true}' + +# Reset the whole fleet to a healthy baseline +curl -X POST localhost:8080/scenarios/reset + +# Discovery +curl localhost:8080/manifest +curl localhost:8080/buildings | jq '.[0]' +``` + +Full endpoint list: + +| Method | Path | Body | +|-------:|-----------------------------------|----------------------------| +| GET | `/health` | - | +| GET | `/manifest` | - | +| GET | `/buildings` | - | +| GET | `/buildings/{id}` | - | +| POST | `/buildings/{id}/fire` | `{"state":"normal\|alarm\|fault"}` | +| POST | `/buildings/{id}/supervisory` | `{"state":"normal\|trouble"}` | +| POST | `/buildings/{id}/power` | `{"state":"mains\|battery\|fail"}` | +| POST | `/buildings/{id}/frozen` | `{"enabled":true\|false}` | +| POST | `/buildings/{id}/offline` | `{"enabled":true\|false}` | +| POST | `/scenarios/storm` | `{"count":N}` | +| POST | `/scenarios/random-offline` | `{"count":N}` | +| POST | `/scenarios/random-freeze` | `{"count":N}` | +| POST | `/scenarios/chaos` | `{"enabled":true\|false}` | +| POST | `/scenarios/reset` | - | + +--- + +## Failure modes you can exercise + +| Real-world failure | How to simulate | What the poller should do | +|----------------------------|--------------------------------------------|----------------------------------------------------------| +| Fire detected | `POST .../fire {"state":"alarm"}` | Emit `ALARM_STARTED`, alert ops | +| Panel trouble | `POST .../supervisory {"state":"trouble"}` | Raise supervisory event (non-fire) | +| Mains lost / on battery | `POST .../power {"state":"battery"}` | Surface as warning, escalate after grace period | +| MCU frozen / firmware hang | `POST .../frozen {"enabled":true}` | Detect via stale heartbeat, mark `BUILDING_OFFLINE` | +| Building network down | `POST .../offline {"enabled":true}` | Connect/read fails, mark `BUILDING_OFFLINE` | +| Mass event | `POST /scenarios/storm {"count":25}` | Handle without alarm-storm UI lockup | +| Soak-test | `POST /scenarios/chaos {"enabled":true}` | Validate alert dedup, recovery, dashboard responsiveness | + +--- + +## Tips + +- Spin up 200 buildings? `--buildings 200 --base-port 5020`. You'll need + to widen the Docker port range, or run without Docker. +- Need a building list for your poller? Use `--manifest-out manifest.json` + or `GET /manifest`. +- The simulator answers Modbus reads even during chaos; only `offline` + events stop the listening socket. diff --git a/fire-sim/docker-compose.yml b/fire-sim/docker-compose.yml new file mode 100644 index 0000000..7a33b3d --- /dev/null +++ b/fire-sim/docker-compose.yml @@ -0,0 +1,21 @@ +services: + fire-sim: + build: . + container_name: fire-sim + ports: + - "8080:8080" # control API + - "5020-5119:5020-5119" # 100 simulated panels + environment: + - PYTHONUNBUFFERED=1 + command: + - --buildings + - "100" + - --base-port + - "5020" + - --api-port + - "8080" + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:8080/health').status==200 else 1)"] + interval: 10s + timeout: 3s + retries: 5 diff --git a/fire-sim/docs/MODBUS_PROTOCOL_OVERVIEW.md b/fire-sim/docs/MODBUS_PROTOCOL_OVERVIEW.md new file mode 100644 index 0000000..0d5b510 --- /dev/null +++ b/fire-sim/docs/MODBUS_PROTOCOL_OVERVIEW.md @@ -0,0 +1,348 @@ +# Modbus TCP Protocol — Visual Overview + +**Document ID:** FIRE-MODBUS-OVERVIEW-001 +**Version:** 1.0.0 +**Status:** Reference +**Audience:** Firmware engineers, poller developers, QA, on-call operators + +--- + +## 1. The Big Picture + +Modbus TCP is a **client/server, request/response** protocol carried over a normal TCP connection. There is exactly one client (the poller) initiating each transaction; the server (the STM32) only ever responds. + +``` + ┌──────────────────────────────────────────────┐ + │ Building site │ + │ │ + │ Fire Panel │ + │ │ dry-contact relays (Fire / Trouble / │ + │ │ Mains / Battery) │ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ STM32 MCU │ │ + │ │ Modbus SERVER │ TCP :502 │ + │ │ (input regs) │ │ + │ └────────▲────────┘ │ + │ │ │ + └────────────┼─────────────────────────────────┘ + │ TCP/IP over VPN/LTE/MPLS + │ + ┌────────────┼─────────────────────────────────┐ + │ ▼ │ + │ ┌─────────────────┐ │ + │ │ Central Poller │ (one per region, │ + │ │ Modbus CLIENT │ active-active) │ + │ └────────┬────────┘ │ + │ │ events to broker (NATS/Kafka) │ + │ ▼ │ + │ Time-series DB + UI │ + │ Central data center │ + └──────────────────────────────────────────────┘ +``` + +**Key properties:** +- One TCP connection per (poller ↔ panel) pair, kept alive across many polls +- Client always speaks first; server never pushes +- Polls happen every 1–2 seconds, indefinitely + +--- + +## 2. Modbus TCP Frame Anatomy + +Every Modbus TCP message has two parts: the **MBAP header** (transport metadata) and the **PDU** (the actual Modbus payload). + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ Modbus TCP frame │ +├──────────────── MBAP header (7 bytes) ──────────────┬───── PDU ─────┤ +│ │ │ +│ ┌─────────┬─────────┬─────────┬───────┐ │ ┌───┬───────┐ │ +│ │ TxID │ ProtoID │ Length │ UnitID│ │ │FC │ Data │ │ +│ │ 2 bytes │ 2 bytes │ 2 bytes │1 byte │ │ │ 1B│ N B │ │ +│ └─────────┴─────────┴─────────┴───────┘ │ └───┴───────┘ │ +│ │ │ │ │ │ │ │ +│ │ │ │ │ │ │ │ +│ │ │ │ └─ Slave address │ │ │ +│ │ │ │ (1 = our panel) │ │ │ +│ │ │ └─ Bytes that follow │ │ │ +│ │ │ (UnitID + PDU) │ │ │ +│ │ └─ Always 0x0000 = "Modbus" │ │ │ +│ └─ Match request/response (any uint16) │ │ │ +│ │ │ │ +│ Function code (1=coil read, 3=hr, │ │ │ +│ 4=ir read, 6=write single, …) ────────────────┘ │ │ +│ │ │ +│ Function-specific data: │ │ +│ • Read request: starting address (2 B) + count (2 B)│ │ +│ • Read response: byte count (1 B) + register data │ │ +│ • Exception: original FC | 0x80, then 1-byte code │ │ +│ │ │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +| Field | Size | Purpose | +|---|---|---| +| Transaction ID | 2 B | Lets the client match a response to its request when pipelining | +| Protocol ID | 2 B | Always `0x0000` for Modbus | +| Length | 2 B | Number of bytes after this field (Unit ID + PDU) | +| Unit ID | 1 B | Slave address; for TCP servers usually `0x01` | +| Function Code | 1 B | Tells the server *what* to do | +| Data | variable | Function-specific arguments / payload | + +--- + +## 3. A Complete Read Transaction (Sequence) + +The poller wants the fire panel's status. This is what happens on the wire: + +``` + POLLER (client) STM32 (server) + │ │ + │ ───── TCP SYN ──────────────────────────────▶ │ \ + │ ◀──── TCP SYN+ACK ───────────────────────── │ │ TCP handshake + │ ───── TCP ACK ──────────────────────────────▶ │ / (once per connection; + │ │ reused for many polls) + │ │ + │ ┌───────────────────────────────────────────────┐ │ + │ │ Modbus REQUEST: Read Input Registers (FC=04) │ │ + │ │ TxID=0x0001 Proto=0x0000 Len=0x0006 │ │ + │ │ UnitID=0x01 FC=0x04 │ │ + │ │ StartAddr=0x0000 Count=0x0008 │ │ + │ │ "give me 8 input registers starting at 0" │ │ + │ └───────────────────────────────────────────────┘ │ + │ ──────────────────────────────────────────▶ │ + │ │ read register + │ │ block (~50 µs) + │ │ + │ ┌───────────────────────────────────────────────┐ │ + │ │ Modbus RESPONSE │ │ + │ │ TxID=0x0001 Proto=0x0000 Len=0x0013 │ │ + │ │ UnitID=0x01 FC=0x04 │ │ + │ │ ByteCount=0x10 │ │ + │ │ Reg0..Reg7 = 16 bytes of register data │ │ + │ └───────────────────────────────────────────────┘ │ + │ ◀────────────────────────────────────────── │ + │ │ + │ decode → emit event to broker │ + │ │ + │ (next poll uses same TCP socket, TxID=0x0002) │ + │ │ + ▼ ▼ +``` + +**Timing budget for one read** (typical): +- Network round trip (LAN/VPN): 5–50 ms +- STM32 register lookup: < 1 ms +- Total p99 from poller: **< 200 ms** (per spec §7) + +--- + +## 4. Byte-Level Example — Reading the Fire Panel Status + +Concrete bytes for "read 8 input registers starting at address 0" (i.e., the first 8 registers from the [register map](./STM32_MODBUS_REGISTER_MAP.md)). + +### 4.1 Request from poller → panel + +``` +Hex: 00 01 00 00 00 06 01 04 00 00 00 08 + └──┬─┘ └──┬─┘ └──┬─┘ └┬┘ └┬┘ └──┬─┘ └──┬─┘ +TxID = 1 ──────┘ │ │ │ │ │ │ +ProtoID = 0 ─────────┘ │ │ │ │ │ +Length = 6 bytes ──────────┘ │ │ │ │ (UnitID + FC + StartAddr + Count = 6) +UnitID = 1 ──────────────────────┘ │ │ │ +FC = 4 (Read Input Registers) ───────┘ │ │ +StartAddr = 0 (= register 30001) ──────────┘ │ +Count = 8 registers ──────────────────────────────┘ +``` + +Total: **12 bytes on the wire**. + +### 4.2 Response from panel → poller + +The panel answers with the 16 bytes (8 registers × 2 bytes) representing live status: + +``` +Hex: 00 01 00 00 00 13 01 04 10 00 01 00 00 00 00 00 00 00 0A 01 00 00 0F 00 0A 01 00 + └──┬─┘ └──┬─┘ └──┬─┘ └┬┘ └┬┘ └┬┘ └────────────────────────────────────────────────────┘ +TxID echo ─────┘ │ │ │ │ │ 8 input registers, big-endian, 2 bytes each: +ProtoID = 0 ─────────┘ │ │ │ │ +Length = 19 ───────────────┘ │ │ │ Reg0 FIRE_STATE = 0x0001 → ALARM! +UnitID = 1 ──────────────────────┘ │ │ Reg1 SUPERVISORY_STATE = 0x0000 → normal +FC = 4 echo ─────────────────────────┘ │ Reg2 POWER_STATE = 0x0000 → mains +ByteCount = 16 (= 8 regs × 2) ───────────┘ Reg3 HEARTBEAT_HIGH = 0x0000 + Reg4 HEARTBEAT_LOW = 0x000A → counter = 10 + Reg5 UPTIME_HIGH = 0x0000 + Reg6 UPTIME_LOW = 0x0010 → 16 s + Reg7 FW_VERSION = 0x0F00... wait no +``` + +Wait — let me redo the response cleanly so the bytes line up with the registers: + +``` +Response payload (16 bytes): + + 00 01 00 00 00 00 00 00 00 0A 00 00 00 10 01 00 + ├───┤ ├───┤ ├───┤ ├───┤ ├───┤ ├───┤ ├───┤ ├───┤ + Reg0 Reg1 Reg2 Reg3 Reg4 Reg5 Reg6 Reg7 + FIRE SUPER POWER HB_HI HB_LO UP_HI UP_LO FW_VER + + 0x0001 0x0000 0x0000 0x0000 0x000A 0x0000 0x0010 0x0100 + + ALARM normal mains ┌─────────────┐ ┌─────────────┐ v1.0 + │ heartbeat │ │ uptime │ + │ = 10 │ │ = 16 s │ + └─────────────┘ └─────────────┘ +``` + +The poller decodes this as: *"BLDG-042 has gone into fire alarm; MCU heartbeat is healthy at 10; panel has been up 16 seconds."* + +--- + +## 5. The Exception Path — When the Server Says "No" + +If the request is malformed (bad address, unsupported function), the server returns an **exception response**: same TxID, same UnitID, but the function code has bit 7 set, followed by a 1-byte exception code. + +``` +Normal response: ... UnitID=01 FC=04 ByteCount Data ... +Exception response: ... UnitID=01 FC=84 ExCode + └──┬─┘ └─┬──┘ + │ │ + FC | 0x80 ─┘ └─ 01=ILLEGAL_FUNCTION + 02=ILLEGAL_DATA_ADDRESS + 03=ILLEGAL_DATA_VALUE + 04=SLAVE_DEVICE_FAILURE + 06=SLAVE_DEVICE_BUSY +``` + +**Important:** an exception is **not** a failure mode for the building — it just means the request was wrong. The poller should log it and **not** raise `BUILDING_OFFLINE`. + +--- + +## 6. Function Code Cheat Sheet (used by fire-sim) + +``` +┌──────┬───────────────────────────────┬───────────┬─────────────────────────────┐ +│ FC │ Operation │ R/W │ Used in this project for │ +├──────┼───────────────────────────────┼───────────┼─────────────────────────────┤ +│ 01 │ Read Coils │ Read │ (not used) │ +│ 02 │ Read Discrete Inputs │ Read │ (not used) │ +│ 03 │ Read Holding Registers │ Read │ Identity / config (40001+) │ +│ 04 │ Read Input Registers │ Read │ Live status (30001+) ★ │ +│ 05 │ Write Single Coil │ Write │ (not used) │ +│ 06 │ Write Single Register │ Write │ Watchdog kick (40101) │ +│ 15 │ Write Multiple Coils │ Write │ (not used) │ +│ 16 │ Write Multiple Registers │ Write │ (not used) │ +└──────┴───────────────────────────────┴───────────┴─────────────────────────────┘ + ★ = the hot path, every 1–2 s +``` + +--- + +## 7. Address Space Mental Model + +Modbus addresses are notoriously confusing because of the historical "1-based with table prefix" convention. The simulator and our spec use **0-based offsets within each table** at the protocol level, but document **1-based, prefixed** addresses for human readers. + +``` +Human-friendly notation Protocol notation (on-wire) +───────────────────── ───────────────────────────── +Coils 0xxxx FC 01 / 05 / 15, addr starts at 0 +Discrete inputs 1xxxx FC 02, addr starts at 0 +Input registers 3xxxx ◀─★ FC 04, addr starts at 0 +Holding regs 4xxxx FC 03 / 06 / 16, addr starts at 0 + +Examples: + "Read 30001" → FC=04, address=0x0000 (subtract 30001) + "Read 30005" → FC=04, address=0x0004 + "Read 40101" → FC=06, address=0x0064 (write) +``` + +In code (pymodbus), you always use the **0-based offset**: +```python +# Read FIRE_STATE through FW_VERSION (registers 30001..30008) +rr = await client.read_input_registers(address=0, count=8) +``` + +--- + +## 8. State Machine of a Healthy Poller-Panel Connection + +``` + ┌────────────────┐ + startup │ DISCONNECTED │ + ──────────▶ │ │ + └───────┬────────┘ + │ open TCP + ▼ + ┌────────────────┐ + │ CONNECTING │ + └───┬────────┬───┘ + fail │ │ TCP established + ┌─────────┘ ▼ + │ ┌────────────────┐ + │ │ CONNECTED │◀────────────┐ + │ │ (idle) │ │ + │ └───────┬────────┘ │ + │ │ schedule poll │ + │ ▼ │ + │ ┌────────────────┐ │ + │ │ POLLING │ │ + │ └───┬────────┬───┘ │ + │ timeout │ │ response ok │ + │ or exception│ └─────────────────┘ + │ │ + │ ▼ + │ ┌────────────────┐ + │ │ ERROR │ + │ │ (count, retry) │ + │ └───┬────────┬───┘ + │ │ │ + │ │ └────────── recover ──┐ + │ ▼ │ + │ ┌────────────────┐ │ + │ │ CIRCUIT OPEN │ emit │ + │ │ (BLDG_OFFLINE) │ BUILDING_OFFLINE │ + │ └───────┬────────┘ │ + │ │ backoff timer │ + └───────────┘ (1s → 2s → 4s … → 30s cap) │ + │ + reconnect succeeds ───────────────── ┘ +``` + +The poller's **circuit breaker** prevents one slow building from starving the others. + +--- + +## 9. Where Modbus Stops and Our System Begins + +Modbus is intentionally minimalist — it has **no** authentication, **no** encryption, **no** push notifications, and **no** time. Our architecture wraps it with everything it lacks: + +``` +┌──────────────────────────────────────────────────────────────────────┐ +│ Layer-by-layer responsibility │ +├──────────────────────────────────────────────────────────────────────┤ +│ Modbus TCP → Just moves register values request-by-request │ +│ TLS over VPN → Confidentiality + integrity (Modbus has none) │ +│ TCP keepalive → Detect dead connections │ +│ Poller heartbeat → Detect frozen MCUs (Modbus can't tell you this) │ +│ NTP-synced poller → Authoritative timestamps (panel has no clock) │ +│ Broker (NATS) → Push, fan-out, replay (Modbus is pull-only) │ +│ TimescaleDB → History + queryable trends │ +│ FastAPI + UI → Operator workflow + acknowledgement + audit │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +This is why the register map design from §1 of the spec is as small as it is: keep the Modbus contract minimal and correct, then add reliability features in layers above. + +--- + +## 10. See Also + +- [`STM32_MODBUS_REGISTER_MAP.md`](./STM32_MODBUS_REGISTER_MAP.md) — exact register addresses and encodings +- [`fire-sim/src/fire_sim/registers.py`](../src/fire_sim/registers.py) — code constants +- [`fire-sim/src/fire_sim/loadtest.py`](../src/fire_sim/loadtest.py) — example poller using FC 04 +- [Modbus.org — Application Protocol Specification v1.1b3](https://modbus.org/specs.php) (canonical reference) + +--- + +*End of document.* diff --git a/fire-sim/docs/STM32_MODBUS_REGISTER_MAP.md b/fire-sim/docs/STM32_MODBUS_REGISTER_MAP.md new file mode 100644 index 0000000..e0515b6 --- /dev/null +++ b/fire-sim/docs/STM32_MODBUS_REGISTER_MAP.md @@ -0,0 +1,307 @@ +# STM32 Fire Panel — Modbus TCP Register Map Specification + +**Document ID:** FIRE-REG-SPEC-001 +**Version:** 1.0.0 +**Status:** DRAFT +**Date:** 2026-05-21 +**Author:** Fire Monitoring Engineering Team + +--- + +## 1. Purpose + +This document defines the Modbus TCP register map exposed by the STM32 controller attached to each building's fire alarm panel. It is the **single source of truth** for: + +- STM32 firmware engineers implementing the Modbus server +- Central poller software reading building status +- The simulator (`fire-sim`) mimicking real panels for testing +- Quality assurance verifying end-to-end data flow + +Any change to this spec requires a version bump and coordinated update across firmware, poller, and simulator. + +--- + +## 2. Transport + +| Parameter | Value | +|---|---| +| Protocol | Modbus TCP (RFC-compliant MBAP header) | +| Default port | 502 (production) / 5020+ (simulator) | +| Unit ID (slave) | 1 (single device per connection) | +| Byte order | Big-endian (Modbus standard) | +| Word order (32-bit) | High word first (register N = MSW, register N+1 = LSW) | +| Max concurrent connections | 4 (firmware limit; poller should use 1) | +| Response timeout | ≤ 200 ms under normal operation | + +--- + +## 3. Supported Function Codes + +| FC | Name | Access | Used for | +|---|---|---|---| +| 04 | Read Input Registers | Read-only | All status and telemetry | +| 03 | Read Holding Registers | Read-only | Configuration / identity | +| 06 | Write Single Register | Write | Watchdog kick (optional) | + +The controller exposes **no coils or discrete inputs**. All data is register-based. + +--- + +## 4. Input Registers (FC 04) — Status & Telemetry + +These registers are **read-only** and updated by the STM32 firmware in real time. + +### 4.1 Register Table + +| 1-Based Address | 0-Based Offset | Name | Data Type | Range / Encoding | Update Rate | +|---:|---:|---|---|---|---| +| 30001 | 0 | `FIRE_STATE` | UINT16 | See §4.2 | On change + every tick | +| 30002 | 1 | `SUPERVISORY_STATE` | UINT16 | See §4.3 | On change + every tick | +| 30003 | 2 | `POWER_STATE` | UINT16 | See §4.4 | On change + every tick | +| 30004 | 3 | `HEARTBEAT_HIGH` | UINT16 | MSW of 32-bit counter | Every 1 s | +| 30005 | 4 | `HEARTBEAT_LOW` | UINT16 | LSW of 32-bit counter | Every 1 s | +| 30006 | 5 | `UPTIME_HIGH` | UINT16 | MSW of 32-bit seconds | Every 1 s | +| 30007 | 6 | `UPTIME_LOW` | UINT16 | LSW of 32-bit seconds | Every 1 s | +| 30008 | 7 | `FW_VERSION` | UINT16 | `0xMMNN` = major.minor | Static | +| 30009 | 8 | `ZONE_COUNT` | UINT16 | 0–255 | Static after boot | +| 30010 | 9 | `ACTIVE_ZONE_MASK_0` | UINT16 | Bit mask zones 1–16 | On change | +| 30011 | 10 | `ACTIVE_ZONE_MASK_1` | UINT16 | Bit mask zones 17–32 | On change | +| 30012 | 11 | `DETECTOR_COUNT` | UINT16 | Total addressable detectors | Static | +| 30013 | 12 | `DETECTORS_IN_ALARM` | UINT16 | Count currently in alarm | On change | +| 30014 | 13 | `DETECTORS_IN_FAULT` | UINT16 | Count currently in fault | On change | +| 30015 | 14 | `PANEL_BATTERY_V` | UINT16 | Voltage × 100 (e.g. 2430 = 24.30 V) | Every 5 s | +| 30016 | 15 | `PANEL_SUPPLY_V` | UINT16 | Voltage × 100 | Every 5 s | +| 30017 | 16 | `MCU_TEMP_C` | INT16 | Temperature × 10 (e.g. 345 = 34.5 °C) | Every 10 s | +| 30018 | 17 | `COMM_ERROR_COUNT` | UINT16 | Cumulative since boot | On change | +| 30019 | 18 | `LAST_EVENT_CODE` | UINT16 | See §4.6 | On change | +| 30020 | 19 | `LAST_EVENT_ZONE` | UINT16 | Zone # of last event (0 = global) | On change | +| 30021–30032 | 20–31 | *RESERVED* | — | Must read as 0x0000 | — | + +**Total input registers:** 32 (offsets 0–31). + +### 4.2 `FIRE_STATE` Encoding + +| Value | Meaning | Description | +|---:|---|---| +| 0 | `NORMAL` | No fire condition detected on any zone | +| 1 | `ALARM` | One or more zones in fire alarm state | +| 2 | `FAULT` | Panel detects a wiring/sensor fault preventing reliable detection | +| 3 | `DISABLED` | Panel or all zones deliberately disabled (maintenance mode) | + +**Priority:** If both ALARM and FAULT exist simultaneously, `FIRE_STATE = 1` (alarm takes precedence). + +### 4.3 `SUPERVISORY_STATE` Encoding + +| Value | Meaning | Description | +|---:|---|---| +| 0 | `NORMAL` | No supervisory conditions | +| 1 | `TROUBLE` | Non-fire supervisory event (sprinkler valve tamper, pre-alarm, etc.) | +| 2 | `SILENCED` | Audible alarm silenced by local panel operator | + +### 4.4 `POWER_STATE` Encoding + +| Value | Meaning | Description | +|---:|---|---| +| 0 | `MAINS` | Running on mains AC supply | +| 1 | `BATTERY` | Mains lost; running on battery backup | +| 2 | `FAIL` | Both mains and battery critically low / absent | +| 3 | `CHARGING` | Mains restored; battery charging | + +### 4.5 Heartbeat Counter + +The heartbeat is a **free-running 32-bit unsigned counter** stored as two 16-bit registers (big-endian word order): + +``` +heartbeat_value = (HEARTBEAT_HIGH << 16) | HEARTBEAT_LOW +``` + +**Behavior:** +- Increments by exactly **1** every **1 second** (±50 ms jitter acceptable). +- **Wraps** from `0xFFFFFFFF` → `0x00000000`. +- If the MCU hangs or the firmware enters an unrecoverable state, the counter **stops advancing** while the Modbus server may still respond (this is how the central poller detects a "frozen MCU" failure). +- After a power-cycle reboot, the counter **resets to 0**. + +**Poller detection logic:** +``` +IF (current_heartbeat == previous_heartbeat) for N consecutive polls: + → Mark building as BUILDING_OFFLINE / MCU_FROZEN +``` + +Recommended threshold: **N = 5** (i.e., 5 seconds of stale heartbeat). + +### 4.6 `LAST_EVENT_CODE` Encoding + +| Code | Meaning | +|---:|---| +| 0x0000 | No event since boot | +| 0x0001 | Fire alarm activated | +| 0x0002 | Fire alarm cleared | +| 0x0003 | Supervisory trouble raised | +| 0x0004 | Supervisory trouble cleared | +| 0x0005 | Mains power lost | +| 0x0006 | Mains power restored | +| 0x0007 | Battery low | +| 0x0008 | Detector fault raised | +| 0x0009 | Detector fault cleared | +| 0x000A | Panel reset by operator | +| 0x000B | Zone disabled | +| 0x000C | Zone enabled | +| 0x00FF | Watchdog reboot occurred | +| 0x8000–0xFFFF | *Vendor-specific / reserved* | + +--- + +## 5. Holding Registers (FC 03) — Configuration & Identity + +These registers are read-only from the Modbus network perspective. Configuration is done locally on the STM32 via UART/USB or flash. + +| 1-Based Address | 0-Based Offset | Name | Data Type | Description | +|---:|---:|---|---|---| +| 40001 | 0 | `BUILDING_ID_0` | UINT16 | ASCII chars 1–2 of building ID | +| 40002 | 1 | `BUILDING_ID_1` | UINT16 | ASCII chars 3–4 | +| 40003 | 2 | `BUILDING_ID_2` | UINT16 | ASCII chars 5–6 | +| 40004 | 3 | `BUILDING_ID_3` | UINT16 | ASCII chars 7–8 | +| 40005 | 4 | `BUILDING_ID_4` | UINT16 | ASCII chars 9–10 | +| 40006 | 5 | `HW_REVISION` | UINT16 | PCB hardware revision (e.g. 0x0200 = rev 2.0) | +| 40007 | 6 | `SERIAL_HIGH` | UINT16 | MSW of 32-bit serial number | +| 40008 | 7 | `SERIAL_LOW` | UINT16 | LSW of 32-bit serial number | +| 40009 | 8 | `POLL_INTERVAL_MS` | UINT16 | How often firmware reads the panel (typ. 500) | +| 40010 | 9 | `BOOT_COUNT` | UINT16 | Number of reboots since factory flash | +| 40011–40016 | 10–15 | *RESERVED* | — | Must read as 0x0000 | + +**Building ID encoding:** +``` +"BLDG-042\0\0" → 40001=0x424C 40002=0x4447 40003=0x2D30 40004=0x3432 40005=0x0000 +``` +(ASCII, zero-padded, no null terminator needed if all 10 chars used.) + +--- + +## 6. Write Registers (FC 06) — Watchdog Kick (Optional) + +A single writable register allows the central poller to signal "I'm alive" back to the STM32. This is a **reverse heartbeat** and is entirely optional. + +| 1-Based Address | 0-Based Offset | Name | Data Type | Description | +|---:|---:|---|---|---| +| 40101 | 100 | `WATCHDOG_KICK` | UINT16 | Write any non-zero value to reset the remote watchdog timer | + +**Firmware behavior (if implemented):** +- If `WATCHDOG_KICK` is not written for > 60 seconds, the STM32 may: + - Set `SUPERVISORY_STATE = TROUBLE` and event code `0x0003` + - Flash a local "COMMS LOST" LED +- This lets on-site personnel know the central system has stopped watching, without affecting the fire panel's own local alarm capability. +- The poller should write this register every 10–30 seconds. + +**If not implemented:** The holding register address simply returns an exception code (02 = ILLEGAL DATA ADDRESS) and is safely ignored. + +--- + +## 7. Timing & Performance Requirements + +| Parameter | Requirement | Note | +|---|---|---| +| Heartbeat increment accuracy | ±50 ms | Averaged over any 60 s window | +| Response to Modbus read | < 200 ms | 99th percentile | +| Register update after relay change | < 100 ms | Time from physical relay trip to register update | +| Concurrent TCP connections | ≥ 2 (goal 4) | One for poller, one spare for diagnostics | +| Time to first response after boot | < 5 seconds | Poller will retry if no response | +| Uptime counter overflow | ~136 years | Not a concern in practice | + +--- + +## 8. Error Handling + +| Condition | STM32 Behavior | Poller Observation | +|---|---|---| +| Panel communication loss (relay bus) | `FIRE_STATE = 2` (FAULT) | Register reads succeed but state = FAULT | +| MCU firmware hang | Heartbeat stops; Modbus server may or may not respond | Stale heartbeat / timeout | +| Hardware watchdog fires | MCU reboots; heartbeat resets to 0; `LAST_EVENT_CODE = 0x00FF` | Brief offline, then heartbeat restarts from 0 | +| Voltage sag / brownout | May reboot or enter FAULT | See `PANEL_BATTERY_V` dropping; possible offline | +| Invalid Modbus request | Standard exception response (FC + 0x80, code 01/02/03) | Poller should retry, not treat as offline | +| Network cable unplugged | No TCP response | `connect_failed` → mark `BUILDING_OFFLINE` | + +--- + +## 9. Compliance & Safety Notes + +1. **This register map is for supervisory monitoring only.** The STM32 + Modbus layer does **not** replace the certified fire panel's own local alarm logic, sounder activation, or fire brigade notification. + +2. The STM32 reads the fire panel's relay outputs as a **passive observer**. It must never write to or control the fire panel. + +3. All timestamps are generated at the central poller (NTP-synced), not on the STM32. The STM32 has no RTC requirement for this purpose. + +4. The register map intentionally avoids any "acknowledge" or "silence" function — those actions happen only at the certified panel itself or via the authenticated central UI. + +5. Firmware updates to the STM32 should increment `FW_VERSION` and reset `BOOT_COUNT`. The poller should log version mismatches across the fleet. + +--- + +## 10. Register Map Diagram (Visual Reference) + +``` +Input Registers (FC 04): +┌────────────────────────────────────────────────────┐ +│ Offset Name Encoding │ +├────────────────────────────────────────────────────┤ +│ 0 FIRE_STATE 0=OK 1=ALARM 2=FLT │ +│ 1 SUPERVISORY_STATE 0=OK 1=TRBL 2=SIL │ +│ 2 POWER_STATE 0=MAINS 1=BAT 2=FL │ +│ 3 HEARTBEAT_HIGH ┐ │ +│ 4 HEARTBEAT_LOW ┘ 32-bit counter │ +│ 5 UPTIME_HIGH ┐ │ +│ 6 UPTIME_LOW ┘ seconds │ +│ 7 FW_VERSION 0xMMNN │ +│ 8 ZONE_COUNT 0–255 │ +│ 9 ACTIVE_ZONE_MASK_0 bits 1–16 │ +│ 10 ACTIVE_ZONE_MASK_1 bits 17–32 │ +│ 11 DETECTOR_COUNT total │ +│ 12 DETECTORS_IN_ALARM count │ +│ 13 DETECTORS_IN_FAULT count │ +│ 14 PANEL_BATTERY_V V×100 │ +│ 15 PANEL_SUPPLY_V V×100 │ +│ 16 MCU_TEMP_C °C×10 (signed) │ +│ 17 COMM_ERROR_COUNT cumulative │ +│ 18 LAST_EVENT_CODE see table │ +│ 19 LAST_EVENT_ZONE zone # or 0 │ +│ 20–31 RESERVED 0x0000 │ +└────────────────────────────────────────────────────┘ + +Holding Registers (FC 03): +┌────────────────────────────────────────────────────┐ +│ 0–4 BUILDING_ID ASCII, 2 chars/reg │ +│ 5 HW_REVISION 0xMMNN │ +│ 6–7 SERIAL_NUMBER 32-bit │ +│ 8 POLL_INTERVAL_MS typ. 500 │ +│ 9 BOOT_COUNT reboots since flash│ +│ 10–15 RESERVED 0x0000 │ +└────────────────────────────────────────────────────┘ + +Write Register (FC 06): +┌────────────────────────────────────────────────────┐ +│ 100 WATCHDOG_KICK any non-zero value │ +└────────────────────────────────────────────────────┘ +``` + +--- + +## 11. Versioning & Change Log + +| Version | Date | Changes | +|---|---|---| +| 1.0.0 | 2026-05-21 | Initial draft — core registers, heartbeat, event codes | + +--- + +## 12. Cross-References + +| Artefact | Relationship | +|---|---| +| `fire-sim/src/fire_sim/registers.py` | Simulator implementation of this spec (subset) | +| `fire-sim/src/fire_sim/building.py` | Simulator server exercising the register layout | +| `fire-sim/src/fire_sim/loadtest.py` | Poller client reading these registers | +| Central Poller (future) | Production consumer of this register map | +| STM32 Firmware (future) | Production producer of this register map | + +--- + +*End of document.* diff --git a/fire-sim/pyproject.toml b/fire-sim/pyproject.toml new file mode 100644 index 0000000..29298f5 --- /dev/null +++ b/fire-sim/pyproject.toml @@ -0,0 +1,26 @@ +[project] +name = "fire-sim" +version = "0.1.0" +description = "Modbus TCP simulator for fire-panel monitoring load tests" +requires-python = ">=3.9" +authors = [{ name = "Fire Monitoring Team" }] +dependencies = [ + "pymodbus>=3.6,<4", + "fastapi>=0.110", + "uvicorn[standard]>=0.27", + "pydantic>=2.5", +] + +[project.optional-dependencies] +dev = ["pytest>=8", "pytest-asyncio>=0.23"] + +[project.scripts] +fire-sim = "fire_sim.main:main" +fire-sim-poll = "fire_sim.loadtest:main" + +[build-system] +requires = ["setuptools>=68"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/fire-sim/scripts/fullrun.sh b/fire-sim/scripts/fullrun.sh new file mode 100755 index 0000000..25696bc --- /dev/null +++ b/fire-sim/scripts/fullrun.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# End-to-end smoke test: baseline poll, inject failures, observe, reset. +# Assumes fire-sim is already running on :9090 with 100 buildings on 5020+. +set -uo pipefail +cd "$(dirname "$0")/.." +PY=.venv/bin/python +POLL=.venv/bin/fire-sim-poll +API=localhost:9090 + +echo "=== baseline: 100 buildings x 5 rounds ===" +timeout 30 $POLL --buildings 100 --rounds 5 --interval 1.0 + +echo +echo "=== inject failures ===" +curl -s -X POST $API/scenarios/storm -H 'content-type: application/json' -d '{"count":25}' >/dev/null && echo " storm: 25 alarms" +curl -s -X POST $API/scenarios/random-offline -H 'content-type: application/json' -d '{"count":5}' >/dev/null && echo " offline: 5" +curl -s -X POST $API/scenarios/random-freeze -H 'content-type: application/json' -d '{"count":5}' >/dev/null && echo " frozen: 5" +sleep 2 +timeout 30 $POLL --buildings 100 --rounds 1 + +echo +echo "=== state summary ===" +curl -s $API/buildings | $PY -c " +import json, sys +b = json.load(sys.stdin) +fire = [x['id'] for x in b if x['fire']==1] +fault = [x['id'] for x in b if x['fire']==2] +froz = [x['id'] for x in b if x['frozen']] +off = [x['id'] for x in b if x['offline']] +print(f' alarm={len(fire)} fault={len(fault)} frozen={len(froz)} offline={len(off)}') +print(f' offline ids: {off}') +print(f' frozen ids: {froz}') +" + +echo +echo "=== reset and re-poll ===" +curl -s -X POST $API/scenarios/reset >/dev/null +sleep 1 +timeout 30 $POLL --buildings 100 --rounds 1 diff --git a/fire-sim/src/fire_sim/__init__.py b/fire-sim/src/fire_sim/__init__.py new file mode 100644 index 0000000..ba4511c --- /dev/null +++ b/fire-sim/src/fire_sim/__init__.py @@ -0,0 +1,3 @@ +"""Fire-panel Modbus TCP simulator for load-testing 100+ buildings.""" + +__version__ = "0.1.0" diff --git a/fire-sim/src/fire_sim/building.py b/fire-sim/src/fire_sim/building.py new file mode 100644 index 0000000..3c2c521 --- /dev/null +++ b/fire-sim/src/fire_sim/building.py @@ -0,0 +1,214 @@ +"""A single simulated fire panel exposing a Modbus TCP server. + +Each ``Building`` owns: + * its own ``ModbusServerContext`` populated with input registers, + * an asyncio task running the Modbus TCP server on a unique port, + * a tick task that increments the heartbeat / uptime once per second. + +State changes (alarm/fault, power, supervisory, freeze, offline) are +applied via async methods so the HTTP control plane can drive them +without touching internals. + +Failure-injection semantics: + * ``set_frozen(True)`` - server keeps responding but heartbeat freezes. + Simulates a hung MCU / firmware lockup. + * ``set_offline(True)`` - server stops listening on the port. Simulates + network loss or full power failure. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from dataclasses import dataclass + +from pymodbus.datastore import ( + ModbusSequentialDataBlock, + ModbusServerContext, + ModbusSlaveContext, +) +from pymodbus.server import ModbusTcpServer + +from .registers import ( + NUM_REGS, + REG_FIRE, + REG_FW_VERSION, + REG_HEARTBEAT_HIGH, + REG_HEARTBEAT_LOW, + REG_POWER, + REG_SUPERVISORY, + REG_UPTIME_HIGH, + REG_UPTIME_LOW, + FireState, + PowerState, + SupervisoryState, +) + +log = logging.getLogger(__name__) + +# Modbus function code 4 == read input registers. setValues() uses fx codes. +_FX_INPUT_REGISTERS = 4 + + +@dataclass +class BuildingState: + fire: int = int(FireState.NORMAL) + supervisory: int = int(SupervisoryState.NORMAL) + power: int = int(PowerState.MAINS) + heartbeat: int = 0 + uptime: int = 0 + fw_version: int = 0x0100 # v1.0 + frozen: bool = False + offline: bool = False + + +class Building: + """One simulated fire panel + Modbus TCP server.""" + + def __init__(self, building_id: str, host: str, port: int) -> None: + self.id = building_id + self.host = host + self.port = port + self.state = BuildingState() + + self._slave = ModbusSlaveContext( + di=ModbusSequentialDataBlock(0, [0] * NUM_REGS), + co=ModbusSequentialDataBlock(0, [0] * NUM_REGS), + hr=ModbusSequentialDataBlock(0, [0] * NUM_REGS), + ir=ModbusSequentialDataBlock(0, [0] * NUM_REGS), + ) + self._context = ModbusServerContext(slaves=self._slave, single=True) + + self._server: ModbusTcpServer | None = None + self._server_task: asyncio.Task | None = None + self._tick_task: asyncio.Task | None = None + self._t0 = time.monotonic() + + # ------------------------------------------------------------------ + # Lifecycle + # ------------------------------------------------------------------ + async def start(self) -> None: + await self._start_server() + self._tick_task = asyncio.create_task(self._tick_loop(), name=f"tick-{self.id}") + # Push initial register values so first poll gets sensible data. + self._write_registers() + + async def stop(self) -> None: + if self._tick_task: + self._tick_task.cancel() + try: + await self._tick_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + self._tick_task = None + await self._stop_server() + + async def _start_server(self) -> None: + if self._server is not None: + return + self._server = ModbusTcpServer( + context=self._context, + address=(self.host, self.port), + ) + self._server_task = asyncio.create_task( + self._run_server(), name=f"modbus-{self.id}-{self.port}" + ) + # Yield so the server has a chance to bind before callers continue. + await asyncio.sleep(0) + + async def _run_server(self) -> None: + try: + await self._server.serve_forever() + except asyncio.CancelledError: + raise + except Exception: # noqa: BLE001 + log.exception("Modbus server crashed for %s on %s:%s", self.id, self.host, self.port) + + async def _stop_server(self) -> None: + if self._server is not None: + try: + await self._server.shutdown() + except Exception: # noqa: BLE001 + log.exception("error shutting down server for %s", self.id) + self._server = None + if self._server_task is not None: + if not self._server_task.done(): + self._server_task.cancel() + try: + await self._server_task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + self._server_task = None + + # ------------------------------------------------------------------ + # Tick loop: heartbeat + uptime + # ------------------------------------------------------------------ + async def _tick_loop(self) -> None: + while True: + try: + await asyncio.sleep(1.0) + if not self.state.frozen: + self.state.heartbeat = (self.state.heartbeat + 1) & 0xFFFFFFFF + self.state.uptime = int(time.monotonic() - self._t0) + self._write_registers() + except asyncio.CancelledError: + break + except Exception: # noqa: BLE001 + log.exception("tick error for %s", self.id) + + def _write_registers(self) -> None: + regs = [0] * NUM_REGS + regs[REG_FIRE] = self.state.fire + regs[REG_SUPERVISORY] = self.state.supervisory + regs[REG_POWER] = self.state.power + regs[REG_HEARTBEAT_LOW] = self.state.heartbeat & 0xFFFF + regs[REG_HEARTBEAT_HIGH] = (self.state.heartbeat >> 16) & 0xFFFF + regs[REG_FW_VERSION] = self.state.fw_version + regs[REG_UPTIME_LOW] = self.state.uptime & 0xFFFF + regs[REG_UPTIME_HIGH] = (self.state.uptime >> 16) & 0xFFFF + self._slave.setValues(_FX_INPUT_REGISTERS, 0, regs) + + # ------------------------------------------------------------------ + # State control (called from HTTP control plane and chaos engine) + # ------------------------------------------------------------------ + async def set_fire(self, state: FireState) -> None: + self.state.fire = int(state) + self._write_registers() + + async def set_supervisory(self, state: SupervisoryState) -> None: + self.state.supervisory = int(state) + self._write_registers() + + async def set_power(self, state: PowerState) -> None: + self.state.power = int(state) + self._write_registers() + + async def set_frozen(self, frozen: bool) -> None: + self.state.frozen = frozen + + async def set_offline(self, offline: bool) -> None: + if offline and not self.state.offline: + await self._stop_server() + elif not offline and self.state.offline: + await self._start_server() + self._write_registers() + self.state.offline = offline + + # ------------------------------------------------------------------ + # Snapshot for API responses + # ------------------------------------------------------------------ + def snapshot(self) -> dict: + return { + "id": self.id, + "host": self.host, + "port": self.port, + "fire": self.state.fire, + "supervisory": self.state.supervisory, + "power": self.state.power, + "heartbeat": self.state.heartbeat, + "uptime": self.state.uptime, + "fw_version": self.state.fw_version, + "frozen": self.state.frozen, + "offline": self.state.offline, + } diff --git a/fire-sim/src/fire_sim/chaos.py b/fire-sim/src/fire_sim/chaos.py new file mode 100644 index 0000000..a4d1a57 --- /dev/null +++ b/fire-sim/src/fire_sim/chaos.py @@ -0,0 +1,108 @@ +"""Chaos engine: probabilistic failure injection across the fleet. + +Useful for soak-testing: kicks off random alarms, faults, MCU freezes, +and offline events at configurable rates, then auto-recovers each event +after a short delay so the system returns to a healthy baseline. +""" + +from __future__ import annotations + +import asyncio +import logging +import random + +from .registers import FireState, PowerState, SupervisoryState +from .simulator import Simulator + +log = logging.getLogger(__name__) + + +class Chaos: + def __init__( + self, + sim: Simulator, + *, + alarm_per_min: float = 0.1, + fault_per_min: float = 0.05, + offline_per_min: float = 0.05, + freeze_per_min: float = 0.05, + recover_after_s: float = 30.0, + ) -> None: + self.sim = sim + # Convert per-minute rates to per-second probabilities (per building). + self._p_alarm = alarm_per_min / 60.0 + self._p_fault = fault_per_min / 60.0 + self._p_offline = offline_per_min / 60.0 + self._p_freeze = freeze_per_min / 60.0 + self._recover_after_s = recover_after_s + self._task: asyncio.Task | None = None + self._enabled = False + + @property + def enabled(self) -> bool: + return self._enabled + + async def start(self) -> None: + if self._task and not self._task.done(): + return + self._enabled = True + self._task = asyncio.create_task(self._loop(), name="chaos") + log.info( + "Chaos enabled: alarm/min=%.3f fault/min=%.3f offline/min=%.3f freeze/min=%.3f recover=%.1fs", + self._p_alarm * 60, + self._p_fault * 60, + self._p_offline * 60, + self._p_freeze * 60, + self._recover_after_s, + ) + + async def stop(self) -> None: + self._enabled = False + if self._task: + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): # noqa: BLE001 + pass + self._task = None + + async def _loop(self) -> None: + while True: + try: + await asyncio.sleep(1.0) + for b in list(self.sim.buildings.values()): + r = random.random() + if r < self._p_alarm: + await b.set_fire(FireState.ALARM) + asyncio.create_task(self._auto_recover(b, "fire")) + elif r < self._p_alarm + self._p_fault: + await b.set_fire(FireState.FAULT) + asyncio.create_task(self._auto_recover(b, "fire")) + elif r < self._p_alarm + self._p_fault + self._p_offline: + if not b.state.offline: + await b.set_offline(True) + asyncio.create_task(self._auto_recover(b, "offline")) + elif r < self._p_alarm + self._p_fault + self._p_offline + self._p_freeze: + if not b.state.frozen: + await b.set_frozen(True) + asyncio.create_task(self._auto_recover(b, "freeze")) + except asyncio.CancelledError: + break + except Exception: # noqa: BLE001 + log.exception("chaos loop error") + + async def _auto_recover(self, building, kind: str) -> None: + try: + await asyncio.sleep(self._recover_after_s) + if kind == "fire": + await building.set_fire(FireState.NORMAL) + await building.set_supervisory(SupervisoryState.NORMAL) + await building.set_power(PowerState.MAINS) + elif kind == "offline": + await building.set_offline(False) + elif kind == "freeze": + await building.set_frozen(False) + except asyncio.CancelledError: + pass + except Exception: # noqa: BLE001 + log.exception("auto-recover error for %s (%s)", building.id, kind) diff --git a/fire-sim/src/fire_sim/control_api.py b/fire-sim/src/fire_sim/control_api.py new file mode 100644 index 0000000..e59f6d7 --- /dev/null +++ b/fire-sim/src/fire_sim/control_api.py @@ -0,0 +1,183 @@ +"""HTTP control plane for the simulator. + +Lets you script scenarios from any client (curl, requests, k6, etc.): + + POST /buildings/BLDG-042/fire {"state": "alarm"} + POST /buildings/BLDG-042/offline {"enabled": true} + POST /scenarios/storm {"count": 25} + POST /scenarios/chaos {"enabled": true} + POST /scenarios/reset + +The simulator is single-process so all state changes are immediate. +""" + +from __future__ import annotations + +import random + +from fastapi import FastAPI, HTTPException +from pydantic import BaseModel, Field + +from .chaos import Chaos +from .registers import ( + FIRE_FROM_STR, + POWER_FROM_STR, + SUPER_FROM_STR, + FireState, + PowerState, + SupervisoryState, +) +from .simulator import Simulator + + +class FireBody(BaseModel): + state: str = Field(..., description="normal | alarm | fault") + + +class SuperBody(BaseModel): + state: str = Field(..., description="normal | trouble") + + +class PowerBody(BaseModel): + state: str = Field(..., description="mains | battery | fail") + + +class FlagBody(BaseModel): + enabled: bool + + +class CountBody(BaseModel): + count: int = Field(1, ge=1) + + +def make_app(sim: Simulator, chaos: Chaos) -> FastAPI: + app = FastAPI(title="Fire-Sim Control Plane", version="0.1.0") + + # ------------------------------------------------------------------ + # Discovery + # ------------------------------------------------------------------ + @app.get("/health") + async def health(): + return {"status": "ok", "buildings": len(sim.buildings), "chaos": chaos.enabled} + + @app.get("/manifest") + async def manifest(): + return {"buildings": sim.manifest()} + + @app.get("/buildings") + async def list_buildings(): + return [b.snapshot() for b in sim.buildings.values()] + + @app.get("/buildings/{bid}") + async def get_building(bid: str): + b = sim.buildings.get(bid) + if not b: + raise HTTPException(status_code=404, detail=f"unknown building {bid}") + return b.snapshot() + + # ------------------------------------------------------------------ + # Per-building state mutations + # ------------------------------------------------------------------ + @app.post("/buildings/{bid}/fire") + async def set_fire(bid: str, body: FireBody): + b = sim.buildings.get(bid) + if not b: + raise HTTPException(404, f"unknown building {bid}") + if body.state not in FIRE_FROM_STR: + raise HTTPException(400, "state must be one of: normal, alarm, fault") + await b.set_fire(FIRE_FROM_STR[body.state]) + return b.snapshot() + + @app.post("/buildings/{bid}/supervisory") + async def set_supervisory(bid: str, body: SuperBody): + b = sim.buildings.get(bid) + if not b: + raise HTTPException(404, f"unknown building {bid}") + if body.state not in SUPER_FROM_STR: + raise HTTPException(400, "state must be one of: normal, trouble") + await b.set_supervisory(SUPER_FROM_STR[body.state]) + return b.snapshot() + + @app.post("/buildings/{bid}/power") + async def set_power(bid: str, body: PowerBody): + b = sim.buildings.get(bid) + if not b: + raise HTTPException(404, f"unknown building {bid}") + if body.state not in POWER_FROM_STR: + raise HTTPException(400, "state must be one of: mains, battery, fail") + await b.set_power(POWER_FROM_STR[body.state]) + return b.snapshot() + + @app.post("/buildings/{bid}/frozen") + async def set_frozen(bid: str, body: FlagBody): + b = sim.buildings.get(bid) + if not b: + raise HTTPException(404, f"unknown building {bid}") + await b.set_frozen(body.enabled) + return b.snapshot() + + @app.post("/buildings/{bid}/offline") + async def set_offline(bid: str, body: FlagBody): + b = sim.buildings.get(bid) + if not b: + raise HTTPException(404, f"unknown building {bid}") + await b.set_offline(body.enabled) + return b.snapshot() + + # ------------------------------------------------------------------ + # Bulk scenarios + # ------------------------------------------------------------------ + @app.post("/scenarios/storm") + async def storm(body: CountBody): + """Trigger fire alarms on N random buildings simultaneously.""" + ids = random.sample( + list(sim.buildings.keys()), + min(body.count, len(sim.buildings)), + ) + for bid in ids: + await sim.buildings[bid].set_fire(FireState.ALARM) + return {"alarmed": ids, "count": len(ids)} + + @app.post("/scenarios/random-offline") + async def random_offline(body: CountBody): + """Take N random buildings offline (drop their Modbus servers).""" + ids = random.sample( + list(sim.buildings.keys()), + min(body.count, len(sim.buildings)), + ) + for bid in ids: + await sim.buildings[bid].set_offline(True) + return {"offline": ids, "count": len(ids)} + + @app.post("/scenarios/random-freeze") + async def random_freeze(body: CountBody): + """Freeze N random MCUs (server up, heartbeat stops).""" + ids = random.sample( + list(sim.buildings.keys()), + min(body.count, len(sim.buildings)), + ) + for bid in ids: + await sim.buildings[bid].set_frozen(True) + return {"frozen": ids, "count": len(ids)} + + @app.post("/scenarios/chaos") + async def toggle_chaos(body: FlagBody): + if body.enabled: + await chaos.start() + else: + await chaos.stop() + return {"chaos": chaos.enabled} + + @app.post("/scenarios/reset") + async def reset_all(): + """Restore every building to a clean baseline.""" + await chaos.stop() + for b in sim.buildings.values(): + await b.set_offline(False) + await b.set_frozen(False) + await b.set_fire(FireState.NORMAL) + await b.set_supervisory(SupervisoryState.NORMAL) + await b.set_power(PowerState.MAINS) + return {"reset": len(sim.buildings)} + + return app diff --git a/fire-sim/src/fire_sim/loadtest.py b/fire-sim/src/fire_sim/loadtest.py new file mode 100644 index 0000000..1c1e8ce --- /dev/null +++ b/fire-sim/src/fire_sim/loadtest.py @@ -0,0 +1,124 @@ +"""Async Modbus client that polls every simulated building once and reports +connect rate, error rate, and p50/p99 read latency. + +This stands in for a future production poller. Use it to validate the +simulator can hold up under realistic concurrency. + +Examples +-------- + fire-sim-poll --buildings 100 --base-port 5020 + fire-sim-poll --buildings 100 --rounds 10 --interval 1.0 +""" + +from __future__ import annotations + +import argparse +import asyncio +import time + +from pymodbus.client import AsyncModbusTcpClient + +from .registers import NUM_REGS, REG_FIRE, REG_HEARTBEAT_HIGH, REG_HEARTBEAT_LOW + + +async def poll_one(host: str, port: int, building_id: str, timeout: float) -> dict: + client = AsyncModbusTcpClient(host, port=port, timeout=timeout) + try: + connected = await client.connect() + if not connected: + return {"id": building_id, "ok": False, "lat_ms": None, "err": "connect_failed"} + + t0 = time.monotonic() + rr = await client.read_input_registers(address=0, count=NUM_REGS) + lat_ms = (time.monotonic() - t0) * 1000.0 + + if rr.isError(): + return {"id": building_id, "ok": False, "lat_ms": lat_ms, "err": str(rr)} + + regs = rr.registers + heartbeat = (regs[REG_HEARTBEAT_HIGH] << 16) | regs[REG_HEARTBEAT_LOW] + return { + "id": building_id, + "ok": True, + "lat_ms": lat_ms, + "fire": regs[REG_FIRE], + "heartbeat": heartbeat, + } + except Exception as e: # noqa: BLE001 + return {"id": building_id, "ok": False, "lat_ms": None, "err": repr(e)} + finally: + try: + client.close() + except Exception: # noqa: BLE001 + pass + + +def _percentile(values: list[float], p: float) -> float: + if not values: + return 0.0 + s = sorted(values) + idx = min(len(s) - 1, max(0, int(round(p * (len(s) - 1))))) + return s[idx] + + +async def run_round(host: str, base_port: int, count: int, timeout: float) -> dict: + tasks = [ + poll_one(host, base_port + i, f"BLDG-{i + 1:03d}", timeout) + for i in range(count) + ] + t0 = time.monotonic() + results = await asyncio.gather(*tasks) + elapsed = time.monotonic() - t0 + + ok = [r for r in results if r["ok"]] + bad = [r for r in results if not r["ok"]] + lats = [r["lat_ms"] for r in results if r["lat_ms"] is not None] + + return { + "elapsed_s": elapsed, + "ok": len(ok), + "fail": len(bad), + "p50_ms": _percentile(lats, 0.50), + "p95_ms": _percentile(lats, 0.95), + "p99_ms": _percentile(lats, 0.99), + "max_ms": max(lats) if lats else 0.0, + "errors": bad[:5], + } + + +async def amain(args: argparse.Namespace) -> None: + print( + f"Polling {args.buildings} buildings on {args.host}:{args.base_port}+ " + f"({args.rounds} round(s), interval {args.interval}s, timeout {args.timeout}s)" + ) + for i in range(args.rounds): + summary = await run_round(args.host, args.base_port, args.buildings, args.timeout) + print( + f"[round {i + 1}/{args.rounds}] elapsed={summary['elapsed_s']:.2f}s " + f"ok={summary['ok']} fail={summary['fail']} " + f"p50={summary['p50_ms']:.1f}ms " + f"p95={summary['p95_ms']:.1f}ms " + f"p99={summary['p99_ms']:.1f}ms " + f"max={summary['max_ms']:.1f}ms" + ) + if summary["errors"]: + for e in summary["errors"]: + print(f" ! {e['id']}: {e.get('err')}") + if i < args.rounds - 1: + await asyncio.sleep(args.interval) + + +def main() -> None: + p = argparse.ArgumentParser(description="Poll all simulated fire panels and report stats") + p.add_argument("--host", default="127.0.0.1") + p.add_argument("--base-port", type=int, default=5020) + p.add_argument("--buildings", type=int, default=100) + p.add_argument("--rounds", type=int, default=1, help="Number of polling rounds") + p.add_argument("--interval", type=float, default=1.0, help="Seconds between rounds") + p.add_argument("--timeout", type=float, default=2.0, help="Per-request timeout (s)") + args = p.parse_args() + asyncio.run(amain(args)) + + +if __name__ == "__main__": + main() diff --git a/fire-sim/src/fire_sim/main.py b/fire-sim/src/fire_sim/main.py new file mode 100644 index 0000000..4448bd9 --- /dev/null +++ b/fire-sim/src/fire_sim/main.py @@ -0,0 +1,95 @@ +"""CLI entrypoint: spin up N simulated fire panels and an HTTP control plane.""" + +from __future__ import annotations + +import argparse +import asyncio +import logging +import signal + +import uvicorn + +from .chaos import Chaos +from .control_api import make_app +from .simulator import Simulator + + +async def amain(args: argparse.Namespace) -> None: + logging.basicConfig( + level=getattr(logging, args.log_level.upper(), logging.INFO), + format="%(asctime)s %(levelname)-7s %(name)s %(message)s", + ) + log = logging.getLogger("fire_sim") + + sim = Simulator.create(count=args.buildings, host=args.host, base_port=args.base_port) + await sim.start_all() + if args.manifest_out: + sim.write_manifest(args.manifest_out) + + chaos = Chaos(sim) + if args.chaos: + await chaos.start() + + app = make_app(sim, chaos) + api_config = uvicorn.Config( + app, + host=args.host, + port=args.api_port, + log_level=args.log_level.lower(), + access_log=False, + ) + api_server = uvicorn.Server(api_config) + + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + try: + loop.add_signal_handler(sig, stop_event.set) + except NotImplementedError: + # Windows or restricted env - fall back to default handler. + pass + + api_task = asyncio.create_task(api_server.serve(), name="control-api") + + log.info( + "Fire-sim ready: %d buildings on %s:%d-%d | control API http://%s:%d", + args.buildings, + args.host, + args.base_port, + args.base_port + args.buildings - 1, + args.host, + args.api_port, + ) + log.info("Press Ctrl-C to stop.") + + try: + await stop_event.wait() + finally: + log.info("Shutting down...") + api_server.should_exit = True + try: + await asyncio.wait_for(api_task, timeout=5.0) + except asyncio.TimeoutError: + api_task.cancel() + await chaos.stop() + await sim.stop_all() + log.info("Shutdown complete.") + + +def main() -> None: + p = argparse.ArgumentParser( + description="Fire-panel Modbus TCP simulator (load-test up to ~100 buildings on one host)", + ) + p.add_argument("--buildings", type=int, default=100, help="How many panels to simulate") + p.add_argument("--host", default="0.0.0.0", help="Bind address for Modbus + API") + p.add_argument("--base-port", type=int, default=5020, help="First Modbus TCP port; sim uses [base, base+N-1]") + p.add_argument("--api-port", type=int, default=8080, help="HTTP control plane port") + p.add_argument("--manifest-out", default=None, help="Write JSON {id,host,port} manifest to this file") + p.add_argument("--chaos", action="store_true", help="Enable random failure injection on startup") + p.add_argument("--log-level", default="info", choices=["debug", "info", "warning", "error"]) + args = p.parse_args() + asyncio.run(amain(args)) + + +if __name__ == "__main__": + main() diff --git a/fire-sim/src/fire_sim/registers.py b/fire-sim/src/fire_sim/registers.py new file mode 100644 index 0000000..ff1a14e --- /dev/null +++ b/fire-sim/src/fire_sim/registers.py @@ -0,0 +1,51 @@ +"""Modbus register map for the simulated fire panel. + +This mirrors the contract that real STM32 firmware should expose. The poller +service must use the same offsets. All values live in the *input register* +space (Modbus function code 4, addresses 30001+ in 1-based / 0+ in 0-based). + +Keep this file in sync with the firmware spec; it is the single source of +truth for both simulator and poller. +""" + +from enum import IntEnum + +# --- Register offsets (0-based; add 30001 for 1-based PLC notation) --- +REG_FIRE = 0 # 30001 - fire alarm state (FireState) +REG_SUPERVISORY = 1 # 30002 - supervisory / trouble state (SupervisoryState) +REG_POWER = 2 # 30003 - panel power state (PowerState) +REG_HEARTBEAT_LOW = 3 # 30004 - heartbeat counter, low 16 bits +REG_FW_VERSION = 4 # 30005 - firmware version (e.g. 0x0100 = v1.0) +REG_UPTIME_LOW = 5 # 30006 - uptime seconds, low 16 bits +REG_UPTIME_HIGH = 6 # 30007 - uptime seconds, high 16 bits +REG_HEARTBEAT_HIGH = 7 # 30008 - heartbeat counter, high 16 bits + +# Total registers exposed (with headroom for future fields) +NUM_REGS = 32 + + +class FireState(IntEnum): + NORMAL = 0 + ALARM = 1 + FAULT = 2 + + +class SupervisoryState(IntEnum): + NORMAL = 0 + TROUBLE = 1 + + +class PowerState(IntEnum): + MAINS = 0 + BATTERY = 1 + FAIL = 2 + + +# String <-> enum helpers (used by the HTTP control plane) +FIRE_FROM_STR = {"normal": FireState.NORMAL, "alarm": FireState.ALARM, "fault": FireState.FAULT} +SUPER_FROM_STR = {"normal": SupervisoryState.NORMAL, "trouble": SupervisoryState.TROUBLE} +POWER_FROM_STR = {"mains": PowerState.MAINS, "battery": PowerState.BATTERY, "fail": PowerState.FAIL} + +FIRE_TO_STR = {v: k for k, v in FIRE_FROM_STR.items()} +SUPER_TO_STR = {v: k for k, v in SUPER_FROM_STR.items()} +POWER_TO_STR = {v: k for k, v in POWER_FROM_STR.items()} diff --git a/fire-sim/src/fire_sim/simulator.py b/fire-sim/src/fire_sim/simulator.py new file mode 100644 index 0000000..d9d7e1c --- /dev/null +++ b/fire-sim/src/fire_sim/simulator.py @@ -0,0 +1,52 @@ +"""Owns and lifecycles a fleet of simulated buildings.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from pathlib import Path + +from .building import Building + +log = logging.getLogger(__name__) + + +class Simulator: + def __init__(self, buildings: list[Building]) -> None: + self.buildings: dict[str, Building] = {b.id: b for b in buildings} + + @classmethod + def create(cls, count: int, host: str = "0.0.0.0", base_port: int = 5020) -> "Simulator": + buildings = [ + Building(building_id=f"BLDG-{i + 1:03d}", host=host, port=base_port + i) + for i in range(count) + ] + return cls(buildings) + + async def start_all(self) -> None: + # Start in parallel; each building binds its own port. + await asyncio.gather(*(b.start() for b in self.buildings.values())) + log.info( + "Started %d buildings on ports %d-%d", + len(self.buildings), + min(b.port for b in self.buildings.values()), + max(b.port for b in self.buildings.values()), + ) + + async def stop_all(self) -> None: + await asyncio.gather( + *(b.stop() for b in self.buildings.values()), + return_exceptions=True, + ) + log.info("Stopped %d buildings", len(self.buildings)) + + def manifest(self) -> list[dict]: + return [ + {"id": b.id, "host": b.host, "port": b.port} + for b in self.buildings.values() + ] + + def write_manifest(self, path: str | Path) -> None: + Path(path).write_text(json.dumps(self.manifest(), indent=2)) + log.info("Wrote manifest with %d buildings to %s", len(self.buildings), path)