Commit 21507440 authored by vertighel's avatar vertighel
Browse files

Dependency system refactoring: device-level deps from devices.ini



Moves dependency information from per-endpoint api.ini to per-device
devices.ini, replacing client-side telemetry parsing with server-side
cascade via state-diff WebSocket events.

Key changes:
- devices.ini: add depends-on and ping-using to each active device
- api.ini: remove all depends-on lines (70 entries)
- deps.py: rewrite around device-level dep tree; _check_device walks
  parent chain + ping check; skips self-ping for power/connection
  endpoints to avoid circular 424 when toggling root devices
- stream.py: compute device_active topologically after each poll cycle,
  project to endpoint_states, broadcast state-diff diffs; webcam_loop
  now updates global_device_states[/webcam/clock]; send endpoint-roots
  list and full state snapshot to each new WS client on connect
- dependency-guard.js: rewrite to consume state-diff events; ROOTS map
  maps subsystem → root endpoint path; alwaysOn derived from
  endpoint-roots message (root device = no depends-on in devices.ini);
  fixes Stage/Camera/Camera2 showing active when telescope is off

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent d625d05e
Loading
Loading
Loading
Loading
+89 −39
Original line number Diff line number Diff line
# deps.py
# Dependency Manager providing topological priorities and REST route verification.
# Dependency Manager: device-level dep tree from devices.ini.

import time
import configparser
from pathlib import Path
from noctua.api.basecontext import ends, resource_registry

_devices_path = Path(__file__).parent.parent / "config" / "devices.ini"
_devices_cfg = configparser.ConfigParser()
_devices_cfg.read(_devices_path)


class DependencyCache:
    """
    Manages dependency verification with a simple time-based cache for single calls,
@@ -15,25 +22,61 @@ class DependencyCache:
        self._ttl = ttl
        self.priorities = {}

        # Device-level maps (built at init from devices.ini + api.ini)
        self._device_deps = {}      # {dev: parent_dev or None}
        self._device_ping = {}      # {dev: resolved endpoint path or None}
        self._endpoint_device = {}  # {endpoint_path: dev}

        self._build_device_maps()

    def _build_device_maps(self):
        # Step 1: device deps and ping-using names from devices.ini
        device_ping_name = {}
        for dev in _devices_cfg.sections():
            self._device_deps[dev] = _devices_cfg.get(dev, 'depends-on', fallback=None)
            ping_name = _devices_cfg.get(dev, 'ping-using', fallback=None)
            if ping_name:
                device_ping_name[dev] = ping_name

        # Step 2: endpoint → device map from api.ini
        for path in ends.sections():
            dev = ends.get(path, 'device', fallback=None)
            if dev:
                self._endpoint_device[path] = dev

        # Step 3: resolve ping-using names to endpoint paths
        for dev, ping_name in device_ping_name.items():
            for path, d in self._endpoint_device.items():
                if d == dev and path.split('/')[-1] == ping_name:
                    self._device_ping[dev] = path
                    break

    def _dev_depth(self, dev, memo):
        if dev in memo:
            return memo[dev]
        parent = self._device_deps.get(dev)
        memo[dev] = self._dev_depth(parent, memo) + 1 if parent else 0
        return memo[dev]

    def calculate_priorities(self):
        """
        Calculate the hierarchical priority level (0 = root, 1 = child, etc.)
        for all active resources based on depends-on declarations.
        Calculate endpoint polling priority from owner-device depth.
        Ping endpoints get device depth; others get depth+1 so the ping
        is always polled before its siblings within the same device.
        """
        def get_level(path):
            parent = ends.get(path, "depends-on", fallback=None)
            if not parent or parent not in resource_registry:
                return 0
            return get_level(parent) + 1

        self.priorities = {path: get_level(path) for path in resource_registry.keys()}

        memo = {}
        self.priorities = {}
        for path in resource_registry.keys():
            dev = self._endpoint_device.get(path)
            if dev:
                d = self._dev_depth(dev, memo)
                # Ping endpoint is the gate for the device — poll it first
                self.priorities[path] = d if self._device_ping.get(dev) == path else d + 1
            else:
                self.priorities[path] = 0

    async def get_status(self, path, force=False):
        """
        Verify if an endpoint is allowed to execute based on its parents.
        """
        """Verify if an endpoint is allowed to execute based on its device deps."""
        if force:
            return True, None

@@ -43,35 +86,42 @@ class DependencyCache:
            if (now - timestamp) < self._ttl:
                return status

        result = await self._perform_check(path, force)
        result = await self._perform_check(path)
        self._cache[path] = (result, now)

        return result


    async def _perform_check(self, path, force):
        dependency = ends.get(path, "depends-on", fallback=None)

        if not dependency:
    async def _perform_check(self, path):
        dev = self._endpoint_device.get(path)
        if not dev:
            return True, None

        parent_resource = resource_registry.get(dependency)
        if not parent_resource:
            return False, f"Missing resource: {dependency}"

        # Recursive check for single REST requests
        parent_ok, parent_err = await self.get_status(dependency, force)
        if not parent_ok:
            return False, parent_err

        # Check parent actual state
        response = await parent_resource.get()
        # If path is its device's own ping endpoint, skip that device's ping check:
        # checking whether the power outlet is on before allowing access to the power
        # outlet itself is circular — only the parent chain matters.
        skip_self_ping = dev if self._device_ping.get(dev) == path else None
        return await self._check_device(dev, visited=set(), skip_ping=skip_self_ping)

    async def _check_device(self, dev, visited, skip_ping=None):
        if dev in visited:
            return True, None
        visited.add(dev)

        parent = self._device_deps.get(dev)
        if parent:
            ok, err = await self._check_device(parent, visited, skip_ping)
            if not ok:
                return False, err

        if dev != skip_ping:
            ping_path = self._device_ping.get(dev)
            if ping_path:
                resource = resource_registry.get(ping_path)
                if not resource:
                    return False, f"Missing ping resource: {ping_path}"
                response = await resource.get()
                data = response[0] if isinstance(response, tuple) else response
        
                raw_val = data.get("raw")

                if raw_val is False or raw_val is None or data.get("error"):
            return False, f"Link failure: {dependency}"
                    return False, f"Ping failed: {ping_path}"

        return True, None

+0 −67
Original line number Diff line number Diff line
@@ -14,42 +14,34 @@
[/stage/connection]
resource = State
device = pdu_moxa
depends-on = /telescope/power

[/stage/power]
resource = State
device = pdu_stage
depends-on = /stage/connection

[/stage/position]
resource = Position
device = stage
depends-on = /stage/power

[/stage/positions]
resource = Positions
device = stage
depends-on = /stage/power

[/stage/named]
resource = Named
device = stage
depends-on = /stage/power

[/stage/status]
resource = Initialization
device = stage
depends-on = /stage/power

[/stage/movement]
resource = Movement
device = stage
depends-on = /stage/power

[/stage/limits]
resource = Limits
device = stage
depends-on = /stage/power

##############
# dome
@@ -62,47 +54,38 @@ device = dom
[/dome/light]
resource = State
device = light
depends-on = /dome/connection

[/dome/shutter]
resource = Shutter
device = dom
depends-on = /dome/connection

[/dome/shutter/movement]
resource = ShutterMovement
device = dom
depends-on = /dome/connection

[/dome/position]
resource = Position
device = dom
depends-on = /dome/connection

[/dome/position/movement]
resource = PositionMovement
device = dom
depends-on = /dome/connection

[/dome/position/movement/park]
resource = PositionMovementPark
device = dom
depends-on = /dome/connection

[/dome/position/movement/azimuth]
resource = PositionMovementAzimuth
device = dom
depends-on = /dome/connection

[/dome/position/slaved]
resource = PositionSlaved
device = dom
depends-on = /dome/connection

[/dome/position/sync]
resource = PositionSync
device = dom
depends-on = /dome/connection

##############
# telescope
@@ -115,27 +98,22 @@ device = cab
[/telescope/clock]
resource = Clock
device = tel
depends-on = /telescope/power

[/telescope/lamp]
resource = State
device = lamp
depends-on = /dome/connection

[/telescope/cover]
resource = Cover
device = tel
depends-on = /telescope/clock

[/telescope/cover/movement]
resource = CoverMovement
device = tel
depends-on = /telescope/power

[/telescope/coordinates]
resource = Coordinates
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/resolve]
resource = CoordinatesResolve
@@ -144,42 +122,34 @@ device = tel
[/telescope/coordinates/movement]
resource = CoordinatesMovement
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/movement/radec]
resource = CoordinatesMovementRadec
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/movement/altaz]
resource = CoordinatesMovementAltaz
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/movement/atpark]
resource = CoordinatesMovementAtpark
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/movement/park]
resource = CoordinatesMovementPark
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/movement/unpark]
resource = CoordinatesMovementUnpark
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/offset]
resource = CoordinatesOffset
device = tel
depends-on = /telescope/clock

[/telescope/coordinates/tracking]
resource = CoordinatesTracking
device = tel
depends-on = /telescope/clock

# [/telescope/connection]
# resource = Connection
@@ -188,32 +158,26 @@ depends-on = /telescope/clock
[/telescope/error]
resource = Error
device = tel
depends-on = /telescope/power

[/telescope/error/details]
resource = ErrorDetails
device = tel
depends-on = /telescope/power

[/telescope/focuser]
resource = Focuser
device = foc
depends-on = /telescope/clock

[/telescope/focuser/movement]
resource = FocuserMovement
device = foc
depends-on = /telescope/clock

[/telescope/rotator]
resource = Rotator
device = rot
depends-on = /telescope/clock

[/telescope/rotator/movement]
resource = RotatorMovement
device = rot
depends-on = /telescope/clock

##############
# camera
@@ -222,27 +186,22 @@ depends-on = /telescope/clock
[/camera/power]
resource = State
device = pdu_cam
depends-on = /telescope/power

[/camera/frame/binning]
resource = FrameBinning
device = cam
depends-on = /camera/power

[/camera/cooler]
resource = Cooler
device = cam
depends-on = /camera/power

[/camera/cooler/temperature/setpoint]
resource = CoolerTemperatureSetpoint
device = cam
depends-on = /camera/power

[/camera/cooler/warmup]
resource = CoolerWarmup
device = cam
depends-on = /camera/power

# [/camera/filters]
# resource = Filters
@@ -252,12 +211,10 @@ depends-on = /camera/power
[/camera/filter]
resource = Filter
device = cam
depends-on = /camera/power

[/camera/filter/movement]
resource = FilterMovement
device = cam
depends-on = /camera/power

# [/camera/frame]
# resource = Frame
@@ -271,47 +228,38 @@ depends-on = /camera/power
[/camera/frame/full]
resource = FrameFull
device = cam
depends-on = /camera/power

[/camera/frame/half]
resource = FrameHalf
device = cam
depends-on = /camera/power

[/camera/frame/small]
resource = FrameSmall
device = cam
depends-on = /camera/power

[/camera/snapshot/raw]
resource = SnapshotRaw
device = cam
depends-on = /camera/power

[/camera/snapshot]
resource = Snapshot
device = cam
depends-on = /camera/power

[/camera/snapshot/state]
resource = SnapshotState
device = cam
depends-on = /camera/power

[/camera/snapshot/recenter]
resource = SnapshotRecenter
device = cam
depends-on = /camera/power

[/camera/snapshot/domeslewing]
resource = SnapshotDomeslewing
device = cam
depends-on = /camera/power

[/camera/settings]
resource = Settings
device = cam
depends-on = /camera/power

##############
# camera2 (Atik — spectro/echelle station)
@@ -320,62 +268,50 @@ depends-on = /camera/power
[/camera2/power]
resource = State
device = pdu_cam2
depends-on = /telescope/power

[/camera2/frame/binning]
resource = FrameBinning
device = cam2
depends-on = /camera2/power

[/camera2/frame/full]
resource = FrameFull
device = cam2
depends-on = /camera2/power

[/camera2/frame/half]
resource = FrameHalf
device = cam2
depends-on = /camera2/power

[/camera2/frame/small]
resource = FrameSmall
device = cam2
depends-on = /camera2/power

[/camera2/cooler]
resource = Cooler
device = cam2
depends-on = /camera2/power

[/camera2/cooler/temperature/setpoint]
resource = CoolerTemperatureSetpoint
device = cam2
depends-on = /camera2/power

[/camera2/cooler/warmup]
resource = CoolerWarmup
device = cam2
depends-on = /camera2/power

[/camera2/snapshot]
resource = Snapshot
device = cam2
depends-on = /camera2/power

[/camera2/snapshot/state]
resource = SnapshotState
device = cam2
depends-on = /camera2/power

[/camera2/snapshot/recenter]
resource = SnapshotRecenter
device = cam2
depends-on = /camera2/power

[/camera2/settings]
resource = Settings
device = cam2
depends-on = /camera2/power

# [/camera2/snapshot/domeslewing]
# resource = SnapshotDomeslewing
@@ -393,17 +329,14 @@ device = ipcam
[/webcam/stream]
resource = VideoStream
device = ipcam
depends-on = /webcam/clock

[/webcam/snapshot]
resource = Snapshot
device = ipcam
depends-on = /webcam/clock

[/webcam/position]
resource = Pointing
device = ipcam
depends-on = /webcam/clock

# ##############
# # environment
+25 −1
Original line number Diff line number Diff line
@@ -12,6 +12,8 @@
module = mercury
class = Stage
node = MERCURY
depends-on = pdu_stage
ping-using = version
pos_imaging = 1
pos_spectro = 68
pos_echelle = 110
@@ -20,6 +22,7 @@ pos_echelle = 110
module = stx
class = Ao
node = STX
depends-on = cam

######################################

@@ -44,11 +47,14 @@ node = MAKO115
module = stx
class = Camera
node = STX
depends-on = pdu_cam
ping-using = all

[cam2]
module = atik
class = Camera
node = DUMMY
depends-on = pdu_cam2

######################################

@@ -57,11 +63,15 @@ module = netio
class = Switch
node = PDU
outlet = 2
depends-on = cab
ping-using = power

[pdu_cam2] # Atik
module = netio
class = Switch
node = PDU
depends-on = cab
ping-using = power
outlet = 5

[pdu_stage] # Mercury
@@ -69,18 +79,23 @@ module = netio
class = Switch
node = PDU
outlet = 1
depends-on = pdu_moxa
ping-using = power

[pdu_moxa]
module = netio
class = Switch
node = PDU
outlet = 7
depends-on = cab
ping-using = connection

[cab]
module = siemens
class = Switch
node = SIEMENS_SWITCH
outlet = 0
ping-using = power

######################################

@@ -88,16 +103,20 @@ outlet = 0
module = astelco
class = Telescope
node = CABINET
depends-on = cab
ping-using = clock

[foc]
module = astelco
class = Focuser
node = CABINET
depends-on = tel

[rot]
module = astelco
class = Rotator
node = CABINET
depends-on = tel

######################################

@@ -105,17 +124,20 @@ node = CABINET
module = alpaca
class = Dome
node = ASCOM_REMOTE
ping-using = connection

[lamp]
module = alpaca
class = Switch
node = ASCOM_REMOTE
depends-on = dom
outlet = 2

[light]
module = alpaca
class = Switch
node = ASCOM_REMOTE
depends-on = dom
outlet = 3

######################################
@@ -124,11 +146,13 @@ outlet = 3
module = ipcam
class = Webcam
node = IPCAM
ping-using = clock

[tel_temp]
module = astelco
class = Sensor
node = CABINET
depends-on = tel
outlet1 = 1
outlet2 = 2

+13 −0
Original line number Diff line number Diff line
@@ -95,6 +95,19 @@ async def ws():
    real_ws = websocket._get_current_object()
    await broadcaster.register(real_ws)

    # Send topology: which endpoint paths belong to root devices (no parent)
    await real_ws.send(json.dumps({
        "name": "endpoint-roots",
        "data": streamer.root_endpoint_paths
    }))

    # Send current endpoint states so the guard has immediate initial state
    if streamer._prev_endpoint_states:
        await real_ws.send(json.dumps({
            "name": "state-diff",
            "data": streamer._prev_endpoint_states
        }))

    # Send recent log history on connect
    path = current_log_path()
    if os.path.exists(path):
+40 −30
Original line number Diff line number Diff line
// dependency-guard.js
// Marks widget containers as .subsystem-offline when their root endpoint is off or unreachable.
//
// "offline" = errors present OR powered=false (raw !== true).
// The container that holds the power/connection toggle itself is always exempt so the
// user can always switch the subsystem on.
// Marks widget containers as .subsystem-offline when their subsystem root is inactive.
// State arrives from the server via 'state-diff' WebSocket events (server-side cascade).

// Root endpoint path for each subsystem — the "gate" controlling the whole chain.
const ROOTS = {
    telescope: { key: 'power',      powered: true  },
    dome:      { key: 'connection', powered: true  },
    camera:    { key: 'power',      powered: true  },
    camera2:   { key: 'power',      powered: true  },
    stage:     { key: 'power',      powered: true  },
    webcam:    { key: 'clock',      powered: false },  // just check reachability
    telescope: '/telescope/power',
    dome:      '/dome/connection',
    camera:    '/camera/power',
    camera2:   '/camera2/power',
    stage:     '/stage/connection',
    webcam:    '/webcam/clock',
};

const endpointStates = {};

// Populated from 'endpoint-roots' message: paths whose device has no parent in devices.ini.
// A root container is only exempt from the offline class if its path is in this set
// (true root, always accessible) or if the endpoint is currently ON (so the user can turn it off).
const rootDeviceEndpoints = new Set();

document.addEventListener('endpoint-roots', e => {
    (e.detail ?? []).forEach(p => rootDeviceEndpoints.add(p));
});

function nearestContainer(el) {
    return el.closest('fieldset.widget-universal, .card.bg-dark');
}

function isReachable(sub, data) {
    const { key, powered } = ROOTS[sub];
    const entry = data?.[key];
    if (!entry || entry.errors?.length) return false;
    return powered ? entry.raw === true : true;
}

// A container that holds the root toggle (e.g. /telescope/power) stays enabled even
// when the subsystem is offline — it's the switch to turn it back on.
function isRootContainer(container, sub) {
    const rootUrl = `/${sub}/${ROOTS[sub].key}`;
    return !!container.querySelector(`.btn-universal[data-url="${rootUrl}"]`);
    const rootPath = ROOTS[sub];
    if (!container.querySelector(`.btn-universal[data-url="${rootPath}"]`)) return false;
    // Device has no parent → always accessible (user must be able to turn it on from scratch).
    // Device has a parent → only accessible when the endpoint is ON (so user can turn it off).
    // Before endpoint-roots arrives, default to always-accessible (safe fallback).
    const alwaysOn = rootDeviceEndpoints.size === 0 || rootDeviceEndpoints.has(rootPath);
    return alwaysOn || endpointStates[rootPath] === true;
}

function applyGuard(sub, offline) {
    // 1. Explicit data-subsystem (set dynamically by applyMode for camera cards)
    const rootPath = ROOTS[sub];

    // 1. Explicit data-subsystem containers (e.g. webcam card)
    document.querySelectorAll(`[data-subsystem="${sub}"]`).forEach(el => {
        el.classList.toggle('subsystem-offline', offline);
    });

    // 2. Auto-detected: containers holding btn-universal with /{sub}/ URLs
    // 2. Auto-detected: containers holding /{sub}/ buttons
    const seen = new WeakSet();
    document.querySelectorAll(`.btn-universal[data-url^="/${sub}/"]`).forEach(btn => {
        const container = nearestContainer(btn);
@@ -49,10 +56,13 @@ function applyGuard(sub, offline) {
    });
}

document.addEventListener('noctua-telemetry', e => {
    const { name, data } = e.detail || {};
    if (!name?.startsWith('all-')) return;
    const sub = name.slice(4);
    if (!(sub in ROOTS)) return;
    applyGuard(sub, !isReachable(sub, data));
document.addEventListener('state-diff', e => {
    const diff = e.detail ?? {};
    Object.assign(endpointStates, diff);

    for (const [sub, rootPath] of Object.entries(ROOTS)) {
        if (rootPath in diff) {
            applyGuard(sub, endpointStates[rootPath] === false);
        }
    }
});
Loading