This commit is contained in:
2026-03-02 17:48:55 -05:00
commit 75678fce4d
27 changed files with 5354 additions and 0 deletions

156
server.py Normal file
View File

@@ -0,0 +1,156 @@
#!/usr/bin/env python3
"""
server.py — Arnold I/O server entrypoint.
Usage:
python3 server.py [--config FILE] [--host HOST] [--port PORT] [--log-level LEVEL]
--config YAML config file (default: config.yaml)
--host API listen address (default: 0.0.0.0)
--port API listen port (default: 8000)
--log-level debug | info | warning | error (default: info)
Interactive API docs at http://<host>:<port>/docs once running.
"""
from __future__ import annotations
import argparse
import logging
import signal
import sys
import threading
import time
from pathlib import Path
import uvicorn
from arnold.config import load as load_config, ConfigError
from arnold.terminator_io import IORegistry
from arnold.sequencer import Sequencer
from arnold.api import AppContext, create_app
def _setup_logging(level: str) -> None:
logging.basicConfig(
level=getattr(logging, level.upper(), logging.INFO),
format="%(asctime)s %(levelname)-8s %(name)s %(message)s",
datefmt="%Y-%m-%dT%H:%M:%S",
)
def main() -> None:
parser = argparse.ArgumentParser(description="Arnold — Terminator I/O server")
parser.add_argument("--config", default="config.yaml")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8000)
parser.add_argument("--log-level", default="info",
choices=["debug", "info", "warning", "error"])
args = parser.parse_args()
_setup_logging(args.log_level)
log = logging.getLogger("arnold.server")
# 1. Load config -----------------------------------------------------------
log.info("Loading config: %s", args.config)
try:
config = load_config(args.config)
except ConfigError as exc:
log.error("Config error: %s", exc)
sys.exit(1)
log.info("Config: %d device(s) %d signal(s) %d sequence(s)",
len(config.devices), len(config.logical_io), len(config.sequences))
for dev in config.devices:
log.info(" %-20s %s:%d %d din %d dout %d ain %d aout poll=%dms",
dev.id, dev.host, dev.port,
dev.total_input_points(), dev.total_output_points(),
dev.total_analog_input_channels(), dev.total_analog_output_channels(),
dev.poll_interval_ms)
# 2. Build runtime objects -------------------------------------------------
registry = IORegistry(config)
sequencer = Sequencer(config, registry, Path("runs.log"))
ctx = AppContext(
config=config,
registry=registry,
sequencer=sequencer,
started_at=time.monotonic(),
)
app = create_app(ctx)
# 3. Start poll threads ----------------------------------------------------
log.info("Starting poll threads...")
registry.start()
# 4. Apply output defaults (digital + analog) --------------------------------
digital_defaults = [
s for s in config.logical_io
if s.direction == "output" and s.value_type == "bool"
and s.default_state is not None
]
analog_defaults = [
s for s in config.logical_io
if s.direction == "output" and s.value_type == "int"
and s.default_value is not None
]
total_defaults = len(digital_defaults) + len(analog_defaults)
if total_defaults:
log.info("Applying %d output default(s)...", total_defaults)
time.sleep(0.5) # give Modbus connection a moment to establish
for sig in digital_defaults:
driver = registry.driver(sig.device)
if driver and sig.default_state is not None:
ok = driver.write_output(sig.modbus_address, sig.default_state)
log.info(" %s%s (%s)", sig.name,
"ON" if sig.default_state else "OFF",
"ok" if ok else "FAILED")
for sig in analog_defaults:
driver = registry.driver(sig.device)
if driver and sig.default_value is not None:
ok = driver.write_register(sig.modbus_address, sig.default_value)
log.info(" %s%d (%s)", sig.name,
sig.default_value, "ok" if ok else "FAILED")
# 5. Graceful shutdown -----------------------------------------------------
shutdown = threading.Event()
def _on_signal(sig, _frame):
log.info("Signal %s received — shutting down...", sig)
shutdown.set()
signal.signal(signal.SIGINT, _on_signal)
signal.signal(signal.SIGTERM, _on_signal)
# 6. Start uvicorn in a daemon thread --------------------------------------
uv_server = uvicorn.Server(uvicorn.Config(
app,
host=args.host,
port=args.port,
log_level=args.log_level,
access_log=False,
))
uv_thread = threading.Thread(target=uv_server.run, daemon=True)
uv_thread.start()
log.info("API listening on http://%s:%d (docs: /docs)", args.host, args.port)
# Block until SIGINT / SIGTERM
try:
while not shutdown.is_set():
shutdown.wait(1.0)
except KeyboardInterrupt:
pass
# 7. Clean shutdown --------------------------------------------------------
log.info("Stopping poll threads...")
registry.stop()
log.info("Stopping API server...")
uv_server.should_exit = True
uv_thread.join(timeout=5)
log.info("Done.")
if __name__ == "__main__":
main()