Commit f1afbc37 authored by vertighel's avatar vertighel
Browse files

Guard: gate-based dependent root exemption; synoptic stage animation fix



dependency-guard.js + __init__.py:
- endpoint-roots now carries gates map (path → parent-chain gate path)
- isRootContainer: dependent roots (e.g. /camera/power) are accessible when
  their infrastructure gate is active, regardless of outlet state
- applyGuard: always remove subsystem-offline when going online, even from
  root containers (fixes class not removed after offline→online transition)

synoptic.svg: replace stage group with cross-shaped mirror element

synoptic.js (animateStage):
- SVG default = 0mm; mirror moves left as position increases (0mm = right end)
- travel = refWidth − mirrorWidth so mirror stays within stage-reference bar
- clamp range corrected to 0–120mm

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 21507440
Loading
Loading
Loading
Loading
+5 −2
Original line number Diff line number Diff line
@@ -95,10 +95,13 @@ 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)
    # Send topology: root device endpoints + gates for each endpoint that has one
    await real_ws.send(json.dumps({
        "name": "endpoint-roots",
        "data": streamer.root_endpoint_paths
        "data": {
            "roots": streamer.root_endpoint_paths,
            "gates": streamer._endpoint_gate,
        }
    }))

    # Send current endpoint states so the guard has immediate initial state
+44 −33
Original line number Diff line number Diff line
@@ -23,15 +23,15 @@
   inkscape:pageopacity="0.0"
   inkscape:pagecheckerboard="0"
   inkscape:deskcolor="#d1d1d1"
   inkscape:zoom="1.3965359"
   inkscape:cx="426.05421"
   inkscape:cy="131.75458"
   inkscape:zoom="7.9"
   inkscape:cx="206.26582"
   inkscape:cy="202.65823"
   inkscape:window-width="2511"
   inkscape:window-height="1371"
   inkscape:window-x="0"
   inkscape:window-y="0"
   inkscape:window-maximized="1"
   inkscape:current-layer="prova"
   inkscape:current-layer="stage"
   inkscape:pageshadow="2"
   showgrid="false" /><defs
   id="defs2">
@@ -401,34 +401,6 @@
     style="fill:#008080;fill-opacity:0.3;stroke:#cccccc;stroke-width:0.999996;stroke-linecap:round;stroke-dasharray:none"
     id="path52"
     sodipodi:nodetypes="cssc" /></g><g
   id="stage"
   transform="rotate(-45,297.23637,173.22426)"
   style="display:inline"><rect
     style="fill:#f2f2f2;fill-opacity:0.298039;stroke:none;stroke-width:0.899999;stroke-linecap:round;stroke-dasharray:none"
     id="stage-reference"
     width="124.45097"
     height="21.961935"
     x="140.85654"
     y="123.24329"
     rx="9.8353071"
     ry="9.8353071" /><path
     sodipodi:type="star"
     style="fill:#008080;fill-opacity:0.298039;stroke:#cccccc;stroke-width:1.12162;stroke-linecap:round;stroke-dasharray:none"
     id="structure-stage-mirror"
     inkscape:flatsided="true"
     sodipodi:sides="3"
     sodipodi:cx="166.32912"
     sodipodi:cy="19.746836"
     sodipodi:r1="17.905018"
     sodipodi:r2="8.9525089"
     sodipodi:arg1="0.52359878"
     sodipodi:arg2="1.5707963"
     inkscape:rounded="0.157"
     inkscape:randomized="0"
     d="m 181.83532,28.699345 c -2.43448,4.216631 -28.57793,4.216631 -31.0124,0 -2.43448,-4.216632 10.63725,-26.8575272 15.5062,-26.8575271 4.86894,0 17.94067,22.6408951 15.5062,26.8575271 z"
     inkscape:transform-center-y="-6.5168619"
     transform="matrix(0.53758934,0.51861339,0.93113202,-0.29942158,120.62526,54.076636)"
     inkscape:transform-center-x="-1.2258825" /></g><g
   inkscape:groupmode="layer"
   id="layer2"
   inkscape:label="wires"
@@ -563,7 +535,46 @@
     id="structure-beam-echelle"
     style="display:inline;fill:none;stroke:#37c8ab;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
     d="m 203.17512,214.14623 84.48557,-84.72949 66.3242,-2e-5 m -103.4728,37.31002 45.85233,-3e-5 47.85234,-1e-5"
     sodipodi:nodetypes="cccccc" /></g><path
     sodipodi:nodetypes="cccccc" /></g><g
   id="stage"
   transform="rotate(-45,303.50187,188.35052)"
   style="display:inline"><rect
     style="display:inline;fill:#f2f2f2;fill-opacity:0.298039;stroke:none;stroke-width:0.90038;stroke-linecap:round;stroke-dasharray:none"
     id="stage-reference"
     width="80.21328"
     height="14.155283"
     x="190.91089"
     y="132.95383"
     rx="6.339221"
     ry="6.339221"
     transform="matrix(0.99978808,-0.02058631,-0.02058631,0.99978808,0,0)" /><g
     id="structure-stage-mirror"
     style="display:inline;fill:#37c8ab;fill-opacity:1"
     transform="translate(-1.606646,-0.81003344)"><rect
       style="fill:#37c8ab;fill-opacity:1;stroke-width:1;stroke-linecap:round"
       id="rect1"
       width="2.6582279"
       height="17.215189"
       x="265.87256"
       y="-86.262161"
       transform="rotate(45)"
       rx="5.2959347"
       ry="2.9185479" /><rect
       style="fill:#37c8ab;fill-opacity:1;stroke-width:1;stroke-linecap:round"
       id="rect2"
       width="2.6582279"
       height="17.215189"
       x="86.072289"
       y="268.18268"
       rx="5.2959347"
       ry="2.9185479"
       transform="rotate(-45)" /></g><path
     style="fill:none;stroke:#ffff00;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
     d="m 151.36689,128.41347 v 14.67918"
     id="path2" /><path
     style="fill:none;stroke:#ffff00;stroke-width:1px;stroke-linecap:butt;stroke-linejoin:miter;stroke-opacity:1"
     d="m 242.75352,127.6079 v 14.67918"
     id="path5" /></g><path
   id="icon:dome-light-status"
   style="fill:#008080;fill-opacity:0.298039;stroke:#cccccc;stroke-width:0.999998;stroke-dasharray:none"
   d="m 197.14131,44.062272 c 0.0241,0.729645 -0.7628,3.424269 -2.9302,3.424269 -2.1674,0 -2.9302,-2.49921 -2.9302,-3.424269 z m 3.90751,-10.42066 c 1e-4,-0.907814 -0.1805,-1.806613 -0.53144,-2.643868 -0.35094,-0.837246 -0.86499,-1.596196 -1.51237,-2.232645 -0.64738,-0.636439 -1.41499,-1.13759 -2.25811,-1.474211 -0.84301,-0.336631 -1.74473,-0.501963 -2.65241,-0.486403 -1.74249,0.05182 -3.40159,0.757963 -4.64688,1.977902 -1.24529,1.219949 -1.98543,2.86419 -2.07305,4.605265 -0.0417,1.161813 0.21363,2.315042 0.74173,3.350769 0.5281,1.035629 1.31158,1.919572 2.27636,2.56822 0.27192,0.176789 0.49553,0.418432 0.6507,0.70315 0.15518,0.284815 0.23703,0.603718 0.23814,0.927993 v 1.494401 h 5.8604 v -1.494401 c 0,-0.323396 0.0804,-0.641713 0.23383,-0.92643 0.15345,-0.284718 0.37517,-0.52685 0.64523,-0.704713 0.93005,-0.624034 1.69268,-1.467052 2.2206,-2.454822 0.52803,-0.987769 0.80522,-2.090207 0.80727,-3.210207 z"
+22 −10
Original line number Diff line number Diff line
@@ -14,13 +14,16 @@ const ROOTS = {

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).
// Populated from 'endpoint-roots' on connect.
// roots: paths whose device has no parent → always accessible.
// gates: {path → gate_path} — the parent-chain gate for each endpoint.
const rootDeviceEndpoints = new Set();
const endpointGates = {};

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

function nearestContainer(el) {
@@ -30,11 +33,18 @@ function nearestContainer(el) {
function isRootContainer(container, sub) {
    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;

    // Before endpoint-roots arrives: safe fallback — everything accessible.
    if (rootDeviceEndpoints.size === 0) return true;

    // True root (no parent device): always accessible so user can turn it on.
    if (rootDeviceEndpoints.has(rootPath)) return true;

    // Dependent root (e.g. /camera/power depends on cab):
    // accessible when the parent infrastructure (gate) is active — even if the
    // outlet itself is currently off — so the user can turn it on.
    const gate = endpointGates[rootPath];
    return gate ? endpointStates[gate] === true : false;
}

function applyGuard(sub, offline) {
@@ -51,7 +61,9 @@ function applyGuard(sub, offline) {
        const container = nearestContainer(btn);
        if (!container || container.dataset.subsystem || seen.has(container)) return;
        seen.add(container);
        if (isRootContainer(container, sub)) return;
        // Root containers are exempt from being disabled (skip when going offline).
        // When going back online always remove the class, even from root containers.
        if (offline && isRootContainer(container, sub)) return;
        container.classList.toggle('subsystem-offline', offline);
    });
}
+16 −15
Original line number Diff line number Diff line
@@ -54,18 +54,31 @@ function initializeGeometry() {
    if (stageRefEl && stageMirrorEl) {
        const sRefBBox = stageRefEl.getBBox();
        stageRefGeom = {
            startX: sRefBBox.x,                      
            endX: sRefBBox.x + sRefBBox.width,       
            widthPx: sRefBBox.width
        };

        const mBBox = stageMirrorEl.getBBox();
        stageMirrorGeom = {
            cx: mBBox.x + mBBox.width / 2
            width: mBBox.width
        };
    }
}

function animateStage(posMm) {
    const mirrorEl = document.getElementById('structure-stage-mirror');
    if (!mirrorEl || !stageRefGeom || !stageMirrorGeom) return;

    const clampedPos = Math.max(0, Math.min(120, posMm));
    const fraction = clampedPos / 120.0;

    // SVG default = 0mm (right end). Higher position → mirror moves left.
    // Travel range = refWidth − mirrorWidth so mirror stays within the reference bar.
    const travelPx = stageRefGeom.widthPx - stageMirrorGeom.width;
    const deltaX = -(fraction * travelPx);

    mirrorEl.style.transform = `translate(${deltaX}px, 0px)`;
}

function animateBeams(namedPosition) {
    const beamIn  = document.getElementById('structure-beam-incoming');
    const beamImg = document.getElementById('structure-beam-imaging');
@@ -146,18 +159,6 @@ function animatePetals(state) {
    cover4El.style.transform = `translate(${offsetPx}px, -${offsetPx}px)`;
}

function animateStage(posMm) {
    const mirrorEl = document.getElementById('structure-stage-mirror');
    if (!mirrorEl || !stageRefGeom || !stageMirrorGeom) return;

    const clampedPos = Math.max(0, Math.min(150, posMm));
    const percentage = clampedPos / 150.0;
    const targetX = stageRefGeom.startX + (percentage * stageRefGeom.widthPx);
    const deltaX = targetX - stageMirrorGeom.cx;

    mirrorEl.style.transform = `translate(${deltaX}px, 0px)`;
}

function updateSwitchColor(el, state) {
    if (!el) return;