Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions fire-poller/config.py
Original file line number Diff line number Diff line change
@@ -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"}
152 changes: 152 additions & 0 deletions fire-poller/main.py
Original file line number Diff line number Diff line change
@@ -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())
Loading