157 lines
5.4 KiB
Python
157 lines
5.4 KiB
Python
#!/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()
|