#!/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://:/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()