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

494
arnold/sequencer.py Normal file
View File

@@ -0,0 +1,494 @@
"""
arnold/sequencer.py — Sequence execution engine.
Design:
- One sequence can run at a time (enforced by a mutex).
- Runs asynchronously in a worker thread; returns a run_id immediately.
- Caller polls GET /runs/{run_id} for result.
- Absolute timing: each step fires at t_start + step.t_ms (monotonic clock).
- check_input: reads from IOState cache (fast-poll value), instant check.
- set_output: calls IODriver.write_output() directly.
- On step failure: remaining steps are skipped, outputs are NOT auto-reset
(caller is responsible for safety; a future "on_abort" hook could be added).
"""
from __future__ import annotations
import json
import logging
import threading
import time
import uuid
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone
from pathlib import Path
from typing import TYPE_CHECKING, Any, Callable, Literal
if TYPE_CHECKING:
from .config import Config, Sequence, SequenceStep
from .terminator_io import IORegistry
log = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Run result dataclasses
# ---------------------------------------------------------------------------
@dataclass
class StepResult:
step_index: int
t_ms: int
action: str
signal: str
success: bool
detail: str = "" # human-readable description
actual: bool | int | None = None # for check_input / wait_input
expected: bool | int | None = None
@dataclass
class RunResult:
run_id: str
sequence_name: str
status: Literal["pending", "running", "success", "failed", "error"]
started_at: str = "" # ISO8601
finished_at: str = ""
duration_ms: int = 0
steps_completed: int = 0
total_steps: int = 0
current_step_index: int = -1 # index of the step currently executing (-1 = none)
failed_step: StepResult | None = None
error_message: str = ""
def to_dict(self) -> dict:
d = asdict(self)
return d
# ---------------------------------------------------------------------------
# Run log (JSON-lines file)
# ---------------------------------------------------------------------------
class RunLog:
def __init__(self, path: Path) -> None:
self._path = path
self._lock = threading.Lock()
def append(self, result: RunResult) -> None:
with self._lock:
with open(self._path, "a") as f:
f.write(json.dumps(result.to_dict()) + "\n")
def tail(self, n: int = 50) -> list[dict]:
"""Return the last n entries."""
if not self._path.exists():
return []
with self._lock:
lines = self._path.read_text().splitlines()
entries = []
for line in lines[-n:]:
line = line.strip()
if line:
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
pass
return list(reversed(entries)) # most recent first
# ---------------------------------------------------------------------------
# Sequencer
# ---------------------------------------------------------------------------
class Sequencer:
def __init__(
self,
config: "Config",
registry: "IORegistry",
log_path: Path,
on_output_write: Callable[[str, bool | int], None] | None = None,
) -> None:
self._config = config
self._registry = registry
self._run_log = RunLog(log_path)
# Optional callback fired after every successful set_output step.
# Signature: on_output_write(signal_name: str, value: bool | int)
self._on_output_write = on_output_write
# One-at-a-time enforcement
self._run_lock = threading.Lock()
self._active_id: str | None = None
# Result store: run_id -> RunResult
self._results_lock = threading.Lock()
self._results: dict[str, RunResult] = {}
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def start(self, sequence_name: str) -> tuple[str, bool]:
"""
Launch sequence_name in a background thread.
Returns (run_id, started).
started=False means another sequence is already running (caller
should return HTTP 409).
"""
seq = self._config.sequence(sequence_name)
if seq is None:
raise ValueError(f"Unknown sequence: {sequence_name!r}")
# Try to acquire run lock non-blocking
if not self._run_lock.acquire(blocking=False):
return ("", False)
run_id = str(uuid.uuid4())
result = RunResult(
run_id=run_id,
sequence_name=sequence_name,
status="pending",
total_steps=len(seq.steps),
)
with self._results_lock:
self._results[run_id] = result
self._active_id = run_id
t = threading.Thread(
target=self._run_thread,
args=(seq, run_id),
name=f"seq-{sequence_name}-{run_id[:8]}",
daemon=True,
)
t.start()
return (run_id, True)
def get_result(self, run_id: str) -> RunResult | None:
with self._results_lock:
return self._results.get(run_id)
def active_run_id(self) -> str | None:
with self._results_lock:
return self._active_id
def recent_runs(self, n: int = 50) -> list[dict]:
return self._run_log.tail(n)
# ------------------------------------------------------------------
# Execution thread
# ------------------------------------------------------------------
def _run_thread(self, seq: "Sequence", run_id: str) -> None:
started_at = datetime.now(timezone.utc)
t_start = time.monotonic()
self._update_result(run_id, status="running",
started_at=started_at.isoformat())
log.info("Sequence %r started run_id=%s steps=%d",
seq.name, run_id, len(seq.steps))
failed_step: StepResult | None = None
steps_completed = 0
try:
for i, step in enumerate(seq.steps):
# Mark which step we're about to execute
self._update_result(run_id, current_step_index=i)
# Wait until absolute time t_ms from start
target = t_start + step.t_ms / 1000.0
now = time.monotonic()
if target > now:
time.sleep(target - now)
ok, step_result = self._execute_step(i, step)
if not ok:
steps_completed = i
failed_step = step_result
log.warning(
"Sequence %r FAILED at step %d (%s %s): %s",
seq.name, i, step.action, step.signal, step_result.detail,
)
break
steps_completed = i + 1
self._update_result(run_id, steps_completed=steps_completed)
log.debug("Step %d OK: %s %s", i, step.action, step.signal)
except Exception as exc:
log.exception("Sequence %r raised exception: %s", seq.name, exc)
finished_at = datetime.now(timezone.utc)
duration_ms = int((time.monotonic() - t_start) * 1000)
result = self._update_result(
run_id,
status="error",
finished_at=finished_at.isoformat(),
duration_ms=duration_ms,
steps_completed=steps_completed,
current_step_index=-1,
error_message=str(exc),
)
self._run_log.append(result)
self._run_lock.release()
with self._results_lock:
self._active_id = None
return
finished_at = datetime.now(timezone.utc)
duration_ms = int((time.monotonic() - t_start) * 1000)
if failed_step:
status = "failed"
else:
status = "success"
steps_completed = len(seq.steps)
result = self._update_result(
run_id,
status=status,
finished_at=finished_at.isoformat(),
duration_ms=duration_ms,
steps_completed=steps_completed,
current_step_index=-1,
failed_step=failed_step,
)
self._run_log.append(result)
self._run_lock.release()
with self._results_lock:
self._active_id = None
log.info(
"Sequence %r %s run_id=%s duration=%dms steps=%d/%d",
seq.name, status.upper(), run_id, duration_ms,
steps_completed, len(seq.steps),
)
# ------------------------------------------------------------------
# Step execution
# ------------------------------------------------------------------
def _execute_step(self, index: int, step: "SequenceStep") -> tuple[bool, StepResult]:
sig = self._config.signal(step.signal)
if sig is None:
# Should never happen — validated at load time
sr = StepResult(index, step.t_ms, step.action, step.signal,
False, f"Unknown signal {step.signal!r}")
return False, sr
if step.action == "set_output":
return self._set_output(index, step, sig)
elif step.action == "check_input":
return self._check_input(index, step, sig)
elif step.action == "wait_input":
return self._wait_input(index, step, sig)
else:
sr = StepResult(index, step.t_ms, step.action, step.signal,
False, f"Unknown action {step.action!r}")
return False, sr
# -- set_output ----------------------------------------------------
def _set_output(
self, index: int, step: "SequenceStep", sig: Any
) -> tuple[bool, StepResult]:
driver = self._registry.driver(sig.device)
if driver is None:
sr = StepResult(index, step.t_ms, step.action, step.signal,
False, f"No driver for device {sig.device!r}")
return False, sr
if sig.value_type == "int":
# Analog output — FC06 (single register write)
write_val: bool | int = int(step.value or 0)
ok = driver.write_register(sig.modbus_address, write_val)
detail_ok = f"Set {step.signal}={write_val}"
detail_err = f"Register write failed for {step.signal}"
else:
# Digital output — FC05 (single coil write)
write_val = bool(step.state)
ok = driver.write_output(sig.modbus_address, write_val)
detail_ok = f"Set {step.signal}={'ON' if write_val else 'OFF'}"
detail_err = f"Coil write failed for {step.signal}"
if ok and self._on_output_write:
try:
self._on_output_write(step.signal, write_val)
except Exception:
pass
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=ok,
detail=detail_ok if ok else detail_err,
)
return ok, sr
# -- check_input ---------------------------------------------------
def _check_input(
self, index: int, step: "SequenceStep", sig: Any
) -> tuple[bool, StepResult]:
actual = self._registry.get_value(step.signal)
stale = self._registry.is_stale(step.signal)
if stale or actual is None:
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=False,
detail=f"Signal {step.signal!r} is stale or not yet read",
actual=actual,
expected=self._expected_for_step(step, sig),
)
return False, sr
ok, expected_display = self._compare(actual, step, sig)
if sig.value_type == "int":
detail = (
f"Check {step.signal}: expected={expected_display} "
f"actual={actual}"
)
else:
detail = (
f"Check {step.signal}: expected={'ON' if step.expected else 'OFF'} "
f"actual={'ON' if actual else 'OFF'}"
)
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=ok,
detail=detail,
actual=actual,
expected=self._expected_for_step(step, sig),
)
return ok, sr
# -- wait_input ----------------------------------------------------
def _wait_input(
self, index: int, step: "SequenceStep", sig: Any
) -> tuple[bool, StepResult]:
"""
Poll the signal cache until the expected value is seen or timeout expires.
Polls every 50 ms. Supports both digital (exact bool match) and
analog (abs(actual - expected_value) <= tolerance) comparisons.
"""
timeout_s = (step.timeout_ms or 0) / 1000.0
deadline = time.monotonic() + timeout_s
poll_interval = 0.05 # 50 ms
exp_display = self._expected_display(step, sig)
while True:
actual = self._registry.get_value(step.signal)
stale = self._registry.is_stale(step.signal)
if not stale and actual is not None:
ok, _ = self._compare(actual, step, sig)
if ok:
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=True,
detail=f"Wait {step.signal}=={exp_display}: condition met",
actual=actual,
expected=self._expected_for_step(step, sig),
)
return True, sr
if time.monotonic() >= deadline:
if stale or actual is None:
act_str = "stale"
elif sig.value_type == "int":
act_str = str(actual)
else:
act_str = "ON" if actual else "OFF"
sr = StepResult(
step_index=index,
t_ms=step.t_ms,
action=step.action,
signal=step.signal,
success=False,
detail=(
f"Wait {step.signal}=={exp_display}: "
f"timeout after {step.timeout_ms} ms (actual={act_str})"
),
actual=actual,
expected=self._expected_for_step(step, sig),
)
return False, sr
time.sleep(poll_interval)
# -- Comparison helpers --------------------------------------------
@staticmethod
def _compare(
actual: bool | int,
step: "SequenceStep",
sig: Any,
) -> tuple[bool, str]:
"""
Compare actual value against step expectation.
Returns (match: bool, expected_display: str).
"""
if sig.value_type == "int":
# Analog comparison with tolerance
expected = step.expected_value if step.expected_value is not None else 0
tolerance = step.tolerance if step.tolerance is not None else 0
ok = abs(int(actual) - expected) <= tolerance
if tolerance > 0:
display = f"{expected}±{tolerance}"
else:
display = str(expected)
return ok, display
else:
# Digital: exact bool match
ok = (actual == step.expected)
display = "ON" if step.expected else "OFF"
return ok, display
@staticmethod
def _expected_for_step(step: "SequenceStep", sig: Any) -> bool | int | None:
"""Return the expected value in the appropriate type for StepResult."""
if sig.value_type == "int":
return step.expected_value
return step.expected
@staticmethod
def _expected_display(step: "SequenceStep", sig: Any) -> str:
"""Human-readable expected value string."""
if sig.value_type == "int":
expected = step.expected_value if step.expected_value is not None else 0
tolerance = step.tolerance if step.tolerance is not None else 0
if tolerance > 0:
return f"{expected}±{tolerance}"
return str(expected)
return "ON" if step.expected else "OFF"
# ------------------------------------------------------------------
# Internal result update
# ------------------------------------------------------------------
def _update_result(self, run_id: str, **kwargs) -> RunResult:
with self._results_lock:
result = self._results[run_id]
for k, v in kwargs.items():
setattr(result, k, v)
return result