first
This commit is contained in:
156
server.py
Normal file
156
server.py
Normal 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()
|
||||
Reference in New Issue
Block a user