Feat/omny eps #267
@@ -0,0 +1,303 @@
|
||||
# EPS / Machine Status Page — Design, Findings, Integration Plan
|
||||
|
||||
cSAXS (X12SA). A standalone daemon that reads EPICS PVs over Channel Access,
|
||||
writes `eps_status.json` + two HTML pages, serves them locally, and optionally
|
||||
uploads them. Built to run on its own now, and to fold into the flOMNI
|
||||
webpage generator later.
|
||||
|
||||
---
|
||||
|
||||
## 1. What the page is
|
||||
|
||||
Two browser pages backed by one JSON file:
|
||||
|
||||
**`eps_status.html`** — the overview. Ordered top to bottom:
|
||||
1. **Machine status** — ring current, lifetime, light delivery, undulator gap,
|
||||
beamline status, orbit feedback, injection rate, ring vacuum.
|
||||
2. **Operator messages** — the SLS control-room message board.
|
||||
3. **Alarm status** — the EPS alarm banner (green when `AlarmCnt_EPS == 0`,
|
||||
red with the alarm text otherwise), plus front-end alarms.
|
||||
4. **Mono cryocooler** — run / alarm / inhibit / interlock / heartbeat.
|
||||
5. **Beamline synoptic** — a link to the full synoptic page.
|
||||
6. **Zone detail** — FE / OP / ES / EH1 cards, each a severity-colored summary
|
||||
with an expandable table of every channel.
|
||||
7. **Recent history** — the last 5 EPS + machine events.
|
||||
|
||||
**`eps_synoptic.html`** — a faithful replica of the caQtDM EPS expert panel
|
||||
(`X_EPS_X12SA-Graphic_new.ui`), transpiled to SVG at true geometry. Pipes,
|
||||
valves, gauges, ion pumps, chambers, and live value readouts, colored by EPICS
|
||||
severity, with valves showing their real open/closed state. Pan (drag), zoom
|
||||
(wheel / pinch), fit. Works as a desktop wall display or a phone pan-around.
|
||||
|
||||
Both pages auto-refresh every 10 s and degrade gracefully: unreachable PVs
|
||||
render grey, a stale bulk sweep is flagged, and a demo banner shows when values
|
||||
are synthetic.
|
||||
|
||||
---
|
||||
|
||||
## 2. How it works
|
||||
|
||||
### Data flow
|
||||
|
||||
```
|
||||
EPICS IOCs
|
||||
│ Channel Access
|
||||
▼
|
||||
┌──────────────────┐ tier 1 (hot): CA monitors, ~15 s, ~22 PVs
|
||||
│ CaReader │ tier 2 (bulk): periodic caget, ~5 min, ~366 PVs
|
||||
└────────┬─────────┘
|
||||
│ values + severity + connection state
|
||||
▼
|
||||
┌──────────────────┐ builds one payload dict:
|
||||
│ EpsStatusGenerator│ hot, bulk{zones,synoptic,synoptic_values,cryo,
|
||||
│ _build_payload │ machine,messages}, history, generator
|
||||
└────────┬─────────┘
|
||||
│ eps_status.json (+ history file)
|
||||
├─────────────► LocalHttpServer (port 8082)
|
||||
└─────────────► HttpUploader (optional, → omny.online)
|
||||
│
|
||||
▼
|
||||
eps_status.html / eps_synoptic.html
|
||||
fetch JSON every 10 s, render
|
||||
```
|
||||
|
||||
### Two tiers
|
||||
|
||||
| Tier | Cadence | Mechanism | What |
|
||||
|---|---|---|---|
|
||||
| hot | 15 s | CA **monitors** (push) | shutters, EPS alarms, ring current, light delivery, undulator gap |
|
||||
| bulk | 5 min | periodic `caget` | ~366 gauges, pumps, temps, valves, cooling, cryocooler, machine |
|
||||
|
||||
Hot PVs are monitored so a shutter close appears within seconds. Bulk uses
|
||||
plain `caget` because holding hundreds of subscriptions for data nobody is
|
||||
watching isn't worth the IOC load. Both write into one JSON with separate
|
||||
timestamps, so the page can grey out detail readings independently when the
|
||||
bulk sweep goes stale.
|
||||
|
||||
### The synoptic transpiler (`eps_synoptic_svg.py`)
|
||||
|
||||
Runs once at startup. Parses the caQtDM `.ui` and emits `eps_synoptic.svg`
|
||||
with three layers:
|
||||
- **static** — fixed structure (pipes, chamber outlines, section labels)
|
||||
- **dynamic** — shapes recolored / shown-hidden live, tagged `data-pv` and
|
||||
`data-vis*`
|
||||
- **text** — live value fields, tagged `data-pv`
|
||||
|
||||
It expands each `caInclude` by loading the sub-template (`EPS_Valve.ui`,
|
||||
`EPS_Gauge.ui`, …), offsetting its shapes to the include's position and
|
||||
substituting the macro — so real device glyphs are drawn, not just the pipe
|
||||
skeleton. The frontend then, every refresh: evaluates visibility rules,
|
||||
recolors alarm shapes from `.SEVR`, and fills value text.
|
||||
|
||||
### History
|
||||
|
||||
`eps_alarm_history.json`, persisted across restarts, three streams:
|
||||
- **eps_events** — opened when `AlarmCnt_EPS` goes 0 → non-zero, closed when it
|
||||
returns; rapid re-alarms with the same text are coalesced.
|
||||
- **machine_events** — verbatim transitions of key PVs (light delivery, BL
|
||||
status, EPS permit, shutters, FOFB). No inference — "PV said X, now Y, at T".
|
||||
- **ring_current_trace** — one sample per bulk cycle, ~24 h, for the sparkline.
|
||||
|
||||
50 events/stream persisted; 5 shown on the page.
|
||||
|
||||
---
|
||||
|
||||
## 3. Key findings (the non-obvious things)
|
||||
|
||||
These are the things that cost time to discover and are easy to get wrong
|
||||
again. Worth reading before touching the code.
|
||||
|
||||
**Three PV namespaces, not one.** The EPS lives in three disjoint namespaces:
|
||||
per-device PLC channels (`X12SA-OP-DMM-VPIG-3010:PRESS-DISP`), subsystem
|
||||
rollups (`X12SA-EPS-VS:SYSTEM_COM`), and the beamline-native names
|
||||
(`X12SA-OP-MO:TC1`). An early assumption that the flat panel `X_EPS_X12SA.ui`
|
||||
was a pre-resolved copy of the synoptic was wrong — they share **zero** PV
|
||||
names. The manifest is scoped to what the EPS expert panel actually draws.
|
||||
|
||||
**caQtDM panels are template-driven.** The synoptic is ~120 `caInclude`
|
||||
instantiations of `EPS_*.ui` sub-templates, each stamped with a macro carrying
|
||||
the device name. The PVs only exist once you expand include × template ×
|
||||
suffix. The suffix map is in the manifest header.
|
||||
|
||||
**Colors come from EPICS severity, not thresholds.** `colorMode: Alarm` in
|
||||
caQtDM means the widget colors from the PV's `.SEVR`
|
||||
(NO_ALARM/MINOR/MAJOR/INVALID → green/amber/red/grey). No per-device thresholds
|
||||
are reimplemented anywhere; the browser reads severity and maps it. Units come
|
||||
from `.EGU`, read once and cached.
|
||||
|
||||
**Visibility rules are how valves work.** A valve is a green OPEN bowtie + a red
|
||||
CLOSED bowtie + a stem, each shown/hidden by a `visibility` rule bound to
|
||||
`PLC_OPEN`/`PLC_CLOSE`. You must carry those rules through and evaluate them
|
||||
live; drawing all states at once is meaningless. The full `visibilityCalc`
|
||||
grammar (`(A=0)||(B=0)||(C=0)`, `!(A=1&&B=1)`, lowercase `a=0`, channels A–D)
|
||||
is evaluated in the frontend.
|
||||
|
||||
**caGraphics Lines are axis-aligned through the box centre.** A caQtDM Line is
|
||||
horizontal (or vertical) through the middle of its bounding box, not
|
||||
corner-to-corner. Drawing it corner-to-corner gives every pipe a slope equal to
|
||||
the box height — the cause of the skewed-pipe bug.
|
||||
|
||||
**Every PV the synoptic references must be in the payload.** Valve
|
||||
`PLC_OPEN`/`PLC_CLOSE` are visibility channels, not in `PANEL_ITEMS` (which
|
||||
carries only `PLC_STATUS`). They were being polled but dropped from the JSON, so
|
||||
valves showed the transition stem. `bulk.synoptic_values` now exposes every
|
||||
synoptic PV's value/severity/connection.
|
||||
|
||||
**pyepics teardown.** Do not call `PV.disconnect()` on shutdown — pyepics'
|
||||
`atexit` handler finalizes libca, and a manual disconnect races its background
|
||||
thread into freed memory (native segfault after "generator stopped"). Clear
|
||||
callbacks, drop references, let atexit finalize.
|
||||
|
||||
**Channel Access is asynchronous — read in parallel.** Creating all PV objects
|
||||
issues all searches at once; a dead IOC doesn't answer slowly, it doesn't answer
|
||||
at all. Bulk read = create all, poll once in a shared window, then `get()` the
|
||||
connected ones. Serial create-then-get costs `N × timeout` (tens of minutes for
|
||||
unreachable IOCs) and stalls the write loop.
|
||||
|
||||
**SSRF hardening.** `allow_redirects=False` on every upload POST, so a
|
||||
compromised public server can't redirect the internal process to an arbitrary
|
||||
internal address. (This is in `web_common.py`; the base flOMNI generator's copy
|
||||
of `HttpUploader` predates the fix — see integration note below.)
|
||||
|
||||
**Live `--verify` result (beamline in shutdown):** 291/302 reachable. 8 were a
|
||||
real bug — chamber `$(SP)` relay PVs mis-stored as the chamber's `DEV` label,
|
||||
now fixed. 3 were correct names on an offline IOC (FE photon shutter, down
|
||||
during shutdown). The one macro-expansion guess (`X12SA-UIND:GAP-RBV`) was
|
||||
confirmed correct.
|
||||
|
||||
---
|
||||
|
||||
## 4. Files
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `eps_status_generator.py` | the daemon: CA reader, tiers, history, payload, CLI, `--verify` |
|
||||
| `eps_pv_manifest.py` | 248 panel PVs + hot/machine/cryo sets, grouped by zone/role with geometry |
|
||||
| `eps_synoptic_svg.py` | caQtDM `.ui` → SVG transpiler (build-time) |
|
||||
| `eps_synoptic_pvs.py` | auto-generated list of PVs the SVG references |
|
||||
| `eps_synoptic.svg` | prebuilt synoptic template (regenerate if the panel changes) |
|
||||
| `eps_status.html` | overview page |
|
||||
| `eps_synoptic.html` | synoptic page (loads the SVG, applies live data) |
|
||||
| `web_common.py` | `HttpUploader` + `LocalHttpServer`, shared with flOMNI |
|
||||
|
||||
Regenerate the SVG when the panel changes:
|
||||
```
|
||||
python eps_synoptic_svg.py /sls/plc/config/qt/X_EPS_X12SA-Graphic_new.ui eps_synoptic.svg
|
||||
```
|
||||
(needs the `EPS_*.ui` + `VTP.ui` templates on the caQtDM path or alongside.)
|
||||
|
||||
---
|
||||
|
||||
## 5. Running standalone
|
||||
|
||||
```bash
|
||||
python eps_status_generator.py --verify # check every PV, exit
|
||||
python eps_status_generator.py --demo # synthetic values, off-network
|
||||
python eps_status_generator.py # live, local only, port 8082
|
||||
python eps_status_generator.py --upload-url https://omny.online/upload.php
|
||||
```
|
||||
|
||||
Port is 8082 (8081 is `_MISMATCH_LOCAL_PORT` in the flOMNI generator).
|
||||
|
||||
---
|
||||
|
||||
## 6. Integration with the flOMNI webpage generator
|
||||
|
||||
The daemon was built decoupled specifically so this step is low-risk. Nothing
|
||||
here changes `_cycle()` in the base generator.
|
||||
|
||||
### Principle
|
||||
|
||||
The EPS generator owns its own threads (hot monitors, bulk sweep, write loop)
|
||||
and its own output files (`eps_status.json`, `eps_status.html`,
|
||||
`eps_synoptic.*`). Those filenames don't collide with the experiment page's
|
||||
`status.html` / `status.json`. So integration is mostly *lifecycle wiring*, not
|
||||
code surgery.
|
||||
|
||||
### Step 1 — share `web_common.py`
|
||||
|
||||
Move `web_common.py` next to `flomni_webpage_generator.py` and have the base
|
||||
generator import `HttpUploader` / `LocalHttpServer` from it instead of defining
|
||||
its own. Behaviour is identical **plus** the `allow_redirects=False` SSRF fix,
|
||||
which the base copy lacks. This is the only change to existing code, and it's a
|
||||
strict improvement. Verify the base generator's existing `handle_error`
|
||||
override still lives on the server subclass (it does in `web_common`).
|
||||
|
||||
### Step 2 — construct and start the EPS generator from the base generator
|
||||
|
||||
In `WebpageGeneratorBase.start()`:
|
||||
```python
|
||||
from eps_status_generator import EpsStatusGenerator
|
||||
self._eps = EpsStatusGenerator(
|
||||
output_dir=self._output_dir, # same dir as the experiment page
|
||||
serve=False, # let the existing server serve both
|
||||
upload_url=self._upload_url, # reuse the same endpoint
|
||||
)
|
||||
self._eps.start()
|
||||
```
|
||||
and in `stop()`:
|
||||
```python
|
||||
if getattr(self, "_eps", None):
|
||||
self._eps.stop()
|
||||
```
|
||||
With `serve=False`, the EPS generator writes its files into the shared output
|
||||
directory and the base generator's existing `LocalHttpServer` serves them
|
||||
alongside `status.html`. One port, both pages, cross-linked in their headers.
|
||||
|
||||
### Step 3 — decide on cadence ownership
|
||||
|
||||
Two options:
|
||||
- **(simplest) leave the EPS generator's own threads running.** It self-manages
|
||||
hot (15 s) and bulk (5 min). No coupling to the experiment cycle. Recommended.
|
||||
- **(tighter) drive it from the base cycle.** Expose an `EpsStatusGenerator.tick()`
|
||||
that does one hot snapshot + conditional bulk sweep + write, and call it from
|
||||
the base `_cycle()`. Only worth it if you want a single heartbeat. Costs the
|
||||
decoupling that makes a CA hiccup unable to stall the experiment page.
|
||||
|
||||
Recommendation: keep option 1. The decoupling is the whole point — a hung CA
|
||||
read must never delay the experiment status page, which is the one people rely
|
||||
on.
|
||||
|
||||
### Step 4 — capability flag
|
||||
|
||||
Fits the existing plugin/flag pattern (`HAS_TOMO_QUEUE`, `TOMO_TYPES`). Add
|
||||
`HAS_EPS_STATUS` and only construct the EPS generator when the BEC session is on
|
||||
the X12SA network and the flag is set — so the commissioning subnet and
|
||||
off-network dev sessions skip it cleanly (they'd only produce a page of grey
|
||||
tiles anyway).
|
||||
|
||||
### Step 5 — EPICS environment
|
||||
|
||||
The EPS generator needs `import epics` (pyepics) and CA network access to the
|
||||
EPS PLC IOCs and the `AGE*`/`ARS*` accelerator IOCs. Both are reachable from the
|
||||
BEC host on the beamline. Off the X12SA network most IOCs are unreachable — in
|
||||
that case simply don't start it (the capability flag handles this).
|
||||
|
||||
### What stays separate
|
||||
|
||||
The experiment-status producer logic (`flomni.py` items like
|
||||
`estimated_finish_time`, `tomo_start_scan_number`) is untouched. The EPS page is
|
||||
additive: a second JSON and two more HTML files in the same output directory,
|
||||
uploaded through the same endpoint, served by the same server.
|
||||
|
||||
### Deployment note
|
||||
|
||||
The web-server side (`omny.online`, Apache) needs no change — the upload
|
||||
endpoint already accepts `.html`/`.json`/`.svg` (all in
|
||||
`HttpUploader._UPLOAD_SUFFIXES`). The `.svg` is uploaded once and only
|
||||
re-uploaded when the panel is regenerated. Auth gate, staleness handling, and
|
||||
IP-whitelisted upload all apply unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 7. Open items
|
||||
|
||||
- **`--verify` on a running beamline** (this was done during shutdown). Re-run
|
||||
when the machine is up to confirm the FE photon-shutter PVs
|
||||
(`X12SA-EPS-PH:SYSTEM`, `X12SA-FE-PH1:OPEN_EPS/CLOSE_EPS`) come back.
|
||||
- **Cryocooler detail.** The card shows run/alarm/inhibit/heartbeat. Full
|
||||
temperature/pressure detail needs `XDS_CRYOCOOLER_OVERVIEW.ui` (a sub-template
|
||||
not yet supplied).
|
||||
- **LamNI / Omny subclasses.** The experiment page has a subclass factory; the
|
||||
EPS page is beamline-wide and setup-independent, so it likely needs no
|
||||
subclassing — but confirm when integrating.
|
||||
@@ -0,0 +1,398 @@
|
||||
# EPS / SLS Machine Status Page — iteration 1
|
||||
|
||||
Standalone status page for the cSAXS (X12SA) Equipment Protection System and
|
||||
SLS machine status. Reads EPICS PVs over Channel Access, writes
|
||||
`eps_status.json` + `eps_status.html`, serves them locally, and optionally
|
||||
uploads them to `omny.online`.
|
||||
|
||||
No BEC dependency. Runs inside a BEC session or as a plain script.
|
||||
|
||||
## Iteration 8: --verify results (live, against the beamline)
|
||||
|
||||
First live verification run: 291/302 PVs reachable. The 11 unreachable break
|
||||
down as:
|
||||
|
||||
REAL BUG (fixed) - 8 chamber label leaks:
|
||||
Pumpst.3, Pumpst.4, DMM, CCM, Exp-Box1, Exp-Box2, Exp-Box3, KB
|
||||
These were the chamber DEV *labels* stored as PVs. The EPS_Chamber
|
||||
templates bind $(SP) (a relay PV like VMMG-0010:PLC_RELAY-E) as the
|
||||
channel, with DEV being just the display name - the extractor had stored
|
||||
DEV instead of SP. Fixed: chambers now contribute their SP relay PV (which
|
||||
in most cases already existed in the manifest, so PANEL_ITEMS went 256->248
|
||||
and the bogus entries are gone). No non-PV entries remain.
|
||||
|
||||
NOT A BUG - 3 offline-IOC PVs (beamline was in shutdown):
|
||||
X12SA-EPS-PH:SYSTEM, X12SA-FE-PH1:OPEN_EPS, X12SA-FE-PH1:CLOSE_EPS
|
||||
These names are correct (they appear in the panels). They failed because
|
||||
the FE photon-shutter IOC was powered down. Corroborated by the rest of the
|
||||
run: CURRENT=0, LIFETIME=nan, LIGHT-DELIVERY='No Light', BL-STATUS='offline',
|
||||
DUO-SHIFT-TYPE='Shutdown', FE pumps 'HV OFF'. They will return with the
|
||||
machine; the page correctly shows them grey meanwhile.
|
||||
|
||||
Also confirmed from the run: UNDULATOR_GAP_PV (X12SA-UIND:GAP-RBV) resolves
|
||||
(20.0 mm) - the one macro-expansion guess from iteration 1 was correct.
|
||||
AlarmList_EPS returns readable text, so the STRING_PVS/as_string handling is
|
||||
right. Several temperatures read ~3276 (RTD open-circuit sentinel) - real
|
||||
sensor state, shown in alarm colour, not a bug.
|
||||
|
||||
## Iteration 7: vacuum valves showed as transition
|
||||
|
||||
Valves that were physically OPEN rendered as the indeterminate/transition
|
||||
state (purple stem) on the web page. Cause: a valve's green/red triangles are
|
||||
shown by visibility rules on $(DEV):PLC_OPEN / :PLC_CLOSE, but those channels
|
||||
were being POLLED (they are in SYNOPTIC_PVS) and then DROPPED from the JSON -
|
||||
the payloads synoptic array is built from PANEL_ITEMS, which only carries the
|
||||
valves :PLC_STATUS. With no open/close data, the frontend calc
|
||||
(A=0&&B=0)||(A=1&&B=1) saw null/null, evaluated the both-zero branch true, and
|
||||
showed the transition stem.
|
||||
|
||||
Fixes:
|
||||
- generator now emits bulk.synoptic_values: the live value/severity/connected
|
||||
of every SYNOPTIC_PVS entry, so the visibility rules have their data.
|
||||
- frontend merges synoptic_values into its PV lookup map.
|
||||
- shapes carrying a visibility rule are no longer severity-recolored - their
|
||||
colour is intrinsic (green=open, red=closed).
|
||||
- demo valve-state matching fixed for the PLC_OPEN / PLC_CLOSE naming.
|
||||
|
||||
Verified: 14/14 demo valves render as clean OPEN (green shown, red + stem
|
||||
hidden); zero purple/red pixels in the valve band.
|
||||
|
||||
## Iteration 6: calc visibility + pipe fix confirmed
|
||||
|
||||
- **Multi-channel calc visibility.** eps_synoptic.html now evaluates full
|
||||
caQtDM visibilityCalc expressions - (A=0)||(B=0)||(C=0), (A=1)&&(B=1)&&(C=1),
|
||||
!(A=1&&B=1), lowercase a=0, and the valve-stem indeterminate case - mapping
|
||||
channels A..D to data-vis-pv..pvd. Verified against all cooling-valve
|
||||
expressions (8/8). Replaces the previous hardcoded two-channel handling.
|
||||
- **Pipes confirmed straight.** After the Line-centre fix, zero diagonal
|
||||
<line> elements remain anywhere in the SVG; the only diagonals are valve
|
||||
bowtie polygons, which are correct.
|
||||
|
||||
Note on EPS_CoolingValves.ui: these templates are not actually included by
|
||||
X_EPS_X12SA-Graphic_new.ui (grep count 0) - they belong to a different EPS
|
||||
panel. The bottom-left boxes in this synoptic are the XBPM1/DIA1/PSH1
|
||||
temperature glyphs (EPS_Temp.ui). The earlier diagonal pipes there were the
|
||||
Line-skew bug, now fixed. The calc evaluator added for these templates is
|
||||
still correct and general, so if a future panel does include them it works.
|
||||
|
||||
## Iteration 5: straight pipes
|
||||
|
||||
The beam pipe skew was a Line-rendering bug. A caQtDM caGraphics Line is drawn
|
||||
axis-aligned through the CENTRE of its bounding box (horizontal if the box is
|
||||
wider than tall, vertical otherwise). All 79 beam-pipe segments have a 10px
|
||||
-tall box; drawing them corner-to-corner gave every one a 10px slope. Now
|
||||
Lines are emitted horizontal/vertical through the box centre, and the beam
|
||||
pipe is straight.
|
||||
|
||||
Known limitation: the FE cooling manifold (bottom-left) is built from
|
||||
EPS_CoolingValves.ui includes, a template not present in the files provided.
|
||||
Its connector pipes therefore cannot be transpiled faithfully yet - the boxes
|
||||
shown there are the XBPM1/DIA1/PSH1 temperature glyphs (EPS_Temp.ui), which do
|
||||
resolve. To complete the manifold, add EPS_CoolingValves.ui (and any
|
||||
EPS_CoolingValvesSmall.ui) to the template directory and regenerate.
|
||||
|
||||
## Iteration 4: synoptic fidelity + zoning fix
|
||||
|
||||
Four issues from the side-by-side comparison with the real EPS panel:
|
||||
|
||||
- **Valves were mush.** A caQtDM valve is a green OPEN bowtie + a red CLOSED
|
||||
bowtie + a stem, each governed by a visibility rule bound to PLC_OPEN /
|
||||
PLC_CLOSE. The first transpiler ignored visibility and drew all three at
|
||||
once. The transpiler now emits data-vis / data-vis-pv / data-vis-calc on
|
||||
every conditional element, and eps_synoptic.html evaluates those rules live
|
||||
each refresh (IfZero / IfNotZero / Calc), so exactly one valve state shows -
|
||||
matching caQtDM.
|
||||
- **Pipes were skewed.** Near-horizontal / near-vertical polyline and line
|
||||
segments are now snapped straight (SNAP_TOL px).
|
||||
- **Labels were missing.** Device names from the $(DESC) macro are drawn under
|
||||
each glyph; static caLabel text (section headings) is rendered too.
|
||||
- **Values hard to read.** Value fields now use a larger bold font on a subtle
|
||||
backing box, like the panel readouts.
|
||||
- **Zoning bug (OP devices shown under End Station).** Zones were assigned by
|
||||
x-pixel-thirds, which misfiled 34 OP devices as ES and 23 as FE. Zone is now
|
||||
derived from the PV name (-FE- / -OP- / -ES- / -EH1-), which is
|
||||
authoritative. FE 75 / OP 147 / ES 31 / EH1 3.
|
||||
|
||||
## Iteration 3: full synoptic
|
||||
|
||||
The compressed inline dot-synoptic was dropped - reducing a 3071x774
|
||||
engineering drawing to a 1000x260 dot field discarded the pipe routing that
|
||||
makes a synoptic readable. Replaced with a faithful transpile on its own page.
|
||||
|
||||
- **eps_synoptic_svg.py** parses X_EPS_X12SA-Graphic_new.ui once and emits
|
||||
**eps_synoptic.svg** at true 3071x774 geometry: static pipe/chamber
|
||||
structure, PV-colored alarm shapes (tagged data-pv), and value fields. It
|
||||
expands each caInclude by loading the sub-template (EPS_Valve.ui etc.),
|
||||
offsetting its shapes to the include position, and substituting the macro -
|
||||
so actual valve/gauge/pump glyphs are drawn, not just the pipe skeleton.
|
||||
Result: 463 static shapes, 192 PV-colored shapes, 178 live value fields.
|
||||
- **eps_synoptic.html** loads that SVG + eps_status.json, recolors shapes from
|
||||
.SEVR and fills value text live every 10 s. Pan (drag), zoom (wheel/pinch),
|
||||
fit button; works as a desktop wall display or phone pan-around.
|
||||
- Colors are the panel's literal RGB for static elements; alarm elements use
|
||||
severity colors. Unresolved caQtDM colors fall back to pipe-grey (close, as
|
||||
agreed).
|
||||
- The synoptic references 39 setpoint fields beyond the core manifest;
|
||||
eps_synoptic_pvs.py lists all 251, and the generator merges them into the
|
||||
bulk sweep so every drawn field has data. Total PVs: 327.
|
||||
- The main page now links to the synoptic instead of embedding it.
|
||||
|
||||
Regenerate the SVG whenever the panel changes:
|
||||
python eps_synoptic_svg.py /sls/plc/config/qt/X_EPS_X12SA-Graphic_new.ui eps_synoptic.svg
|
||||
(needs the EPS_*.ui + VTP.ui templates on the same path or alongside.)
|
||||
|
||||
## Iteration 2 changes
|
||||
|
||||
- **Manifest re-scoped to the EPS expert panel.** Instead of the full
|
||||
600-PV macro expansion, the manifest now carries the 256 curated channels
|
||||
actually drawn on `X_EPS_X12SA-Graphic_new.ui` (the panel operators watch),
|
||||
each tagged with its real geometry, role, and zone. Total PV count dropped
|
||||
from 602 to 288.
|
||||
- **SVG synoptic.** A schematic mimicking the expert panel: FE / Optics /
|
||||
End-Station bands, beam axis, and every device glyph placed by its true
|
||||
panel position, shape-coded by role and colored by `.SEVR`. Scales down for
|
||||
phones (min-width 680 px, horizontal scroll rather than crushed glyphs).
|
||||
- **Mono cryocooler card.** `X12SA-OP-CC-ECCN-0010` run/alarm/inhibit +
|
||||
toplevel `PLC_STS`/`Heartbeat_MON` + `CCM:DISABLE`, mirroring what the EPS
|
||||
panel shows. Full temperature/pressure detail needs
|
||||
`XDS_CRYOCOOLER_OVERVIEW.ui` (not yet supplied); the card expands when it is.
|
||||
- **Layout reordered:** machine status -> operator messages -> alarm status ->
|
||||
cryocooler -> synoptic -> zone detail -> history.
|
||||
- **History display capped at 5** per stream (50 still persisted on disk).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `eps_pv_manifest.py` | 602 PVs, grouped by zone and role |
|
||||
| `web_common.py` | `HttpUploader` + `LocalHttpServer`, lifted from `flomni_webpage_generator.py` |
|
||||
| `eps_status_generator.py` | The daemon |
|
||||
| `eps_status.html` | Frontend (desktop-first, 4-column zone grid) |
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
# frontend work, no network, no EPICS needed
|
||||
python eps_status_generator.py --demo
|
||||
# -> http://localhost:8082/eps_status.html
|
||||
|
||||
# real data, local only, no upload
|
||||
python eps_status_generator.py
|
||||
|
||||
# with upload
|
||||
python eps_status_generator.py --upload-url https://omny.online/upload.php
|
||||
```
|
||||
|
||||
**Do this first on a BEC session:**
|
||||
|
||||
```bash
|
||||
python eps_status_generator.py --verify
|
||||
```
|
||||
|
||||
Reads every PV once and reports reachability per group. See "Verify this"
|
||||
below — this is the only thing standing between the manifest and a set of
|
||||
possibly-wrong PV names.
|
||||
|
||||
Embedded:
|
||||
|
||||
```python
|
||||
from eps_status_generator import EpsStatusGenerator
|
||||
gen = EpsStatusGenerator(output_dir="~/eps_status")
|
||||
gen.start()
|
||||
...
|
||||
gen.stop()
|
||||
```
|
||||
|
||||
## Port
|
||||
|
||||
Default **8082**. Not 8081 — that is `_MISMATCH_LOCAL_PORT` in
|
||||
`flomni_webpage_generator.py`, the fallback port bound when the BEC account
|
||||
does not match the system user. Using 8081 here would make that fallback fail
|
||||
to bind, with a confusing error.
|
||||
|
||||
## Tiers
|
||||
|
||||
| Tier | Cadence | PVs | Mechanism |
|
||||
|---|---|---|---|
|
||||
| hot | 15 s | 26 | CA monitors (callback-driven) |
|
||||
| bulk | 5 min | 576 | periodic `caget`, 2 s timeout |
|
||||
|
||||
Hot PVs are monitored so a shutter close appears within seconds. Bulk uses
|
||||
plain `caget` — 576 open subscriptions for data nobody is watching is not
|
||||
worth the IOC load.
|
||||
|
||||
Both tiers write into one JSON with separate timestamps, so the frontend
|
||||
greys out detail readings independently when the bulk sweep goes stale.
|
||||
|
||||
## PV provenance — read this before trusting the manifest
|
||||
|
||||
Three independent namespaces, with different confidence levels:
|
||||
|
||||
**`SUMMARY_PVS` (259) — HIGH confidence.** Verbatim from
|
||||
`X_EPS_X12SA.ui` and `X_EPS_X12SA-IonPumps.ui`. EPS subsystem rollups:
|
||||
`X12SA-EPS-VS:SYSTEM_COM`, `X12SA-EPS-CC:SYSTEM`, `X12SA-GE-CS:WP1`, etc.
|
||||
Copied straight out of the panels, no transformation.
|
||||
|
||||
**`DEVICE_PVS` (298) — MEDIUM confidence, unvalidated.** Expanded from the
|
||||
120 `caInclude` macro instantiations in `X_EPS_X12SA-Graphic_new.ui` combined
|
||||
with the `EPS_*.ui` templates. The suffix map:
|
||||
|
||||
| Template | Suffixes |
|
||||
|---|---|
|
||||
| `EPS_Temp` | `:TEMP` |
|
||||
| `EPS_Gauge` | `:PRESSURE`, `:STATUS` |
|
||||
| `EPS_IonPump` | `:PRESS-DISP`, `:VOLTAGE`, `:HV`, `:ERROR`, `:SETPOINT-STATUS` |
|
||||
| `EPS_Valve`, `_inv` | `:PLC_STATUS`, `:PLC_OPEN`, `:PLC_CLOSE`, `:PLC_ERROR` |
|
||||
| `EPS_BST` | `-EMLS:STATE`, `-EMLS-0010:OPEN`, `-EMLS-0020:CLOSE` |
|
||||
| `VTP` | `:PLC_TURBO-RBV`, `:PLC_PUMP-RBV`, `:PLC_SPEED-SP` |
|
||||
| `EPS_ShtrCtrl` | `:SH-A-ENABLE`, `:EXTERN-EPS-ENABLE`, `:EXTERN-MIS-ENABLE` |
|
||||
|
||||
These were **never cross-validated**. An early assumption that
|
||||
`X_EPS_X12SA.ui` was a pre-resolved copy of the synoptic turned out to be
|
||||
wrong — the two files share **zero** PV names, because they are different
|
||||
namespaces (device-level PLC channels vs. subsystem rollups). So there is no
|
||||
independent check available; `--verify` against live IOCs is the only one.
|
||||
|
||||
**`MACHINE_PVS` (21) — HIGH confidence.** Verbatim from
|
||||
`A_OP_Info_Panel_inc_*.ui`, except the undulator gap (below).
|
||||
|
||||
## Verify this
|
||||
|
||||
Two specific things `--verify` should settle:
|
||||
|
||||
1. **`UNDULATOR_GAP_PV = "X12SA-UIND:GAP-RBV"`** — expanded from the nested
|
||||
default macro `$(ID=$(Beamline)-UIND$(IDNR=)):GAP-RBV` assuming `IDNR` is
|
||||
empty. Could be `X12SA-UIND1:GAP-RBV`. Isolated as a module constant in
|
||||
`eps_pv_manifest.py` so it is a one-line fix.
|
||||
|
||||
2. **`X12SA-EPS-PLC:AlarmList_EPS` type** — if it is a char waveform rather
|
||||
than a string record, it needs `as_string=True`. It is already listed in
|
||||
`STRING_PVS`; `--verify` will show whether the text comes back readable or
|
||||
as a byte array.
|
||||
|
||||
Also worth checking: whether the accelerator PVs (`AGEBD-*`, `AGEOP-*`,
|
||||
`ARSGE-*`, `ARIVA-*`) are reachable from the BEC host. They live on a
|
||||
different subnet from the beamline IOCs.
|
||||
|
||||
## Performance with unreachable PVs
|
||||
|
||||
Channel Access is asynchronous: creating PV objects issues all searches in
|
||||
parallel, and a dead IOC does not answer *slowly*, it does not answer *at
|
||||
all*. So the cost of unreachable PVs is paid once per sweep, not once per PV.
|
||||
|
||||
Iteration 1 got this wrong — it created and read each channel serially, so
|
||||
576 unreachable PVs cost `576 x timeout` (tens of minutes), and because the
|
||||
sweep ran inline in the write loop, the page stopped updating entirely.
|
||||
|
||||
Now:
|
||||
|
||||
- All channels are created first, then polled together in one shared 2 s
|
||||
window, then read (a `get()` on a connected channel is local and instant).
|
||||
- Channels are cached across sweeps, so steady-state sweeps skip reconnection.
|
||||
- The bulk sweep runs on **its own thread**. The write loop never blocks on
|
||||
EPICS, so the page keeps updating at the hot cadence no matter what the
|
||||
network does.
|
||||
- `start()` writes a page immediately, before the first sweep, so the browser
|
||||
always has valid JSON to load.
|
||||
- Monitor setup never calls a blocking `.get()`.
|
||||
|
||||
Measured with all 576 bulk PVs unreachable: sweep **2.0 s**, `start()`
|
||||
**0.0 s**, page updating continuously. Each sweep logs
|
||||
`bulk sweep: N/576 connected in T s`.
|
||||
|
||||
## Shutdown / segfault
|
||||
|
||||
Iteration 1 segfaulted on Ctrl-C after logging "generator stopped". Cause:
|
||||
`CaReader.stop()` called `PV.disconnect()` on every monitored channel.
|
||||
pyepics registers its own `atexit` handler that runs `ca.finalize_libca()`,
|
||||
so the channels were being torn down twice, while libca's background thread
|
||||
could still be delivering monitor events into freed structures. That crash is
|
||||
in native code, where a Python `try/except` cannot help.
|
||||
|
||||
Now `stop()` clears callbacks, sets a `_closing` flag that makes any
|
||||
in-flight callback return immediately, drops the references, and lets
|
||||
pyepics finalize libca itself. A second Ctrl-C during shutdown is also
|
||||
swallowed so it cannot interrupt teardown halfway.
|
||||
|
||||
Note: this could not be reproduced in the development container, because
|
||||
pyepics there ships `libca.so` without the `caRepeater` binary, so no channel
|
||||
ever connects and `disconnect()` is a no-op. The fix follows pyepics'
|
||||
documented teardown contract, but **the real confirmation is Ctrl-C on a BEC
|
||||
session with channels actually connected**. Please report back.
|
||||
|
||||
## Demo mode
|
||||
|
||||
`--demo` synthesises plausible values so the frontend can be developed
|
||||
off-network. It is **never entered implicitly**: an unreachable PV in real
|
||||
mode renders as a grey "no conn" and is counted in the footer's
|
||||
"N of M PVs unreachable" line. It never silently becomes a fake number. When
|
||||
demo mode is on the page shows a purple DEMO banner.
|
||||
|
||||
Off the X12SA network without `--demo`, the page renders fully with every
|
||||
tile grey — a decent smoke test of the frontend on its own.
|
||||
|
||||
## History
|
||||
|
||||
`eps_alarm_history.json`, written alongside the status files, survives daemon
|
||||
restarts. Three streams:
|
||||
|
||||
- **`eps_events`** — opened when `AlarmCnt_EPS` goes 0 → non-zero, closed when
|
||||
it returns to 0. A re-alarm within 60 s with identical text extends the
|
||||
existing event rather than opening a new one, so flapping does not spam
|
||||
the list.
|
||||
- **`machine_events`** — verbatim transitions of `HISTORY_PVS`
|
||||
(`LIGHT-DELIVERY`, `AGEOP-BL:STATUS-X12SA`, `EPS-PH:SYSTEM`, OP/EH1 shutter,
|
||||
FOFB). No inference, just "this PV said X, now says Y, at this time".
|
||||
- **`ring_current_trace`** — one sample per bulk interval, 288 points ≈ 24 h,
|
||||
rendered as a sparkline. Beam dumps are *visible* next to the
|
||||
`LIGHT-DELIVERY` transition rather than inferred from a threshold, which
|
||||
would be wrong during top-up commissioning.
|
||||
|
||||
Both event streams are interleaved on one timeline in the page, colour-coded
|
||||
by source.
|
||||
|
||||
## Layout
|
||||
|
||||
Expanded/collapsed state of the per-zone detail tables persists across the
|
||||
10 s auto-refresh and across reloads (`sessionStorage`, keyed by zone), and
|
||||
scroll position is preserved across re-renders. On first visit, zones with
|
||||
40 or fewer channels start expanded.
|
||||
|
||||
Desktop-first. Zone cards are 4 across above 1500 px, 3 above 1100 px, 2 above
|
||||
700 px, 1 below. Detail tables are `<details open>` when a zone has ≤ 40
|
||||
channels, collapsed otherwise. Severity comes from EPICS `.SEVR`
|
||||
(`NO_ALARM`/`MINOR`/`MAJOR`/`INVALID` → green/amber/red/grey), matching what
|
||||
`colorMode: Alarm` does in caQtDM — no thresholds are reimplemented anywhere.
|
||||
Units come from `.EGU`, read once and cached.
|
||||
|
||||
Colours match `status.html` (Catppuccin, light/dark via
|
||||
`prefers-color-scheme`).
|
||||
|
||||
## Integrating into the BEC generator later
|
||||
|
||||
1. Move `web_common.py` next to `flomni_webpage_generator.py` and have the
|
||||
base generator import `HttpUploader` / `LocalHttpServer` from it instead of
|
||||
defining them. Behaviour is identical, plus `allow_redirects=False` (the
|
||||
SSRF fix, which is not in the current base-generator copy).
|
||||
2. Construct `EpsStatusGenerator` inside `WebpageGeneratorBase.start()` and
|
||||
call its `stop()` from `stop()`. It owns its own threads, so no changes to
|
||||
`_cycle()`.
|
||||
3. Either keep it on its own port, or set `serve=False` and let the existing
|
||||
`LocalHttpServer` serve both pages from one directory — the filenames do
|
||||
not collide.
|
||||
|
||||
Both pages already cross-link in their headers.
|
||||
|
||||
## Known gaps in iteration 1
|
||||
|
||||
- Zone assignment is derived from the PV name prefix, so `GE` (general
|
||||
services: cooling, compressed air) is its own column rather than being
|
||||
distributed to the hutches it serves.
|
||||
- The `:DISABLE` bypass flags in `X_EPS_X12SA-Components.ui` are not yet
|
||||
surfaced. They answer "why is the shutter open when vacuum is bad" and are
|
||||
worth adding in iteration 2.
|
||||
- No SVG synoptic. Deliberate — cards first, and every widget in
|
||||
`X_EPS_X12SA-Graphic_new.ui` carries exact geometry, so a transpiled SVG can
|
||||
reuse this same JSON later without rework.
|
||||
- `EPS_PLCStatus.ui` watchdogs are in the manifest but not given a dedicated
|
||||
panel.
|
||||
@@ -0,0 +1,371 @@
|
||||
"""
|
||||
EPS / machine-status PV manifest for cSAXS (X12SA) - iteration 2.
|
||||
|
||||
Scoped to the curated channels of the EPS expert panel
|
||||
X_EPS_X12SA-Graphic_new.ui (the panel operators actually watch),
|
||||
NOT the full 600-PV macro expansion. Each entry carries the widget
|
||||
geometry from the panel so the SVG synoptic can place it faithfully.
|
||||
|
||||
PANEL_ITEMS entries: (pv, zone, role, desc, xf, yf)
|
||||
xf, yf are normalized 0..1 positions from the 3064x774 panel.
|
||||
"""
|
||||
|
||||
BML = "X12SA"
|
||||
UNDULATOR_GAP_PV = "X12SA-UIND:GAP-RBV" # unverified nested-macro default
|
||||
|
||||
HOT_PVS = [
|
||||
("X12SA-EPS-PLC:AlarmCnt_EPS", "eps_alarm_count", "EPS active alarm count"),
|
||||
("X12SA-EPS-PLC:AlarmList_EPS", "eps_alarm_list", "EPS alarm text"),
|
||||
("X12SA-EPS-PLC:AlarmWatchdog_EPS", "eps_watchdog", "EPS PLC watchdog"),
|
||||
("ARS00-MIS-PLC-01:AlarmCnt_Frontends", "fe_alarm_count", "Front-end alarm count"),
|
||||
("ARS00-MIS-PLC-01:AlarmList_Frontends", "fe_alarm_list", "Front-end alarm text"),
|
||||
("X12SA-EPS-PH:SYSTEM", "eps_permit", "EPS photon-shutter permit"),
|
||||
("X12SA-OP-PSYS:SH-A-OPEN", "sh_op_a_open", "OP shutter A open"),
|
||||
("X12SA-OP-PSYS:SH-A-CLOSE", "sh_op_a_closed", "OP shutter A closed"),
|
||||
("X12SA-OP-PSYS:SH-A-OK", "sh_op_a_ok", "OP shutter A OK"),
|
||||
("X12SA-EH1-PSYS:SH-A-OPEN", "sh_eh1_a_open", "EH1 shutter A open"),
|
||||
("X12SA-EH1-PSYS:SH-A-CLOSE", "sh_eh1_a_closed", "EH1 shutter A closed"),
|
||||
("X12SA-EH1-PSYS:SH-A-OK", "sh_eh1_a_ok", "EH1 shutter A OK"),
|
||||
("X12SA-OP-PSYS:ALARM-COLLECTIVE", "psys_op_alarm", "OP PSYS collective alarm"),
|
||||
("X12SA-EH1-PSYS:ALARM-COLLECTIVE", "psys_eh1_alarm", "EH1 PSYS collective alarm"),
|
||||
("X12SA-FE-PH1:OPEN_EPS", "fe_ph1_open", "FE photon shutter open"),
|
||||
("X12SA-FE-PH1:CLOSE_EPS", "fe_ph1_closed", "FE photon shutter closed"),
|
||||
("AGEBD-PARAMS:CURRENT", "ring_current", "SLS ring current"),
|
||||
("AGEBD-PARAMS:LIFETIME", "lifetime", "Beam lifetime"),
|
||||
("AGEOP-STATUS:LIGHT-DELIVERY", "light_delivery", "Light delivery state"),
|
||||
("AGEOP-BL:STATUS-X12SA", "bl_status", "Beamline status"),
|
||||
("ARSGE-CECL-FOFB1:FOFB-OPERATE", "fofb_on", "Fast orbit feedback"),
|
||||
(UNDULATOR_GAP_PV, "undulator_gap", "Undulator gap"),
|
||||
]
|
||||
|
||||
STRING_PVS = {
|
||||
"X12SA-EPS-PLC:AlarmList_EPS",
|
||||
"ARS00-MIS-PLC-01:AlarmList_Frontends",
|
||||
"AGEOP-STATUS:LIGHT-DELIVERY",
|
||||
"AGEOP-STATUS:DUO-SHIFT-TYPE",
|
||||
"AGEOP-BL:STATUS-X12SA",
|
||||
"X12SA-EPS-PH:SYSTEM",
|
||||
"X12SA-OP-CC-ECCN-0010:ALARM.DESC",
|
||||
"X12SA-OP-CC-ECCN-0010:RUN.DESC",
|
||||
"X12SA-OP-CC:PLC_STS",
|
||||
}
|
||||
|
||||
HISTORY_PVS = [
|
||||
("AGEOP-STATUS:LIGHT-DELIVERY", "Light delivery"),
|
||||
("AGEOP-BL:STATUS-X12SA", "Beamline status"),
|
||||
("X12SA-EPS-PH:SYSTEM", "EPS permit"),
|
||||
("X12SA-OP-PSYS:SH-A-OPEN", "OP shutter A"),
|
||||
("X12SA-EH1-PSYS:SH-A-OPEN", "EH1 shutter A"),
|
||||
("ARSGE-CECL-FOFB1:FOFB-OPERATE", "Orbit feedback"),
|
||||
]
|
||||
|
||||
MACHINE_PVS = [
|
||||
("AGEBD-PARAMS:INJECTION-RATE", "Injection rate"),
|
||||
("AGEBD-PARAMS:TRANSMISSION", "Transmission"),
|
||||
("ARSGE-CECL-FOFB1:B-ERR-X-RMS-UM", "Orbit RMS X"),
|
||||
("ARSGE-CECL-FOFB1:B-ERR-Y-RMS-UM", "Orbit RMS Y"),
|
||||
("ARIVA-VMAVE:PRESS-MAX", "Ring vacuum"),
|
||||
("AGEOP-STATUS:DUO-SHIFT-TYPE", "Shift type"),
|
||||
]
|
||||
|
||||
OPMSG_PVS = [(f"AGEOP-CPCL-OPMSG:CR-MSG{i}",
|
||||
f"AGEOP-CPCL-OPMSG:CR-MSG-DATE{i}") for i in range(1, 6)]
|
||||
|
||||
# XDS cryocooler (mono), mirroring the EPS panel + toplevel liveness.
|
||||
# Full temp/pressure detail needs XDS_CRYOCOOLER_OVERVIEW.ui.
|
||||
CRYO_PVS = [
|
||||
("X12SA-OP-CC:PLC_STS", "status", "PLC status"),
|
||||
("X12SA-OP-CC:Heartbeat_MON", "heartbeat", "Heartbeat"),
|
||||
("X12SA-OP-CC-ECCN-0010:RUN", "run", "Running"),
|
||||
("X12SA-OP-CC-ECCN-0010:RUN.DESC", "run_desc", "Run state text"),
|
||||
("X12SA-OP-CC-ECCN-0010:ALARM", "alarm", "Alarm"),
|
||||
("X12SA-OP-CC-ECCN-0010:ALARM.DESC", "alarm_desc", "Alarm text"),
|
||||
("X12SA-OP-CC-ECCN-0010:INHIBIT", "inhibit", "Inhibited"),
|
||||
("X12SA-OP-CCM:DISABLE", "disable", "Interlock disabled"),
|
||||
]
|
||||
|
||||
# Curated EPS-panel channels, with normalized geometry for the synoptic.
|
||||
PANEL_ITEMS = [
|
||||
("X12SA-FE-VVPG-0000:PLC_STATUS", "FE", "valve", "", 0.0155, 0.5181),
|
||||
("X12SA-FE-ABFE-VPIG-1010:PRESS-DISP", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403),
|
||||
("X12SA-FE-ABFE-VPIG-1010:VOLTAGE", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403),
|
||||
("X12SA-FE-ABFE-VPIG-1010:HV", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403),
|
||||
("X12SA-FE-ABFE-VPIG-1010:ERROR", "FE", "ion_pump", "RingAbsorber2", 0.0307, 0.2403),
|
||||
("X12SA-FE-VMCC-0000:PRESSURE", "FE", "gauge", "IKR", 0.0307, 0.4134),
|
||||
("X12SA-FE-VMCC-0000:STATUS", "FE", "gauge", "IKR", 0.0307, 0.4134),
|
||||
("X12SA-FE-VMTC-0000:PRESSURE", "FE", "gauge", "TPR", 0.0307, 0.3656),
|
||||
("X12SA-FE-VMTC-0000:STATUS", "FE", "gauge", "TPR", 0.0307, 0.3656),
|
||||
("X12SA-FE-AP-EPSG-0010:OK", "FE", "permit", "", 0.0307, 0.1938),
|
||||
("X12SA-FE-VCVS-0000:ENA", "FE", "signal", "", 0.0317, 0.5297),
|
||||
("X12SA-FE-VMMG-0010:PLC_RELAY-B", "FE", "relay", "", 0.0317, 0.5297),
|
||||
("X12SA-FE-XBPM1-ETTC-0010:TEMP", "FE", "temperature", "", 0.0483, 0.5969),
|
||||
("X12SA-FE-XBPM1-ETTC-0020:TEMP", "FE", "temperature", "", 0.0483, 0.6305),
|
||||
("X12SA-FE-CS-ECVW-0010:PLC_STATUS", "FE", "valve", "", 0.0635, 0.7506),
|
||||
("X12SA-FE-CS-ECVW-0020:PLC_STATUS", "FE", "valve", "", 0.0635, 0.8463),
|
||||
("X12SA-FE-PUM1-VPIG-1020:PRESS-DISP", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403),
|
||||
("X12SA-FE-PUM1-VPIG-1020:VOLTAGE", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403),
|
||||
("X12SA-FE-PUM1-VPIG-1020:HV", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403),
|
||||
("X12SA-FE-PUM1-VPIG-1020:ERROR", "FE", "ion_pump", "Pumpstand1", 0.0642, 0.2403),
|
||||
("X12SA-FE-DIA1-ETTC-0010:TEMP", "FE", "temperature", "", 0.074, 0.5969),
|
||||
("X12SA-FE-DIA1-ETTC-0020:TEMP", "FE", "temperature", "", 0.074, 0.6305),
|
||||
("X12SA-FE-XBPM1-EFSW-0010:FLOW", "FE", "flow", "", 0.0885, 0.8036),
|
||||
("X12SA-FE-PSH1-VPIG-1030:PRESS-DISP", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403),
|
||||
("X12SA-FE-PSH1-VPIG-1030:VOLTAGE", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403),
|
||||
("X12SA-FE-PSH1-VPIG-1030:HV", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403),
|
||||
("X12SA-FE-PSH1-VPIG-1030:ERROR", "FE", "ion_pump", "PhotonShutterFE", 0.0986, 0.2403),
|
||||
("X12SA-FE-PSH1-VMTC-1010:PRESSURE", "FE", "gauge", "TPR", 0.0986, 0.3656),
|
||||
("X12SA-FE-PSH1-VMTC-1010:STATUS", "FE", "gauge", "TPR", 0.0986, 0.3656),
|
||||
("X12SA-FE-PSH1-VMCC-1010:PRESSURE", "FE", "gauge", "IKR", 0.0986, 0.4134),
|
||||
("X12SA-FE-PSH1-VMCC-1010:STATUS", "FE", "gauge", "IKR", 0.0986, 0.4134),
|
||||
("X12SA-FE-PSH1-EMLS:STATE", "FE", "shutter", "", 0.099, 0.5181),
|
||||
("X12SA-FE-PSH1-ETTC-0020:TEMP", "FE", "temperature", "", 0.1006, 0.6305),
|
||||
("X12SA-FE-PSH1-ETTC-0010:TEMP", "FE", "temperature", "", 0.1006, 0.5969),
|
||||
("X12SA-FE-DIA1-EFSW-0010:FLOW", "FE", "flow", "", 0.1067, 0.8036),
|
||||
("X12SA-FE-CS-ECVW-0020:PLC_OPEN", "FE", "signal", "", 0.1108, 0.8269),
|
||||
("X12SA-FE-CS-ECVW-0010:PLC_OPEN", "FE", "signal", "", 0.1108, 0.7674),
|
||||
("X12SA-FE-PSH1-EFSW-0010:FLOW", "FE", "shutter", "", 0.1219, 0.8036),
|
||||
("X12SA-FE-VVPG-1010:PLC_STATUS", "FE", "valve", "", 0.1239, 0.5181),
|
||||
("X12SA-FE-CS-ECVW-0040:PLC_STATUS", "FE", "valve", "", 0.1348, 0.8463),
|
||||
("X12SA-FE-CS-ECVW-0030:PLC_STATUS", "FE", "valve", "", 0.1348, 0.7506),
|
||||
("X12SA-FE-VVFV-2010:PLC_CLOSE", "FE", "fast_valve", "", 0.1557, 0.5207),
|
||||
("X12SA-FE-SL1-EFSW-0010:FLOW", "FE", "flow", "", 0.1611, 0.8036),
|
||||
("X12SA-FE-CS-ECVW-0030:PLC_OPEN", "FE", "signal", "", 0.1658, 0.7674),
|
||||
("X12SA-FE-STO1-VPIG-2010:PRESS-DISP", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403),
|
||||
("X12SA-FE-STO1-VPIG-2010:VOLTAGE", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403),
|
||||
("X12SA-FE-STO1-VPIG-2010:HV", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403),
|
||||
("X12SA-FE-STO1-VPIG-2010:ERROR", "FE", "ion_pump", "BeamStopper", 0.1678, 0.2403),
|
||||
("X12SA-FE-STO1-VMTC-2010:PRESSURE", "FE", "gauge", "TPR", 0.1678, 0.3656),
|
||||
("X12SA-FE-STO1-VMTC-2010:STATUS", "FE", "gauge", "TPR", 0.1678, 0.3656),
|
||||
("X12SA-FE-STO1-VMCC-2010:PRESSURE", "FE", "gauge", "IKR", 0.1678, 0.4134),
|
||||
("X12SA-FE-STO1-VMCC-2010:STATUS", "FE", "gauge", "IKR", 0.1678, 0.4134),
|
||||
("X12SA-FE-STO1-EMLS:STATE", "FE", "shutter", "", 0.1695, 0.5181),
|
||||
("X12SA-FE-SL2-EFSW-0010:FLOW", "FE", "flow", "", 0.1766, 0.8036),
|
||||
("X12SA-FE-CS-ECVW-0040:PLC_OPEN", "FE", "signal", "", 0.1817, 0.8269),
|
||||
("X12SA-FE-SL1-ETTC-0010:TEMP", "FE", "temperature", "", 0.1982, 0.5969),
|
||||
("X12SA-FE-SL1-ETTC-0020:TEMP", "FE", "temperature", "", 0.1982, 0.6305),
|
||||
("X12SA-FE-SL2-ETTC-0020:TEMP", "FE", "temperature", "", 0.1982, 0.6977),
|
||||
("X12SA-FE-SL2-ETTC-0010:TEMP", "FE", "temperature", "", 0.1982, 0.6641),
|
||||
("X12SA-FE-PUM2-VPIG-2020:PRESS-DISP", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403),
|
||||
("X12SA-FE-PUM2-VPIG-2020:VOLTAGE", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403),
|
||||
("X12SA-FE-PUM2-VPIG-2020:HV", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403),
|
||||
("X12SA-FE-PUM2-VPIG-2020:ERROR", "FE", "ion_pump", "Pumpstand2", 0.208, 0.2403),
|
||||
("X12SA-FE-VMMG-0010:PLC_RELAY-F", "FE", "relay", "", 0.2506, 0.5297),
|
||||
("X12SA-FE-PUM3-VMCC-2020:PRESSURE", "FE", "gauge", "IKR", 0.2624, 0.4134),
|
||||
("X12SA-FE-PUM3-VMCC-2020:STATUS", "FE", "gauge", "IKR", 0.2624, 0.4134),
|
||||
("X12SA-FE-PUM3-VPIG-2030:PRESS-DISP", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403),
|
||||
("X12SA-FE-PUM3-VPIG-2030:VOLTAGE", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403),
|
||||
("X12SA-FE-PUM3-VPIG-2030:HV", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403),
|
||||
("X12SA-FE-PUM3-VPIG-2030:ERROR", "FE", "ion_pump", "Pumpstand3", 0.2624, 0.2403),
|
||||
("X12SA-FE-PUM3-VMTC-2020:PRESSURE", "FE", "gauge", "TPR", 0.2624, 0.3656),
|
||||
("X12SA-FE-PUM3-VMTC-2020:STATUS", "FE", "gauge", "TPR", 0.2624, 0.3656),
|
||||
("X12SA-FE-VVPG-2010:PLC_STATUS", "FE", "valve", "", 0.2682, 0.5181),
|
||||
("X12SA-FE-VMMG-0010:PLC_RELAY-D", "FE", "chamber", "Pumpst.4", 0.5691, 0.5039),
|
||||
("X12SA-FE-VVFV-2010:PLC_INRUSH", "FE", "signal", "", 0.823, 0.4729),
|
||||
("ARS12-VCVS-1000:ENA", "OP", "signal", "", 0.0, 0.5297),
|
||||
("X12SA-OP-PSYS:SH-A-CLOSE", "OP", "shutter", "", 0.1027, 0.6835),
|
||||
("X12SA-OP-PSYS:SH-A-OPEN", "OP", "shutter", "", 0.1027, 0.6835),
|
||||
("X12SA-OP-PSYS:SH-A-OK", "OP", "shutter", "", 0.1077, 0.4716),
|
||||
("X12SA-OP-PSYS:SH-B-OPEN", "OP", "shutter", "", 0.1733, 0.6202),
|
||||
("X12SA-OP-PSYS:SH-B-CLOSE", "OP", "shutter", "", 0.1733, 0.6202),
|
||||
("X12SA-OP-PSYS:SH-B-OK", "OP", "shutter", "", 0.177, 0.4716),
|
||||
("X12SA-OP-VMMG-0010:PLC_RELAY-B", "OP", "relay", "", 0.2844, 0.5297),
|
||||
("X12SA-OP-PUM1-VPIG-1010:PRESS-DISP", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403),
|
||||
("X12SA-OP-PUM1-VPIG-1010:VOLTAGE", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403),
|
||||
("X12SA-OP-PUM1-VPIG-1010:HV", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403),
|
||||
("X12SA-OP-PUM1-VPIG-1010:ERROR", "OP", "ion_pump", "Pumpstand1", 0.2975, 0.2403),
|
||||
("X12SA-OP-PUM1-VMCC-1010:PRESSURE", "OP", "gauge", "IKR", 0.2975, 0.4134),
|
||||
("X12SA-OP-PUM1-VMCC-1010:STATUS", "OP", "gauge", "IKR", 0.2975, 0.4134),
|
||||
("X12SA-OP-PUM1-VMCP-1010:PRESSURE", "OP", "gauge", "PCR", 0.2975, 0.3656),
|
||||
("X12SA-OP-PUM1-VMCP-1010:STATUS", "OP", "gauge", "PCR", 0.2975, 0.3656),
|
||||
("X12SA-OP-VMMG-0010:PLC_RELAY-C", "OP", "relay", "", 0.3357, 0.5297),
|
||||
("X12SA-OP-VVPG-1010:PLC_STATUS", "OP", "valve", "", 0.3377, 0.5181),
|
||||
("X12SA-OP-PUM2-VPIG-2010:PRESS-DISP", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403),
|
||||
("X12SA-OP-PUM2-VPIG-2010:VOLTAGE", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403),
|
||||
("X12SA-OP-PUM2-VPIG-2010:HV", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403),
|
||||
("X12SA-OP-PUM2-VPIG-2010:ERROR", "OP", "ion_pump", "Pumpstand2", 0.3391, 0.2403),
|
||||
("X12SA-OP-PUM2-VMFR-2010:PRESSURE", "OP", "gauge", "Pumpstand2", 0.3391, 0.4134),
|
||||
("X12SA-OP-PUM2-VMFR-2010:STATUS", "OP", "gauge", "Pumpstand2", 0.3391, 0.4134),
|
||||
("X12SA-OP-SL1-ETTC-2020:TEMP", "OP", "temperature", "", 0.3563, 0.6305),
|
||||
("X12SA-OP-SL2-ETTC-2010:TEMP", "OP", "temperature", "", 0.3563, 0.6641),
|
||||
("X12SA-OP-SL1-ETTC-2010:TEMP", "OP", "temperature", "", 0.3563, 0.5969),
|
||||
("X12SA-OP-SL2-ETTC-2020:TEMP", "OP", "temperature", "", 0.3563, 0.6977),
|
||||
("X12SA-OP-VMMG-0010:PLC_RELAY-D", "OP", "chamber", "Pumpst.3", 0.3739, 0.5039),
|
||||
("X12SA-OP-PUM3-VMFR-2010:PRESSURE", "OP", "gauge", "Pumpstand3", 0.3779, 0.4134),
|
||||
("X12SA-OP-PUM3-VMFR-2010:STATUS", "OP", "gauge", "Pumpstand3", 0.3779, 0.4134),
|
||||
("X12SA-OP-PUM3-VPIG-2010:PRESS-DISP", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403),
|
||||
("X12SA-OP-PUM3-VPIG-2010:VOLTAGE", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403),
|
||||
("X12SA-OP-PUM3-VPIG-2010:HV", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403),
|
||||
("X12SA-OP-PUM3-VPIG-2010:ERROR", "OP", "ion_pump", "Pumpstand3", 0.3779, 0.2403),
|
||||
("X12SA-OP-PUM3-ETTC-2010:TEMP", "OP", "temperature", "", 0.3806, 0.5969),
|
||||
("X12SA-OP-AP-EPTG-0010:PRESSURE", "OP", "gauge", "", 0.381, 0.177),
|
||||
("X12SA-OP-VVPG-2010:PLC_STATUS", "OP", "valve", "", 0.4016, 0.5168),
|
||||
("X12SA-OP-VMMG-0010:PLC_RELAY-E", "OP", "chamber", "DMM", 0.4232, 0.5039),
|
||||
("X12SA-OP-DMM-VMFR-3010:PRESSURE", "OP", "gauge", "DMM", 0.433, 0.4134),
|
||||
("X12SA-OP-DMM-VMFR-3010:STATUS", "OP", "gauge", "DMM", 0.433, 0.4134),
|
||||
("X12SA-OP-DMM-VPIG-3010:PRESS-DISP", "OP", "ion_pump", "DMM", 0.433, 0.2403),
|
||||
("X12SA-OP-DMM-VPIG-3010:VOLTAGE", "OP", "ion_pump", "DMM", 0.433, 0.2403),
|
||||
("X12SA-OP-DMM-VPIG-3010:HV", "OP", "ion_pump", "DMM", 0.433, 0.2403),
|
||||
("X12SA-OP-DMM-VPIG-3010:ERROR", "OP", "ion_pump", "DMM", 0.433, 0.2403),
|
||||
("X12SA-OP-DMM-ETTC-3030:TEMP", "OP", "temperature", "", 0.4336, 0.6641),
|
||||
("X12SA-OP-DMM-ETTC-3040:TEMP", "OP", "temperature", "", 0.4336, 0.6977),
|
||||
("X12SA-OP-DMM-ETTC-3020:TEMP", "OP", "temperature", "", 0.4336, 0.6305),
|
||||
("X12SA-OP-DMM-ETTC-3010:TEMP", "OP", "temperature", "", 0.4336, 0.5969),
|
||||
("X12SA-OP-VVPG-3010:PLC_STATUS", "OP", "valve", "", 0.463, 0.5168),
|
||||
("X12SA-OP-CC-ECCN-0010:ALARM.DESC", "OP", "signal", "", 0.4661, 0.8152),
|
||||
("X12SA-OP-CC-ECCN-0010:RUN.DESC", "OP", "signal", "", 0.4661, 0.854),
|
||||
("X12SA-OP-VVPG-3020:PLC_STATUS", "OP", "valve", "", 0.4877, 0.5168),
|
||||
("X12SA-OP-CC-ECCN-0010:ALARM", "OP", "signal", "", 0.5035, 0.814),
|
||||
("X12SA-OP-CC-ECCN-0010:RUN", "OP", "signal", "", 0.5035, 0.8527),
|
||||
("X12SA-OP-VMMG-0010:PLC_RELAY-F", "OP", "chamber", "CCM", 0.5073, 0.5039),
|
||||
("X12SA-OP-CCM-VMFR-4010:PRESSURE", "OP", "gauge", "CCM", 0.5164, 0.4134),
|
||||
("X12SA-OP-CCM-VMFR-4010:STATUS", "OP", "gauge", "CCM", 0.5164, 0.4134),
|
||||
("X12SA-OP-CCM-VPIG-4010:PRESS-DISP", "OP", "ion_pump", "CCM", 0.5164, 0.2403),
|
||||
("X12SA-OP-CCM-VPIG-4010:VOLTAGE", "OP", "ion_pump", "CCM", 0.5164, 0.2403),
|
||||
("X12SA-OP-CCM-VPIG-4010:HV", "OP", "ion_pump", "CCM", 0.5164, 0.2403),
|
||||
("X12SA-OP-CCM-VPIG-4010:ERROR", "OP", "ion_pump", "CCM", 0.5164, 0.2403),
|
||||
("X12SA-OP-CCM-ETTC-4020:TEMP", "OP", "temperature", "", 0.5184, 0.6305),
|
||||
("X12SA-OP-CCM-ETTC-4010:TEMP", "OP", "temperature", "", 0.5184, 0.5969),
|
||||
("X12SA-OP-VVPG-4010:PLC_STATUS", "OP", "valve", "", 0.5468, 0.5168),
|
||||
("X12SA-OP-EB1-VMCP-5010:PRESSURE", "OP", "gauge", "PrePump", 0.5518, 0.8941),
|
||||
("X12SA-OP-EB1-VMCP-5010:STATUS", "OP", "gauge", "PrePump", 0.5518, 0.8941),
|
||||
("X12SA-OP-EB1-VPTM-5010:PLC_TURBO-RBV", "OP", "turbo", "", 0.5589, 0.6899),
|
||||
("X12SA-OP-EB1-VPRO-5010:PLC_RUN", "OP", "signal", "", 0.5623, 0.8295),
|
||||
("X12SA-OP-PUM4-VMFR-5010:PRESSURE", "OP", "gauge", "Pumpstand4", 0.5721, 0.4134),
|
||||
("X12SA-OP-PUM4-VMFR-5010:STATUS", "OP", "gauge", "Pumpstand4", 0.5721, 0.4134),
|
||||
("X12SA-OP-PUM4-VPIG-5010:PRESS-DISP", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239),
|
||||
("X12SA-OP-PUM4-VPIG-5010:VOLTAGE", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239),
|
||||
("X12SA-OP-PUM4-VPIG-5010:HV", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239),
|
||||
("X12SA-OP-PUM4-VPIG-5010:ERROR", "OP", "ion_pump", "Pumpstand4", 0.5721, 0.239),
|
||||
("X12SA-OP-EB1-VPRO-5010:PLC_ALARM", "OP", "signal", "", 0.5731, 0.938),
|
||||
("X12SA-OP-PUM4-ETTC-5010:TEMP", "OP", "temperature", "", 0.5741, 0.5969),
|
||||
("X12SA-OP-VMMG-0020:PLC_RELAY-F", "OP", "relay", "", 0.5748, 0.8527),
|
||||
("X12SA-OP-EB1-VVPP-5010:PLC_STATUS", "OP", "valve", "", 0.5812, 0.8385),
|
||||
("X12SA-OP-EB1-VVPG-5010:PLC_STATUS", "OP", "valve", "", 0.5947, 0.6124),
|
||||
("X12SA-OP-VMMG-0020:PLC_RELAY-A", "OP", "chamber", "Exp-Box1", 0.5968, 0.5039),
|
||||
("X12SA-OP-EB1-VPIG-5010:PRESS-DISP", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239),
|
||||
("X12SA-OP-EB1-VPIG-5010:VOLTAGE", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239),
|
||||
("X12SA-OP-EB1-VPIG-5010:HV", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239),
|
||||
("X12SA-OP-EB1-VPIG-5010:ERROR", "OP", "ion_pump", "Exp-Box1", 0.6045, 0.239),
|
||||
("X12SA-OP-EB1-VMFR-5020:PRESSURE", "OP", "gauge", "Exp-Box1", 0.6079, 0.4134),
|
||||
("X12SA-OP-EB1-VMFR-5020:STATUS", "OP", "gauge", "Exp-Box1", 0.6079, 0.4134),
|
||||
("X12SA-OP-EB1-VPTM-5010:PLC_SPEED-SP", "OP", "signal", "", 0.6079, 0.6434),
|
||||
("X12SA-OP-VMMG-0020:PLC_RELAY-C", "OP", "relay", "", 0.6322, 0.5297),
|
||||
("X12SA-OP-EB1-VPIG-5020:PRESS-DISP", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239),
|
||||
("X12SA-OP-EB1-VPIG-5020:VOLTAGE", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239),
|
||||
("X12SA-OP-EB1-VPIG-5020:HV", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239),
|
||||
("X12SA-OP-EB1-VPIG-5020:ERROR", "OP", "ion_pump", "Exp-Box1", 0.6349, 0.239),
|
||||
("X12SA-OP-SL3-ETTC-5010:TEMP", "OP", "temperature", "", 0.6484, 0.5969),
|
||||
("X12SA-OP-SL3-ETTC-5020:TEMP", "OP", "temperature", "", 0.6484, 0.6305),
|
||||
("X12SA-OP-SL4-ETTC-5020:TEMP", "OP", "temperature", "", 0.6484, 0.6977),
|
||||
("X12SA-OP-SL4-ETTC-5010:TEMP", "OP", "temperature", "", 0.6484, 0.6641),
|
||||
("X12SA-OP-SL1-EFSW-2010:FLOW", "OP", "flow", "", 0.6494, 0.8398),
|
||||
("X12SA-OP-PUM5-VPIG-2010:PRESS-DISP", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239),
|
||||
("X12SA-OP-PUM5-VPIG-2010:VOLTAGE", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239),
|
||||
("X12SA-OP-PUM5-VPIG-2010:HV", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239),
|
||||
("X12SA-OP-PUM5-VPIG-2010:ERROR", "OP", "ion_pump", "Pumpstand5", 0.6663, 0.239),
|
||||
("X12SA-OP-PUM5-VMFR-5010:PRESSURE", "OP", "gauge", "Pumpstand5", 0.6663, 0.4134),
|
||||
("X12SA-OP-PUM5-VMFR-5010:STATUS", "OP", "gauge", "Pumpstand5", 0.6663, 0.4134),
|
||||
("X12SA-OP-SL2-EFSW-2010:FLOW", "OP", "flow", "", 0.667, 0.8398),
|
||||
("X12SA-OP-CS-ECVW-0020:PLC_OPEN", "OP", "signal", "", 0.6727, 0.863),
|
||||
("X12SA-OP-CS-ECVW-0010:PLC_OPEN", "OP", "signal", "", 0.6727, 0.8036),
|
||||
("X12SA-OP-EB1-EFSW-5010:FLOW", "OP", "flow", "", 0.6846, 0.8398),
|
||||
("X12SA-OP-VVPG-5010:PLC_STATUS", "OP", "valve", "", 0.6852, 0.5168),
|
||||
("X12SA-OP-KB-VPIG-6010:PRESS-DISP", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239),
|
||||
("X12SA-OP-KB-VPIG-6010:VOLTAGE", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239),
|
||||
("X12SA-OP-KB-VPIG-6010:HV", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239),
|
||||
("X12SA-OP-KB-VPIG-6010:ERROR", "OP", "ion_pump", "KB-mirror", 0.7025, 0.239),
|
||||
("X12SA-OP-VMMG-0020:PLC_RELAY-D", "OP", "chamber", "KB", 0.7058, 0.5039),
|
||||
("X12SA-OP-KB-VMFR-6010:PRESSURE", "OP", "gauge", "KB-mirror", 0.7146, 0.4134),
|
||||
("X12SA-OP-KB-VMFR-6010:STATUS", "OP", "gauge", "KB-mirror", 0.7146, 0.4134),
|
||||
("X12SA-OP-SL3-EFSW-5010:FLOW", "OP", "flow", "", 0.7197, 0.8398),
|
||||
("X12SA-OP-KB-VPIG-6020:PRESS-DISP", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239),
|
||||
("X12SA-OP-KB-VPIG-6020:VOLTAGE", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239),
|
||||
("X12SA-OP-KB-VPIG-6020:HV", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239),
|
||||
("X12SA-OP-KB-VPIG-6020:ERROR", "OP", "ion_pump", "KB-mirror", 0.7329, 0.239),
|
||||
("X12SA-OP-KB-EFSW-6010:FLOW", "OP", "flow", "", 0.7548, 0.8398),
|
||||
("X12SA-OP-VVFV-6010:PLC_CLOSE", "OP", "fast_valve", "", 0.7622, 0.5207),
|
||||
("X12SA-OP-VVPG-6010:PLC_STATUS", "OP", "valve", "", 0.7707, 0.5168),
|
||||
("X12SA-OP-VMMG-0020:PLC_RELAY-E", "OP", "relay", "", 0.7876, 0.5297),
|
||||
("X12SA-OP-PSH1-EFSW-7010:FLOW", "OP", "shutter", "", 0.7899, 0.8398),
|
||||
("X12SA-OP-PSH1-VPIG-7010:PRESS-DISP", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239),
|
||||
("X12SA-OP-PSH1-VPIG-7010:VOLTAGE", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239),
|
||||
("X12SA-OP-PSH1-VPIG-7010:HV", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239),
|
||||
("X12SA-OP-PSH1-VPIG-7010:ERROR", "OP", "ion_pump", "PhotonShutterOP", 0.797, 0.239),
|
||||
("X12SA-OP-PSH1-VMFR-6010:PRESSURE", "OP", "gauge", "Ph.ShutterOP", 0.797, 0.4134),
|
||||
("X12SA-OP-PSH1-VMFR-6010:STATUS", "OP", "gauge", "Ph.ShutterOP", 0.797, 0.4134),
|
||||
("X12SA-OP-PSH1-EMLS:STATE", "OP", "shutter", "", 0.7974, 0.5181),
|
||||
("X12SA-OP-PSH1-ETTC-7010:TEMP", "OP", "temperature", "", 0.7994, 0.5969),
|
||||
("X12SA-OP-PSH1-ETTC-7020:TEMP", "OP", "temperature", "", 0.7994, 0.6305),
|
||||
("X12SA-OP-VVPG-7010:PLC_STATUS", "OP", "valve", "", 0.824, 0.5168),
|
||||
("X12SA-OP-CS-ECVW-0010:PLC_STATUS", "OP", "valve", "", 0.8457, 0.7868),
|
||||
("X12SA-OP-CS-ECVW-0020:PLC_STATUS", "OP", "valve", "", 0.8457, 0.8824),
|
||||
("X12SA-OP-VVFV-6010:PLC_INRUSH", "OP", "signal", "", 0.8869, 0.4729),
|
||||
("X12SA-ES-EB2-EFSW-1010:FLOW", "ES", "flow", "", 0.8173, 0.8398),
|
||||
("X12SA-ES-VMMG-0010:PLC_RELAY-A", "ES", "chamber", "Exp-Box2", 0.868, 0.5039),
|
||||
("X12SA-ES-EB2-VPIG-1010:PRESS-DISP", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239),
|
||||
("X12SA-ES-EB2-VPIG-1010:VOLTAGE", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239),
|
||||
("X12SA-ES-EB2-VPIG-1010:HV", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239),
|
||||
("X12SA-ES-EB2-VPIG-1010:ERROR", "ES", "ion_pump", "Exp-Box2", 0.8767, 0.239),
|
||||
("X12SA-ES-EB2-VMFR-1010:PRESSURE", "ES", "gauge", "Exp-Box2", 0.8767, 0.4134),
|
||||
("X12SA-ES-EB2-VMFR-1010:STATUS", "ES", "gauge", "Exp-Box2", 0.8767, 0.4134),
|
||||
("X12SA-ES-VVPG-1010:PLC_STATUS", "ES", "valve", "", 0.9085, 0.5168),
|
||||
("X12SA-ES-EB3-VMCP-2010:PRESSURE", "ES", "gauge", "EB3prevac", 0.9149, 0.814),
|
||||
("X12SA-ES-EB3-VMCP-2010:STATUS", "ES", "gauge", "EB3prevac", 0.9149, 0.814),
|
||||
("X12SA-ES-EB3-VPTM-2010:PLC_TURBO-RBV", "ES", "turbo", "", 0.9169, 0.6899),
|
||||
("X12SA-ES-EB3-VVPG-2010:PLC_STATUS", "ES", "valve", "", 0.9321, 0.6124),
|
||||
("X12SA-ES-VMMG-0010:PLC_RELAY-D", "ES", "chamber", "Exp-Box3", 0.9341, 0.5039),
|
||||
("X12SA-ES-EB3-VMFR-2010:PRESSURE", "ES", "gauge", "Exp-Box3", 0.9436, 0.4134),
|
||||
("X12SA-ES-EB3-VMFR-2010:STATUS", "ES", "gauge", "Exp-Box3", 0.9436, 0.4134),
|
||||
("X12SA-ES-EB3-VVPP-2010:PLC_STATUS", "ES", "valve", "", 0.945, 0.8385),
|
||||
("X12SA-ES-VMMG-0010:PLC_RELAY-E", "ES", "relay", "", 0.9453, 0.6434),
|
||||
("X12SA-ES-VMMG-0010:PLC_RELAY-F", "ES", "relay", "", 0.9622, 0.8527),
|
||||
("X12SA-ES-EB3-VMCP-2020:PRESSURE", "ES", "gauge", "Prepump", 0.972, 0.8941),
|
||||
("X12SA-ES-EB3-VMCP-2020:STATUS", "ES", "gauge", "Prepump", 0.972, 0.8941),
|
||||
("X12SA-ES-DET-CHIL1:DISABLE", "ES", "signal", "", 0.973, 0.1098),
|
||||
("X12SA-ES-DET-CHIL2:DISABLE", "ES", "signal", "", 0.973, 0.1486),
|
||||
("X12SA-ES-EB3-VPRO-2010:PLC_RUN", "ES", "signal", "", 0.9784, 0.8295),
|
||||
("X12SA-ES-EB3-VPRO-2010:PLC_ALARM", "ES", "signal", "", 0.9932, 0.938),
|
||||
("X12SA-ES-FT-VMFR-1010:PRESSURE", "ES", "gauge", "FlightTube", 0.9966, 0.4134),
|
||||
("X12SA-ES-FT-VMFR-1010:STATUS", "ES", "gauge", "FlightTube", 0.9966, 0.4134),
|
||||
("X12SA-ES-DET-ETTC-1020:TEMP", "ES", "temperature", "", 1.0, 0.155),
|
||||
("X12SA-ES-DET-ETTC-1010:TEMP", "ES", "temperature", "", 1.0, 0.1163),
|
||||
("X12SA-ES-DET-ECCW-1010:ALARM", "ES", "signal", "", 1.0, 0.1964),
|
||||
("X12SA-ES-DET-ECCW-1020:ALARM", "ES", "signal", "", 1.0, 0.2326),
|
||||
("X12SA-EH1-PSYS:SH-A-CLOSE", "EH1", "shutter", "", 0.8031, 0.6977),
|
||||
("X12SA-EH1-PSYS:SH-A-OPEN", "EH1", "shutter", "", 0.8031, 0.6977),
|
||||
("X12SA-EH1-PSYS:SH-A-OK", "EH1", "shutter", "", 0.8045, 0.4716),
|
||||
]
|
||||
|
||||
ZONES = ["FE", "OP", "ES", "EH1"]
|
||||
ZONE_LABELS = {"FE": "Front End", "OP": "Optics Hutch",
|
||||
"ES": "End Station", "EH1": "Experimental Hutch"}
|
||||
ROLE_LABELS = {
|
||||
"gauge": "Gauges",
|
||||
"ion_pump": "Ion pumps",
|
||||
"valve": "Valves",
|
||||
"shutter": "Shutters",
|
||||
"fast_valve": "Fast valves",
|
||||
"temperature": "Temperatures",
|
||||
"chamber": "Chambers",
|
||||
"turbo": "Turbo pumps",
|
||||
"permit": "Permits",
|
||||
"relay": "Relays",
|
||||
"flow": "Flow switches",
|
||||
"signal": "Signals",
|
||||
}
|
||||
|
||||
def all_bulk_pvs():
|
||||
out, seen = [], set()
|
||||
for pv, *_ in PANEL_ITEMS:
|
||||
if pv not in seen: seen.add(pv); out.append(pv)
|
||||
for pv, *_ in CRYO_PVS:
|
||||
if pv not in seen: seen.add(pv); out.append(pv)
|
||||
for pv, _ in MACHINE_PVS:
|
||||
if pv not in seen: seen.add(pv); out.append(pv)
|
||||
return out
|
||||
|
||||
def all_pvs():
|
||||
out = [pv for pv, _, _ in HOT_PVS]; seen = set(out)
|
||||
for pv in all_bulk_pvs():
|
||||
if pv not in seen: seen.add(pv); out.append(pv)
|
||||
return out
|
||||
@@ -0,0 +1,308 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>cSAXS EPS & Machine Status</title>
|
||||
<style>
|
||||
:root {
|
||||
--mono: 'Space Mono', 'Courier New', Courier, monospace;
|
||||
--sans: 'DM Sans', 'Helvetica Neue', Arial, sans-serif;
|
||||
--c-ok:#a6e3a1; --c-minor:#f9e2af; --c-major:#f38ba8;
|
||||
--c-invalid:#9399b2; --c-info:#89dceb; --c-demo:#cba6f7;
|
||||
--bg:#0d0f14; --surface:#161a23; --surface2:#1c2130;
|
||||
--border:#2a3045; --text:#cdd6f4; --text-dim:#6c7a9c;
|
||||
}
|
||||
@media (prefers-color-scheme: light) {
|
||||
:root {
|
||||
--bg:#f0f3f9; --surface:#fff; --surface2:#e8ecf5;
|
||||
--border:#c8cedf; --text:#1a1d2e; --text-dim:#5a6480;
|
||||
--c-ok:#40a02b; --c-minor:#df8e1d; --c-major:#d20f39;
|
||||
--c-invalid:#8c8fa1; --c-info:#179299; --c-demo:#8839ef;
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; padding:16px 20px 60px; background:var(--bg); color:var(--text);
|
||||
font-family:var(--sans); font-size:15px; line-height:1.45; }
|
||||
h1 { font-size:1.15rem; font-weight:600; margin:0; }
|
||||
h2 { font-size:0.82rem; font-weight:700; text-transform:uppercase; letter-spacing:0.09em;
|
||||
color:var(--text-dim); margin:20px 0 10px; padding-bottom:6px; border-bottom:1px solid var(--border); }
|
||||
a { color:var(--c-info); }
|
||||
header { display:flex; align-items:baseline; gap:16px; flex-wrap:wrap; margin-bottom:14px; }
|
||||
header .meta { font-family:var(--mono); font-size:0.78rem; color:var(--text-dim); }
|
||||
|
||||
.sev-NO_ALARM{color:var(--c-ok);} .sev-MINOR{color:var(--c-minor);}
|
||||
.sev-MAJOR{color:var(--c-major);} .sev-INVALID,.sev-UNKNOWN,.disconnected{color:var(--c-invalid);}
|
||||
|
||||
.strip { display:grid; gap:10px; margin-bottom:6px;
|
||||
grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); }
|
||||
.tile { background:var(--surface); border:1px solid var(--border); border-radius:9px; padding:12px 14px; }
|
||||
.tile .k { font-size:0.66rem; text-transform:uppercase; letter-spacing:0.07em; color:var(--text-dim); font-weight:700; }
|
||||
.tile .v { font-family:var(--mono); font-size:1.45rem; font-weight:700; margin-top:3px; line-height:1.15; word-break:break-word; }
|
||||
.tile .u { font-size:0.78rem; color:var(--text-dim); font-weight:400; }
|
||||
|
||||
.panel { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:13px 15px; }
|
||||
|
||||
.hero { border-radius:10px; padding:16px 20px; border:1px solid var(--border);
|
||||
background:var(--surface); border-left:6px solid var(--hero-c,var(--c-invalid)); }
|
||||
.hero.ok{--hero-c:var(--c-ok);} .hero.unknown{--hero-c:var(--c-invalid);}
|
||||
.hero.alarm{--hero-c:var(--c-major); background:color-mix(in srgb,var(--c-major) 10%,var(--surface));}
|
||||
.hero-title{font-size:1.4rem; font-weight:700; color:var(--hero-c);}
|
||||
.hero-sub{font-family:var(--mono); font-size:0.82rem; color:var(--text-dim); margin-top:4px;}
|
||||
.hero-text{font-family:var(--mono); font-size:0.92rem; margin-top:10px; white-space:pre-wrap; word-break:break-word;}
|
||||
|
||||
.ev { display:flex; gap:10px; font-family:var(--mono); font-size:0.78rem; padding:3px 0; border-bottom:1px solid var(--border); }
|
||||
.ev .t{ color:var(--text-dim); white-space:nowrap; }
|
||||
.ev.eps .d{ color:var(--c-major); } .ev.mach .d{ color:var(--c-info); }
|
||||
|
||||
.cryo { display:grid; gap:10px; grid-template-columns:repeat(auto-fit,minmax(130px,1fr)); }
|
||||
|
||||
.syn-wrap { background:var(--surface); border:1px solid var(--border); border-radius:10px; padding:10px; overflow-x:auto; }
|
||||
svg.syn { display:block; width:100%; min-width:680px; height:auto; }
|
||||
.syn-legend { display:flex; gap:14px; flex-wrap:wrap; font-family:var(--mono); font-size:0.72rem; color:var(--text-dim); margin-top:8px; }
|
||||
.syn-legend span::before { content:"\25CF "; }
|
||||
.lg-ok::before{color:var(--c-ok);} .lg-minor::before{color:var(--c-minor);}
|
||||
.lg-major::before{color:var(--c-major);} .lg-dead::before{color:var(--c-invalid);}
|
||||
|
||||
.zones { display:grid; gap:12px; grid-template-columns:repeat(4,1fr); }
|
||||
@media (max-width:1500px){ .zones{ grid-template-columns:repeat(3,1fr);} }
|
||||
@media (max-width:1100px){ .zones{ grid-template-columns:repeat(2,1fr);} }
|
||||
@media (max-width:700px){ .zones{ grid-template-columns:1fr;} }
|
||||
.zone { background:var(--surface); border:1px solid var(--border); border-radius:10px;
|
||||
padding:13px 15px; border-top:4px solid var(--zone-c,var(--c-invalid)); }
|
||||
.zone-head { display:flex; justify-content:space-between; align-items:baseline; }
|
||||
.zone-head .n{ font-weight:700; font-size:1rem; } .zone-head .c{ font-family:var(--mono); font-size:0.75rem; color:var(--text-dim); }
|
||||
.pill { display:inline-block; font-family:var(--mono); font-size:0.72rem; padding:2px 7px;
|
||||
border-radius:4px; margin:2px 3px 2px 0; background:var(--surface2); border:1px solid var(--border); }
|
||||
.pill.ok{color:var(--c-ok);} .pill.minor{color:var(--c-minor);} .pill.major{color:var(--c-major);} .pill.dead{color:var(--c-invalid); opacity:.65;}
|
||||
details{ margin-top:9px; } details>summary{ cursor:pointer; font-size:0.82rem; color:var(--text-dim); font-family:var(--mono); padding:3px 0; }
|
||||
details>summary:hover{ color:var(--text); }
|
||||
table{ width:100%; border-collapse:collapse; font-size:0.8rem; margin-top:6px; }
|
||||
th,td{ text-align:left; padding:3px 6px 3px 0; border-bottom:1px solid var(--border); vertical-align:top; }
|
||||
th{ color:var(--text-dim); font-weight:600; font-size:0.72rem; text-transform:uppercase; }
|
||||
td.pv{ font-family:var(--mono); font-size:0.72rem; color:var(--text-dim); word-break:break-all; }
|
||||
td.val{ font-family:var(--mono); text-align:right; white-space:nowrap; }
|
||||
|
||||
.banner { border-radius:8px; padding:9px 14px; margin-bottom:12px; font-family:var(--mono); font-size:0.83rem; font-weight:700; }
|
||||
.banner.demo{ background:color-mix(in srgb,var(--c-demo) 18%,var(--surface)); color:var(--c-demo); border:1px solid var(--c-demo); }
|
||||
.banner.stale{ background:color-mix(in srgb,var(--c-minor) 15%,var(--surface)); color:var(--c-minor); border:1px solid var(--c-minor); }
|
||||
.banner.err{ background:color-mix(in srgb,var(--c-major) 15%,var(--surface)); color:var(--c-major); border:1px solid var(--c-major); }
|
||||
footer{ margin-top:22px; font-family:var(--mono); font-size:0.73rem; color:var(--text-dim); }
|
||||
.empty{ color:var(--text-dim); font-style:italic; font-size:0.85rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header>
|
||||
<h1>cSAXS — EPS & Machine Status</h1>
|
||||
<span class="meta" id="stamp">loading…</span>
|
||||
<span class="meta"><a href="status.html">→ experiment status</a></span>
|
||||
</header>
|
||||
|
||||
<div id="banners"></div>
|
||||
|
||||
<h2>Machine Status</h2>
|
||||
<div class="strip" id="machine-strip"></div>
|
||||
|
||||
<h2>Operator Messages</h2>
|
||||
<div class="panel"><div id="messages"></div></div>
|
||||
|
||||
<h2>Alarm Status</h2>
|
||||
<div id="hero" class="hero unknown"><div class="hero-title">—</div></div>
|
||||
|
||||
<h2>Mono Cryocooler</h2>
|
||||
<div class="panel"><div class="cryo" id="cryo"></div></div>
|
||||
|
||||
<h2>Beamline Synoptic</h2>
|
||||
<div class="panel">
|
||||
<a href="eps_synoptic.html" style="text-decoration:none; color:inherit; display:flex; align-items:center; gap:14px;">
|
||||
<div style="font-size:1.6rem;">🗺</div>
|
||||
<div>
|
||||
<div style="font-weight:700; color:var(--c-info);">Open full beamline synoptic →</div>
|
||||
<div class="empty" style="margin-top:2px;">Faithful EPS panel replica — pipes, valves, gauges, live values. Pan & zoom.</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<h2>Zone Detail</h2>
|
||||
<div class="zones" id="zones"></div>
|
||||
|
||||
<h2>Recent History</h2>
|
||||
<div class="panel" id="history"></div>
|
||||
|
||||
<footer id="footer"></footer>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const SEV_RANK={NO_ALARM:0,MINOR:1,MAJOR:2,INVALID:3,UNKNOWN:3};
|
||||
const SEV_VAR={0:"var(--c-ok)",1:"var(--c-minor)",2:"var(--c-major)",3:"var(--c-invalid)"};
|
||||
|
||||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}
|
||||
function fmtNum(v,egu){
|
||||
if(v===null||v===undefined) return "—";
|
||||
if(typeof v==="number"){
|
||||
let s; const a=Math.abs(v);
|
||||
if(a!==0&&(a<1e-3||a>=1e5)) s=v.toExponential(2);
|
||||
else s=(Math.round(v*100)/100).toString();
|
||||
return s+(egu?` <span class="u">${esc(egu)}</span>`:"");
|
||||
}
|
||||
return esc(String(v));
|
||||
}
|
||||
function ago(e){ if(!e) return "never"; const d=Date.now()/1000-e;
|
||||
if(d<60) return Math.round(d)+"s ago"; if(d<3600) return Math.round(d/60)+"m ago"; return Math.round(d/3600)+"h ago"; }
|
||||
function tstamp(e){ if(!e) return ""; return new Date(e*1000).toLocaleTimeString([],{hour:"2-digit",minute:"2-digit"}); }
|
||||
function truthy(v){ if(v===null||v===undefined) return null;
|
||||
if(typeof v==="number") return v!==0; if(typeof v==="boolean") return v;
|
||||
const s=String(v).trim().toLowerCase();
|
||||
if(["1","true","on","open","ok","yes","enabled","run","running"].includes(s)) return true;
|
||||
if(["0","false","off","closed","no","disabled"].includes(s)) return false; return null; }
|
||||
|
||||
const OPEN_KEY="eps.openZones"; let openZones=new Set();
|
||||
try{ const sv=sessionStorage.getItem(OPEN_KEY); if(sv) openZones=new Set(JSON.parse(sv)); }catch(e){}
|
||||
function persistOpen(){ try{ sessionStorage.setItem(OPEN_KEY,JSON.stringify([...openZones])); }catch(e){} }
|
||||
function onToggle(ev){ const k=ev.target.dataset.zone; if(!k) return;
|
||||
if(ev.target.open) openZones.add(k); else openZones.delete(k); persistOpen(); }
|
||||
function withScroll(fn){ const y=window.scrollY; fn(); if(Math.abs(window.scrollY-y)>1) window.scrollTo(0,y); }
|
||||
|
||||
function render(d){
|
||||
const b=[];
|
||||
if(d.demo) b.push('<div class="banner demo">DEMO MODE — synthetic values, not real beamline data</div>');
|
||||
if(!d.generator.epics_available&&!d.demo) b.push('<div class="banner err">pyepics not available — no PVs can be read</div>');
|
||||
if(d.bulk.stale&&!d.demo) b.push(`<div class="banner stale">Detail readings stale — last sweep ${ago(d.bulk.updated_at_epoch)}</div>`);
|
||||
const un=d.generator.pv_unreachable||0;
|
||||
if(un>0&&!d.demo) b.push(`<div class="banner stale">${un} of ${d.generator.pv_total} PVs unreachable</div>`);
|
||||
document.getElementById("banners").innerHTML=b.join("");
|
||||
|
||||
/* 1. machine */
|
||||
const M=d.bulk.machine||{}, H=d.hot||{};
|
||||
const ms=[];
|
||||
const mtile=(k,rec)=>{
|
||||
const cls=rec&&rec.connected?"sev-"+(rec.severity||"UNKNOWN"):"disconnected";
|
||||
const v=rec&&rec.connected?fmtNum(rec.value,rec.egu):"—";
|
||||
ms.push(`<div class="tile"><div class="k">${k}</div><div class="v ${cls}">${v}</div></div>`);
|
||||
};
|
||||
mtile("Ring current",H.ring_current);
|
||||
mtile("Lifetime",H.lifetime);
|
||||
mtile("Light delivery",H.light_delivery);
|
||||
mtile("Undulator gap",H.undulator_gap);
|
||||
mtile("Beamline",H.bl_status);
|
||||
mtile("Orbit feedback",M.fofb);
|
||||
mtile("Injection rate",M.injection_rate);
|
||||
mtile("Ring vacuum",M.ring_pressure);
|
||||
document.getElementById("machine-strip").innerHTML=ms.join("");
|
||||
|
||||
/* 2. operator messages */
|
||||
const msgs=d.bulk.messages||[];
|
||||
document.getElementById("messages").innerHTML = msgs.length
|
||||
? msgs.map(m=>`<div class="ev"><span class="t">${esc(m.date||"")}</span><span>${esc(m.text)}</span></div>`).join("")
|
||||
: '<div class="empty">no operator messages</div>';
|
||||
|
||||
/* 3. alarm hero */
|
||||
const hero=document.getElementById("hero");
|
||||
const cnt=(H.eps_alarm_count!==undefined)?H.eps_alarm_count:null;
|
||||
hero.className="hero "+d.eps_status;
|
||||
let title,sub="";
|
||||
if(d.eps_status==="ok"){ title="EPS OK"; sub="no active alarms"; }
|
||||
else if(d.eps_status==="alarm"){ title=`EPS ALARM — ${cnt}`; sub="equipment protection system reporting active alarms"; }
|
||||
else { title="EPS UNKNOWN"; sub="cannot read alarm count"; }
|
||||
let hh=`<div class="hero-title">${title}</div><div class="hero-sub">${sub}</div>`;
|
||||
if(H.eps_alarm_list&&String(H.eps_alarm_list).trim()&&d.eps_status==="alarm")
|
||||
hh+=`<div class="hero-text">${esc(H.eps_alarm_list)}</div>`;
|
||||
if(H.fe_alarm_count&&Number(H.fe_alarm_count)>0)
|
||||
hh+=`<div class="hero-text">Front-end alarms: ${esc(H.fe_alarm_count)} — ${esc(H.fe_alarm_list||"")}</div>`;
|
||||
if(d.eps_status==="ok"&&d.history&&d.history.eps_events){
|
||||
const past=d.history.eps_events.filter(e=>e.cleared_at);
|
||||
if(past.length){ const last=past[past.length-1];
|
||||
hh+=`<div class="hero-sub" style="margin-top:8px">last alarm ${ago(last.cleared_at)}: ${esc((last.texts||[]).join("; ")||"(no text)")}</div>`; }
|
||||
}
|
||||
hero.innerHTML=hh;
|
||||
|
||||
/* 4. cryocooler */
|
||||
const C=d.bulk.cryo||{};
|
||||
const cc=[];
|
||||
const runT=truthy(C.run&&C.run.value), almT=truthy(C.alarm&&C.alarm.value);
|
||||
const inhT=truthy(C.inhibit&&C.inhibit.value), disT=truthy(C.disable&&C.disable.value);
|
||||
cc.push(`<div class="tile"><div class="k">Run</div><div class="v ${runT?'sev-NO_ALARM':(runT===false?'sev-MINOR':'disconnected')}">${runT===null?'—':(runT?'RUNNING':'STOPPED')}</div></div>`);
|
||||
cc.push(`<div class="tile"><div class="k">Alarm</div><div class="v ${almT?'sev-MAJOR':(almT===false?'sev-NO_ALARM':'disconnected')}">${almT===null?'—':(almT?'ALARM':'clear')}</div></div>`);
|
||||
if(C.alarm_desc&&C.alarm_desc.value&&String(C.alarm_desc.value).trim())
|
||||
cc.push(`<div class="tile" style="grid-column:span 2"><div class="k">Alarm detail</div><div class="v" style="font-size:0.95rem">${esc(C.alarm_desc.value)}</div></div>`);
|
||||
cc.push(`<div class="tile"><div class="k">Inhibit</div><div class="v ${inhT?'sev-MINOR':'sev-NO_ALARM'}">${inhT===null?'—':(inhT?'INHIBIT':'no')}</div></div>`);
|
||||
cc.push(`<div class="tile"><div class="k">Interlock</div><div class="v ${disT?'sev-MINOR':'sev-NO_ALARM'}">${disT===null?'—':(disT?'DISABLED':'enabled')}</div></div>`);
|
||||
const hb=C.heartbeat&&C.heartbeat.connected;
|
||||
cc.push(`<div class="tile"><div class="k">Heartbeat</div><div class="v ${hb?'sev-NO_ALARM':'disconnected'}">${hb?fmtNum(C.heartbeat.value):'—'}</div></div>`);
|
||||
document.getElementById("cryo").innerHTML=cc.join("");
|
||||
|
||||
/* 6. zones */
|
||||
renderZones(d);
|
||||
|
||||
/* 7. history */
|
||||
const evs=[];
|
||||
for(const e of (d.history.eps_events||[]))
|
||||
evs.push({t:e.first_seen,cls:"eps",d:`EPS alarm (${e.count})${e.cleared_at?" — cleared":" — ACTIVE"}`,x:(e.texts||[]).join("; ")});
|
||||
for(const e of (d.history.machine_events||[]))
|
||||
evs.push({t:e.at,cls:"mach",d:e.label,x:`${e.from} \u2192 ${e.to}`});
|
||||
evs.sort((a,b)=>b.t-a.t);
|
||||
document.getElementById("history").innerHTML = evs.length
|
||||
? evs.slice(0,5).map(e=>`<div class="ev ${e.cls}"><span class="t">${tstamp(e.t)}</span><span class="d">${e.d}</span><span>${esc(e.x||"")}</span></div>`).join("")
|
||||
: '<div class="empty">no events recorded yet</div>';
|
||||
|
||||
document.getElementById("stamp").textContent=`updated ${ago(d.generated_at_epoch)} \u00b7 ${d.generated_at}`;
|
||||
const g=d.generator;
|
||||
document.getElementById("footer").textContent=
|
||||
`${g.host} \u00b7 hot ${g.hot_interval_s}s / bulk ${g.bulk_interval_s}s \u00b7 ${g.pv_total} PVs, ${g.pv_unreachable} unreachable \u00b7 detail ${ago(d.bulk.updated_at_epoch)}`;
|
||||
}
|
||||
|
||||
|
||||
function renderZones(d){
|
||||
const zc=[], labels=d.zone_labels||{};
|
||||
for(const [zone,items] of Object.entries(d.bulk.zones||{})){
|
||||
if(!items.length) continue;
|
||||
let worst=0,dead=0;
|
||||
for(const it of items){ if(!it.connected){dead++;continue;} worst=Math.max(worst,SEV_RANK[it.severity]??3); }
|
||||
const wc=SEV_VAR[worst], live=items.length-dead;
|
||||
const byRole={};
|
||||
for(const it of items){ const r=it.role||"other"; (byRole[r]=byRole[r]||[]).push(it); }
|
||||
const pills=Object.entries(byRole).map(([role,list])=>{
|
||||
let w=0,dd=0; for(const it of list){ if(!it.connected){dd++;continue;} w=Math.max(w,SEV_RANK[it.severity]??3); }
|
||||
const cl=dd===list.length?"dead":["ok","minor","major","dead"][w];
|
||||
const lbl=(d.role_labels&&d.role_labels[role])||role;
|
||||
return `<span class="pill ${cl}">${esc(lbl)} ${list.length-dd}/${list.length}</span>`;
|
||||
}).join("");
|
||||
const rows=items.map(it=>{
|
||||
const cls=it.connected?"sev-"+(it.severity||"UNKNOWN"):"disconnected";
|
||||
const v=it.connected?fmtNum(it.value,it.egu):"no conn";
|
||||
return `<tr><td>${esc(it.label||it.role||"")}</td><td class="pv">${esc(it.pv)}</td><td class="val ${cls}">${v}</td></tr>`;
|
||||
}).join("");
|
||||
zc.push(`<div class="zone" style="--zone-c:${wc}">
|
||||
<div class="zone-head"><span class="n">${esc(labels[zone]||zone)}</span><span class="c">${live}/${items.length}</span></div>
|
||||
<div style="margin-top:8px">${pills}</div>
|
||||
<details data-zone="${esc(zone)}"${openZones.has(zone)?" open":""}>
|
||||
<summary>${items.length} channels</summary>
|
||||
<table><thead><tr><th>Signal</th><th>PV</th><th style="text-align:right">Value</th></tr></thead><tbody>${rows}</tbody></table>
|
||||
</details></div>`);
|
||||
}
|
||||
document.getElementById("zones").innerHTML=zc.join("")||'<div class="zone"><span class="empty">no detail data yet</span></div>';
|
||||
document.querySelectorAll("#zones details[data-zone]").forEach(el=>el.addEventListener("toggle",onToggle));
|
||||
}
|
||||
|
||||
let seeded=sessionStorage.getItem(OPEN_KEY)!==null;
|
||||
async function tick(){
|
||||
try{
|
||||
const r=await fetch("eps_status.json?t="+Date.now(),{cache:"no-store"});
|
||||
if(!r.ok) throw new Error("HTTP "+r.status);
|
||||
const data=await r.json();
|
||||
if(!seeded){
|
||||
for(const [zone,items] of Object.entries(data.bulk.zones||{}))
|
||||
if(items.length&&items.length<=40) openZones.add(zone);
|
||||
seeded=true; persistOpen();
|
||||
}
|
||||
withScroll(()=>render(data));
|
||||
}catch(e){
|
||||
document.getElementById("banners").innerHTML=`<div class="banner err">cannot load eps_status.json — ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
tick();
|
||||
setInterval(tick,10000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>cSAXS EPS Synoptic</title>
|
||||
<style>
|
||||
:root{
|
||||
--mono:'Space Mono','Courier New',monospace;
|
||||
--sans:'DM Sans','Helvetica Neue',Arial,sans-serif;
|
||||
--c-ok:#52da3b; --c-minor:#f9e2af; --c-major:#f38ba8; --c-invalid:#6c7a9c;
|
||||
--bg:#0d0f14; --surface:#161a23; --border:#2a3045; --text:#cdd6f4; --text-dim:#6c7a9c;
|
||||
}
|
||||
@media (prefers-color-scheme: light){
|
||||
:root{ --bg:#e9edf5; --surface:#fff; --border:#c8cedf; --text:#1a1d2e; --text-dim:#5a6480;
|
||||
--c-ok:#40a02b; --c-minor:#df8e1d; --c-major:#d20f39; --c-invalid:#8c8fa1; }
|
||||
}
|
||||
*{box-sizing:border-box;}
|
||||
body{margin:0; background:var(--bg); color:var(--text); font-family:var(--sans); overflow:hidden;}
|
||||
header{ display:flex; align-items:center; gap:14px; padding:8px 14px; background:var(--surface);
|
||||
border-bottom:1px solid var(--border); flex-wrap:wrap; }
|
||||
header h1{ font-size:1rem; margin:0; font-weight:600; }
|
||||
header .meta{ font-family:var(--mono); font-size:0.74rem; color:var(--text-dim); }
|
||||
header a{ color:var(--c-ok); text-decoration:none; font-size:0.8rem; }
|
||||
.controls{ margin-left:auto; display:flex; gap:6px; }
|
||||
button{ font-family:var(--mono); font-size:0.8rem; background:var(--surface); color:var(--text);
|
||||
border:1px solid var(--border); border-radius:6px; padding:4px 10px; cursor:pointer; }
|
||||
button:hover{ border-color:var(--c-ok); }
|
||||
#stage{ position:absolute; top:47px; left:0; right:0; bottom:0; overflow:hidden; cursor:grab; touch-action:none; }
|
||||
#stage.grabbing{ cursor:grabbing; }
|
||||
#vp{ transform-origin:0 0; }
|
||||
svg{ display:block; }
|
||||
.banner{ position:absolute; top:52px; left:50%; transform:translateX(-50%); z-index:5;
|
||||
font-family:var(--mono); font-size:0.8rem; padding:6px 14px; border-radius:6px; font-weight:700; }
|
||||
.banner.demo{ background:#cba6f7; color:#000; } .banner.err{ background:var(--c-major); color:#fff; }
|
||||
.hint{ position:absolute; bottom:10px; left:50%; transform:translateX(-50%); z-index:5;
|
||||
font-family:var(--mono); font-size:0.72rem; color:var(--text-dim); background:var(--surface);
|
||||
padding:4px 10px; border-radius:6px; border:1px solid var(--border); opacity:0.85; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>cSAXS EPS Synoptic</h1>
|
||||
<span class="meta" id="stamp">loading…</span>
|
||||
<a href="eps_status.html">← status overview</a>
|
||||
<div class="controls">
|
||||
<button id="zin">+</button><button id="zout">−</button><button id="zfit">fit</button>
|
||||
</div>
|
||||
</header>
|
||||
<div id="stage"><div id="vp"></div></div>
|
||||
<div class="hint">drag to pan · scroll or pinch to zoom · hover a component for its PV</div>
|
||||
|
||||
<script>
|
||||
"use strict";
|
||||
const SEV_RANK={NO_ALARM:0,MINOR:1,MAJOR:2,INVALID:3,UNKNOWN:3};
|
||||
const SEV_COL={0:"var(--c-ok)",1:"var(--c-minor)",2:"var(--c-major)",3:"var(--c-invalid)"};
|
||||
function esc(s){return String(s).replace(/[&<>"']/g,c=>({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));}
|
||||
function fmtNum(v){
|
||||
if(v===null||v===undefined) return "-";
|
||||
if(typeof v==="number"){ const a=Math.abs(v);
|
||||
if(a!==0&&(a<1e-3||a>=1e5)) return v.toExponential(1);
|
||||
return (Math.round(v*100)/100).toString(); }
|
||||
return String(v);
|
||||
}
|
||||
function ago(e){ if(!e) return "never"; const d=Date.now()/1000-e;
|
||||
if(d<60) return Math.round(d)+"s ago"; if(d<3600) return Math.round(d/60)+"m ago"; return Math.round(d/3600)+"h ago"; }
|
||||
|
||||
let svgEl=null, byPv=new Map();
|
||||
async function loadSvg(){
|
||||
const r=await fetch("eps_synoptic.svg?t="+Date.now(),{cache:"no-store"});
|
||||
const txt=await r.text();
|
||||
document.getElementById("vp").innerHTML=txt;
|
||||
svgEl=document.getElementById("eps-synoptic-svg");
|
||||
// index nodes by pv
|
||||
byPv.clear();
|
||||
svgEl.querySelectorAll("[data-pv]").forEach(n=>{
|
||||
const pv=n.getAttribute("data-pv");
|
||||
if(!byPv.has(pv)) byPv.set(pv,[]);
|
||||
byPv.get(pv).push(n);
|
||||
// tooltip
|
||||
const tt=document.createElementNS("http://www.w3.org/2000/svg","title");
|
||||
tt.textContent=pv; n.appendChild(tt);
|
||||
});
|
||||
fit();
|
||||
}
|
||||
|
||||
function applyData(d){
|
||||
if(!svgEl) return;
|
||||
const map={};
|
||||
const collect=rec=>{ if(rec&&rec.pv) map[rec.pv]=rec; };
|
||||
const bulk=d.bulk||{};
|
||||
(bulk.synoptic||[]).forEach(collect);
|
||||
for(const z of Object.values(bulk.zones||{})) z.forEach(collect);
|
||||
Object.values(bulk.machine||{}).forEach(collect);
|
||||
Object.values(bulk.cryo||{}).forEach(collect);
|
||||
// synoptic-only channels (valve PLC_OPEN/PLC_CLOSE, setpoints, cooling calc)
|
||||
// keyed by PV name; these drive the visibility rules
|
||||
const sv=bulk.synoptic_values||{};
|
||||
for(const pv in sv){ if(!map[pv]) map[pv]={pv, ...sv[pv]}; }
|
||||
const hot=d.hot||{};
|
||||
|
||||
// numeric value of a PV from whatever slice has it
|
||||
const pvVal=pv=>{
|
||||
if(!pv) return null;
|
||||
const rec=map[pv]||map[pv.split(".")[0]];
|
||||
return rec?rec.value:null;
|
||||
};
|
||||
|
||||
// 1. visibility rules (valves open/closed, error flags, cooling locks, etc.)
|
||||
const truth=v=>{ if(v===null||v===undefined) return null;
|
||||
if(typeof v==="number") return v!==0;
|
||||
const s=String(v).trim().toLowerCase();
|
||||
return !["0","false","off","closed","no",""].includes(s); };
|
||||
// evaluate a caQtDM visibilityCalc like "(A=0)||(B=0)||(C=0)" or "!(A=1&&B=1)".
|
||||
// A..D map to the numeric values of data-vis-pv..pvd. Returns bool (default show).
|
||||
const evalCalc=(expr,vals)=>{
|
||||
if(!expr) return true;
|
||||
let js=expr;
|
||||
// map channel letters to numeric literals (A=vals[0] ...)
|
||||
js=js.replace(/\b([A-D])\b/g,(m,L)=>{ const v=vals[L.charCodeAt(0)-65];
|
||||
return (typeof v==="number")?String(v):(truth(v)?"1":"0"); });
|
||||
// caQtDM operators -> JS
|
||||
js=js.replace(/([^<>!=])=([^=])/g,"$1==$2") // single = to ==
|
||||
.replace(/&&/g,"&&").replace(/\|\|/g,"||");
|
||||
// allow only safe characters
|
||||
if(!/^[\d\s()!=<>&|.+\-*/]+$/.test(js)) return true;
|
||||
try{ return !!Function('"use strict";return ('+js+')')(); }
|
||||
catch(e){ return true; }
|
||||
};
|
||||
svgEl.querySelectorAll("[data-vis]").forEach(n=>{
|
||||
const kind=n.getAttribute("data-vis");
|
||||
const a=pvVal(n.getAttribute("data-vis-pv"));
|
||||
let show=true;
|
||||
if(kind==="ifzero"){ const t=truth(a); show=(t===false); }
|
||||
else if(kind==="ifnotzero"){ const t=truth(a); show=(t===true); }
|
||||
else if(kind==="calc"){
|
||||
const vals=[a, pvVal(n.getAttribute("data-vis-pvb")),
|
||||
pvVal(n.getAttribute("data-vis-pvc")),
|
||||
pvVal(n.getAttribute("data-vis-pvd"))];
|
||||
// if the rule needs channels we have no data for, leave visible
|
||||
show=evalCalc(n.getAttribute("data-vis-calc"), vals);
|
||||
}
|
||||
n.style.display = show ? "" : "none";
|
||||
});
|
||||
|
||||
// 2. recolor alarm shapes + fill value text
|
||||
// Skip shapes that carry a visibility rule: their colour is intrinsic
|
||||
// (green=open, red=closed) and must not be overridden by severity.
|
||||
byPv.forEach((nodes,pv)=>{
|
||||
const base=pv.split(".")[0];
|
||||
const rec=map[pv]||map[base];
|
||||
for(const n of nodes){
|
||||
if(n.tagName==="text"){
|
||||
let val="-",conn=false,sev="UNKNOWN";
|
||||
if(rec){ val=fmtNum(rec.value); conn=rec.connected; sev=rec.severity; }
|
||||
n.textContent=val;
|
||||
if(n.getAttribute("data-alarm")==="1")
|
||||
n.setAttribute("fill", conn?SEV_COL[SEV_RANK[sev]??3]:"var(--c-invalid)");
|
||||
} else if(!n.hasAttribute("data-vis")){
|
||||
if(rec) n.setAttribute("fill", SEV_COL[rec.connected?(SEV_RANK[rec.severity]??3):3]);
|
||||
else n.setAttribute("fill","var(--c-invalid)");
|
||||
}
|
||||
}
|
||||
});
|
||||
const banners=[];
|
||||
if(d.demo) banners.push('<div class="banner demo">DEMO — synthetic values</div>');
|
||||
if(d.bulk.stale&&!d.demo) banners.push('<div class="banner err">detail readings stale</div>');
|
||||
document.querySelectorAll(".banner").forEach(b=>b.remove());
|
||||
banners.forEach(h=>document.body.insertAdjacentHTML("afterbegin",h));
|
||||
document.getElementById("stamp").textContent=`updated ${ago(d.generated_at_epoch)} \u00b7 ${d.generator.pv_unreachable} unreachable`;
|
||||
}
|
||||
|
||||
/* ---- pan / zoom ---- */
|
||||
let scale=1, tx=0, ty=0;
|
||||
const vp=document.getElementById("vp"), stage=document.getElementById("stage");
|
||||
function apply(){ vp.style.transform=`translate(${tx}px,${ty}px) scale(${scale})`; }
|
||||
function fit(){
|
||||
if(!svgEl) return;
|
||||
const vb=svgEl.viewBox.baseVal, sr=stage.getBoundingClientRect();
|
||||
scale=Math.min(sr.width/vb.width, sr.height/vb.height)*0.98;
|
||||
tx=(sr.width-vb.width*scale)/2; ty=(sr.height-vb.height*scale)/2;
|
||||
svgEl.setAttribute("width",vb.width); svgEl.setAttribute("height",vb.height);
|
||||
apply();
|
||||
}
|
||||
function zoomAt(cx,cy,factor){
|
||||
const nx=cx-(cx-tx)*factor, ny=cy-(cy-ty)*factor;
|
||||
scale*=factor; tx=nx; ty=ny; apply();
|
||||
}
|
||||
stage.addEventListener("wheel",e=>{ e.preventDefault();
|
||||
const r=stage.getBoundingClientRect();
|
||||
zoomAt(e.clientX-r.left,e.clientY-r.top, e.deltaY<0?1.12:0.89); },{passive:false});
|
||||
let drag=null;
|
||||
stage.addEventListener("pointerdown",e=>{ drag={x:e.clientX-tx,y:e.clientY-ty}; stage.classList.add("grabbing"); stage.setPointerCapture(e.pointerId); });
|
||||
stage.addEventListener("pointermove",e=>{ if(!drag) return; tx=e.clientX-drag.x; ty=e.clientY-drag.y; apply(); });
|
||||
stage.addEventListener("pointerup",e=>{ drag=null; stage.classList.remove("grabbing"); });
|
||||
// pinch
|
||||
let pts=new Map(), pd=0;
|
||||
stage.addEventListener("pointerdown",e=>pts.set(e.pointerId,e));
|
||||
stage.addEventListener("pointermove",e=>{
|
||||
if(!pts.has(e.pointerId)) return; pts.set(e.pointerId,e);
|
||||
if(pts.size===2){ const[a,b]=[...pts.values()]; const nd=Math.hypot(a.clientX-b.clientX,a.clientY-b.clientY);
|
||||
if(pd){ const r=stage.getBoundingClientRect(); zoomAt((a.clientX+b.clientX)/2-r.left,(a.clientY+b.clientY)/2-r.top, nd/pd); } pd=nd; drag=null; }
|
||||
});
|
||||
stage.addEventListener("pointerup",e=>{ pts.delete(e.pointerId); if(pts.size<2) pd=0; });
|
||||
document.getElementById("zin").onclick=()=>{ const r=stage.getBoundingClientRect(); zoomAt(r.width/2,r.height/2,1.2); };
|
||||
document.getElementById("zout").onclick=()=>{ const r=stage.getBoundingClientRect(); zoomAt(r.width/2,r.height/2,0.83); };
|
||||
document.getElementById("zfit").onclick=fit;
|
||||
window.addEventListener("resize",fit);
|
||||
|
||||
async function tick(){
|
||||
try{
|
||||
const r=await fetch("eps_status.json?t="+Date.now(),{cache:"no-store"});
|
||||
if(r.ok) applyData(await r.json());
|
||||
}catch(e){}
|
||||
}
|
||||
(async()=>{ await loadSvg(); await tick(); setInterval(tick,10000); })();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 160 KiB |
@@ -0,0 +1,346 @@
|
||||
"""PVs referenced by the synoptic SVG (auto-generated)."""
|
||||
|
||||
SYNOPTIC_PVS = [
|
||||
"ARS12-VCVS-1000:ENA",
|
||||
"X12SA-EH1-PSYS:SH-A-CLOSE",
|
||||
"X12SA-EH1-PSYS:SH-A-OK",
|
||||
"X12SA-EH1-PSYS:SH-A-OPEN",
|
||||
"X12SA-ES-DET-CHIL1:DISABLE",
|
||||
"X12SA-ES-DET-CHIL2:DISABLE",
|
||||
"X12SA-ES-DET-ECCW-1010:ALARM",
|
||||
"X12SA-ES-DET-ECCW-1020:ALARM",
|
||||
"X12SA-ES-DET-ETTC-1010:TEMP",
|
||||
"X12SA-ES-DET-ETTC-1020:TEMP",
|
||||
"X12SA-ES-EB2-VMFR-1010:PRESSURE",
|
||||
"X12SA-ES-EB2-VMFR-1010:STATUS",
|
||||
"X12SA-ES-EB2-VPIG-1010:ERROR",
|
||||
"X12SA-ES-EB2-VPIG-1010:HV",
|
||||
"X12SA-ES-EB2-VPIG-1010:PRESS-DISP",
|
||||
"X12SA-ES-EB2-VPIG-1010:SETPOINT",
|
||||
"X12SA-ES-EB2-VPIG-1010:SETPOINT-STATUS",
|
||||
"X12SA-ES-EB2-VPIG-1010:VOLTAGE",
|
||||
"X12SA-ES-EB3-VMCP-2010:PRESSURE",
|
||||
"X12SA-ES-EB3-VMCP-2010:STATUS",
|
||||
"X12SA-ES-EB3-VMCP-2020:PRESSURE",
|
||||
"X12SA-ES-EB3-VMCP-2020:STATUS",
|
||||
"X12SA-ES-EB3-VMFR-2010:PRESSURE",
|
||||
"X12SA-ES-EB3-VMFR-2010:STATUS",
|
||||
"X12SA-ES-EB3-VPRO-2010:PLC_RUN",
|
||||
"X12SA-ES-EB3-VPTM-2010:PLC_PUMP-RBV",
|
||||
"X12SA-ES-EB3-VPTM-2010:PLC_TURBO-RBV",
|
||||
"X12SA-ES-EB3-VVPG-2010:PLC_CLOSE",
|
||||
"X12SA-ES-EB3-VVPG-2010:PLC_ERROR",
|
||||
"X12SA-ES-EB3-VVPG-2010:PLC_OPEN",
|
||||
"X12SA-ES-EB3-VVPG-2010:PLC_STATUS",
|
||||
"X12SA-ES-EB3-VVPP-2010:PLC_CLOSE",
|
||||
"X12SA-ES-EB3-VVPP-2010:PLC_ERROR",
|
||||
"X12SA-ES-EB3-VVPP-2010:PLC_OPEN",
|
||||
"X12SA-ES-EB3-VVPP-2010:PLC_STATUS",
|
||||
"X12SA-ES-FT-VMFR-1010:PRESSURE",
|
||||
"X12SA-ES-FT-VMFR-1010:STATUS",
|
||||
"X12SA-ES-VMMG-0010:PLC_RELAY-A",
|
||||
"X12SA-ES-VMMG-0010:PLC_RELAY-D",
|
||||
"X12SA-ES-VMMG-0010:PLC_RELAY-E",
|
||||
"X12SA-ES-VMMG-0010:PLC_RELAY-F",
|
||||
"X12SA-ES-VVPG-1010:PLC_CLOSE",
|
||||
"X12SA-ES-VVPG-1010:PLC_ERROR",
|
||||
"X12SA-ES-VVPG-1010:PLC_OPEN",
|
||||
"X12SA-ES-VVPG-1010:PLC_STATUS",
|
||||
"X12SA-FE-ABFE-VPIG-1010:ERROR",
|
||||
"X12SA-FE-ABFE-VPIG-1010:HV",
|
||||
"X12SA-FE-ABFE-VPIG-1010:PRESS-DISP",
|
||||
"X12SA-FE-ABFE-VPIG-1010:SETPOINT",
|
||||
"X12SA-FE-ABFE-VPIG-1010:SETPOINT-STATUS",
|
||||
"X12SA-FE-ABFE-VPIG-1010:VOLTAGE",
|
||||
"X12SA-FE-AP-EPSG-0010:OK",
|
||||
"X12SA-FE-CS-ECVW-0010:PLC_CLOSE",
|
||||
"X12SA-FE-CS-ECVW-0010:PLC_ERROR",
|
||||
"X12SA-FE-CS-ECVW-0010:PLC_OPEN",
|
||||
"X12SA-FE-CS-ECVW-0010:PLC_STATUS",
|
||||
"X12SA-FE-CS-ECVW-0020:PLC_CLOSE",
|
||||
"X12SA-FE-CS-ECVW-0020:PLC_ERROR",
|
||||
"X12SA-FE-CS-ECVW-0020:PLC_OPEN",
|
||||
"X12SA-FE-CS-ECVW-0020:PLC_STATUS",
|
||||
"X12SA-FE-CS-ECVW-0030:PLC_CLOSE",
|
||||
"X12SA-FE-CS-ECVW-0030:PLC_ERROR",
|
||||
"X12SA-FE-CS-ECVW-0030:PLC_OPEN",
|
||||
"X12SA-FE-CS-ECVW-0030:PLC_STATUS",
|
||||
"X12SA-FE-CS-ECVW-0040:PLC_CLOSE",
|
||||
"X12SA-FE-CS-ECVW-0040:PLC_ERROR",
|
||||
"X12SA-FE-CS-ECVW-0040:PLC_OPEN",
|
||||
"X12SA-FE-CS-ECVW-0040:PLC_STATUS",
|
||||
"X12SA-FE-DIA1-ETTC-0010:TEMP",
|
||||
"X12SA-FE-DIA1-ETTC-0020:TEMP",
|
||||
"X12SA-FE-PSH1-EMLS-0010:OPEN",
|
||||
"X12SA-FE-PSH1-EMLS-0020:CLOSE",
|
||||
"X12SA-FE-PSH1-EMLS:STATE",
|
||||
"X12SA-FE-PSH1-ETTC-0010:TEMP",
|
||||
"X12SA-FE-PSH1-ETTC-0020:TEMP",
|
||||
"X12SA-FE-PSH1-VMCC-1010:PRESSURE",
|
||||
"X12SA-FE-PSH1-VMCC-1010:STATUS",
|
||||
"X12SA-FE-PSH1-VMTC-1010:PRESSURE",
|
||||
"X12SA-FE-PSH1-VMTC-1010:STATUS",
|
||||
"X12SA-FE-PSH1-VPIG-1030:ERROR",
|
||||
"X12SA-FE-PSH1-VPIG-1030:HV",
|
||||
"X12SA-FE-PSH1-VPIG-1030:PRESS-DISP",
|
||||
"X12SA-FE-PSH1-VPIG-1030:SETPOINT",
|
||||
"X12SA-FE-PSH1-VPIG-1030:SETPOINT-STATUS",
|
||||
"X12SA-FE-PSH1-VPIG-1030:VOLTAGE",
|
||||
"X12SA-FE-PUM1-VPIG-1020:ERROR",
|
||||
"X12SA-FE-PUM1-VPIG-1020:HV",
|
||||
"X12SA-FE-PUM1-VPIG-1020:PRESS-DISP",
|
||||
"X12SA-FE-PUM1-VPIG-1020:SETPOINT",
|
||||
"X12SA-FE-PUM1-VPIG-1020:SETPOINT-STATUS",
|
||||
"X12SA-FE-PUM1-VPIG-1020:VOLTAGE",
|
||||
"X12SA-FE-PUM2-VPIG-2020:ERROR",
|
||||
"X12SA-FE-PUM2-VPIG-2020:HV",
|
||||
"X12SA-FE-PUM2-VPIG-2020:PRESS-DISP",
|
||||
"X12SA-FE-PUM2-VPIG-2020:SETPOINT",
|
||||
"X12SA-FE-PUM2-VPIG-2020:SETPOINT-STATUS",
|
||||
"X12SA-FE-PUM2-VPIG-2020:VOLTAGE",
|
||||
"X12SA-FE-PUM3-VMCC-2020:PRESSURE",
|
||||
"X12SA-FE-PUM3-VMCC-2020:STATUS",
|
||||
"X12SA-FE-PUM3-VMTC-2020:PRESSURE",
|
||||
"X12SA-FE-PUM3-VMTC-2020:STATUS",
|
||||
"X12SA-FE-PUM3-VPIG-2030:ERROR",
|
||||
"X12SA-FE-PUM3-VPIG-2030:HV",
|
||||
"X12SA-FE-PUM3-VPIG-2030:PRESS-DISP",
|
||||
"X12SA-FE-PUM3-VPIG-2030:SETPOINT",
|
||||
"X12SA-FE-PUM3-VPIG-2030:SETPOINT-STATUS",
|
||||
"X12SA-FE-PUM3-VPIG-2030:VOLTAGE",
|
||||
"X12SA-FE-SL1-ETTC-0010:TEMP",
|
||||
"X12SA-FE-SL1-ETTC-0020:TEMP",
|
||||
"X12SA-FE-SL2-ETTC-0010:TEMP",
|
||||
"X12SA-FE-SL2-ETTC-0020:TEMP",
|
||||
"X12SA-FE-STO1-EMLS-0010:OPEN",
|
||||
"X12SA-FE-STO1-EMLS-0020:CLOSE",
|
||||
"X12SA-FE-STO1-EMLS:STATE",
|
||||
"X12SA-FE-STO1-VMCC-2010:PRESSURE",
|
||||
"X12SA-FE-STO1-VMCC-2010:STATUS",
|
||||
"X12SA-FE-STO1-VMTC-2010:PRESSURE",
|
||||
"X12SA-FE-STO1-VMTC-2010:STATUS",
|
||||
"X12SA-FE-STO1-VPIG-2010:ERROR",
|
||||
"X12SA-FE-STO1-VPIG-2010:HV",
|
||||
"X12SA-FE-STO1-VPIG-2010:PRESS-DISP",
|
||||
"X12SA-FE-STO1-VPIG-2010:SETPOINT",
|
||||
"X12SA-FE-STO1-VPIG-2010:SETPOINT-STATUS",
|
||||
"X12SA-FE-STO1-VPIG-2010:VOLTAGE",
|
||||
"X12SA-FE-VCVS-0000:ENA",
|
||||
"X12SA-FE-VMCC-0000:PRESSURE",
|
||||
"X12SA-FE-VMCC-0000:STATUS",
|
||||
"X12SA-FE-VMMG-0010:PLC_RELAY-B",
|
||||
"X12SA-FE-VMMG-0010:PLC_RELAY-D",
|
||||
"X12SA-FE-VMMG-0010:PLC_RELAY-F",
|
||||
"X12SA-FE-VMTC-0000:PRESSURE",
|
||||
"X12SA-FE-VMTC-0000:STATUS",
|
||||
"X12SA-FE-VVFV-2010:PLC_CLOSE",
|
||||
"X12SA-FE-VVFV-2010:PLC_INRUSH",
|
||||
"X12SA-FE-VVFV-2010:PLC_OPEN",
|
||||
"X12SA-FE-VVPG-0000:PLC_CLOSE",
|
||||
"X12SA-FE-VVPG-0000:PLC_ERROR",
|
||||
"X12SA-FE-VVPG-0000:PLC_OPEN",
|
||||
"X12SA-FE-VVPG-0000:PLC_STATUS",
|
||||
"X12SA-FE-VVPG-1010:PLC_CLOSE",
|
||||
"X12SA-FE-VVPG-1010:PLC_ERROR",
|
||||
"X12SA-FE-VVPG-1010:PLC_OPEN",
|
||||
"X12SA-FE-VVPG-1010:PLC_STATUS",
|
||||
"X12SA-FE-VVPG-2010:PLC_CLOSE",
|
||||
"X12SA-FE-VVPG-2010:PLC_ERROR",
|
||||
"X12SA-FE-VVPG-2010:PLC_OPEN",
|
||||
"X12SA-FE-VVPG-2010:PLC_STATUS",
|
||||
"X12SA-FE-XBPM1-ETTC-0010:TEMP",
|
||||
"X12SA-FE-XBPM1-ETTC-0020:TEMP",
|
||||
"X12SA-OP-AP-EPTG-0010:PRESSURE",
|
||||
"X12SA-OP-CC-ECCN-0010:ALARM",
|
||||
"X12SA-OP-CC-ECCN-0010:ALARM.DESC",
|
||||
"X12SA-OP-CC-ECCN-0010:RUN",
|
||||
"X12SA-OP-CC-ECCN-0010:RUN.DESC",
|
||||
"X12SA-OP-CCM-ETTC-4010:TEMP",
|
||||
"X12SA-OP-CCM-ETTC-4020:TEMP",
|
||||
"X12SA-OP-CCM-VMFR-4010:PRESSURE",
|
||||
"X12SA-OP-CCM-VMFR-4010:STATUS",
|
||||
"X12SA-OP-CCM-VPIG-4010:ERROR",
|
||||
"X12SA-OP-CCM-VPIG-4010:HV",
|
||||
"X12SA-OP-CCM-VPIG-4010:PRESS-DISP",
|
||||
"X12SA-OP-CCM-VPIG-4010:SETPOINT",
|
||||
"X12SA-OP-CCM-VPIG-4010:SETPOINT-STATUS",
|
||||
"X12SA-OP-CCM-VPIG-4010:VOLTAGE",
|
||||
"X12SA-OP-CS-ECVW-0010:PLC_CLOSE",
|
||||
"X12SA-OP-CS-ECVW-0010:PLC_ERROR",
|
||||
"X12SA-OP-CS-ECVW-0010:PLC_OPEN",
|
||||
"X12SA-OP-CS-ECVW-0010:PLC_STATUS",
|
||||
"X12SA-OP-CS-ECVW-0020:PLC_CLOSE",
|
||||
"X12SA-OP-CS-ECVW-0020:PLC_ERROR",
|
||||
"X12SA-OP-CS-ECVW-0020:PLC_OPEN",
|
||||
"X12SA-OP-CS-ECVW-0020:PLC_STATUS",
|
||||
"X12SA-OP-DMM-ETTC-3010:TEMP",
|
||||
"X12SA-OP-DMM-ETTC-3020:TEMP",
|
||||
"X12SA-OP-DMM-ETTC-3030:TEMP",
|
||||
"X12SA-OP-DMM-ETTC-3040:TEMP",
|
||||
"X12SA-OP-DMM-VMFR-3010:PRESSURE",
|
||||
"X12SA-OP-DMM-VMFR-3010:STATUS",
|
||||
"X12SA-OP-DMM-VPIG-3010:ERROR",
|
||||
"X12SA-OP-DMM-VPIG-3010:HV",
|
||||
"X12SA-OP-DMM-VPIG-3010:PRESS-DISP",
|
||||
"X12SA-OP-DMM-VPIG-3010:SETPOINT",
|
||||
"X12SA-OP-DMM-VPIG-3010:SETPOINT-STATUS",
|
||||
"X12SA-OP-DMM-VPIG-3010:VOLTAGE",
|
||||
"X12SA-OP-EB1-VMCP-5010:PRESSURE",
|
||||
"X12SA-OP-EB1-VMCP-5010:STATUS",
|
||||
"X12SA-OP-EB1-VMFR-5020:PRESSURE",
|
||||
"X12SA-OP-EB1-VMFR-5020:STATUS",
|
||||
"X12SA-OP-EB1-VPIG-5010:ERROR",
|
||||
"X12SA-OP-EB1-VPIG-5010:HV",
|
||||
"X12SA-OP-EB1-VPIG-5010:PRESS-DISP",
|
||||
"X12SA-OP-EB1-VPIG-5010:SETPOINT",
|
||||
"X12SA-OP-EB1-VPIG-5010:SETPOINT-STATUS",
|
||||
"X12SA-OP-EB1-VPIG-5010:VOLTAGE",
|
||||
"X12SA-OP-EB1-VPIG-5020:ERROR",
|
||||
"X12SA-OP-EB1-VPIG-5020:HV",
|
||||
"X12SA-OP-EB1-VPIG-5020:PRESS-DISP",
|
||||
"X12SA-OP-EB1-VPIG-5020:SETPOINT",
|
||||
"X12SA-OP-EB1-VPIG-5020:SETPOINT-STATUS",
|
||||
"X12SA-OP-EB1-VPIG-5020:VOLTAGE",
|
||||
"X12SA-OP-EB1-VPRO-5010:PLC_RUN",
|
||||
"X12SA-OP-EB1-VPTM-5010:PLC_PUMP-RBV",
|
||||
"X12SA-OP-EB1-VPTM-5010:PLC_SPEED-SP",
|
||||
"X12SA-OP-EB1-VPTM-5010:PLC_TURBO-RBV",
|
||||
"X12SA-OP-EB1-VVPG-5010:PLC_CLOSE",
|
||||
"X12SA-OP-EB1-VVPG-5010:PLC_ERROR",
|
||||
"X12SA-OP-EB1-VVPG-5010:PLC_OPEN",
|
||||
"X12SA-OP-EB1-VVPG-5010:PLC_STATUS",
|
||||
"X12SA-OP-EB1-VVPP-5010:PLC_CLOSE",
|
||||
"X12SA-OP-EB1-VVPP-5010:PLC_ERROR",
|
||||
"X12SA-OP-EB1-VVPP-5010:PLC_OPEN",
|
||||
"X12SA-OP-EB1-VVPP-5010:PLC_STATUS",
|
||||
"X12SA-OP-KB-VMFR-6010:PRESSURE",
|
||||
"X12SA-OP-KB-VMFR-6010:STATUS",
|
||||
"X12SA-OP-KB-VPIG-6010:ERROR",
|
||||
"X12SA-OP-KB-VPIG-6010:HV",
|
||||
"X12SA-OP-KB-VPIG-6010:PRESS-DISP",
|
||||
"X12SA-OP-KB-VPIG-6010:SETPOINT",
|
||||
"X12SA-OP-KB-VPIG-6010:SETPOINT-STATUS",
|
||||
"X12SA-OP-KB-VPIG-6010:VOLTAGE",
|
||||
"X12SA-OP-KB-VPIG-6020:ERROR",
|
||||
"X12SA-OP-KB-VPIG-6020:HV",
|
||||
"X12SA-OP-KB-VPIG-6020:PRESS-DISP",
|
||||
"X12SA-OP-KB-VPIG-6020:SETPOINT",
|
||||
"X12SA-OP-KB-VPIG-6020:SETPOINT-STATUS",
|
||||
"X12SA-OP-KB-VPIG-6020:VOLTAGE",
|
||||
"X12SA-OP-PSH1-EMLS-7010:OPEN",
|
||||
"X12SA-OP-PSH1-EMLS-7020:CLOSE",
|
||||
"X12SA-OP-PSH1-EMLS:STATE",
|
||||
"X12SA-OP-PSH1-ETTC-7010:TEMP",
|
||||
"X12SA-OP-PSH1-ETTC-7020:TEMP",
|
||||
"X12SA-OP-PSH1-VMFR-6010:PRESSURE",
|
||||
"X12SA-OP-PSH1-VMFR-6010:STATUS",
|
||||
"X12SA-OP-PSH1-VPIG-7010:ERROR",
|
||||
"X12SA-OP-PSH1-VPIG-7010:HV",
|
||||
"X12SA-OP-PSH1-VPIG-7010:PRESS-DISP",
|
||||
"X12SA-OP-PSH1-VPIG-7010:SETPOINT",
|
||||
"X12SA-OP-PSH1-VPIG-7010:SETPOINT-STATUS",
|
||||
"X12SA-OP-PSH1-VPIG-7010:VOLTAGE",
|
||||
"X12SA-OP-PSYS:SH-A-CLOSE",
|
||||
"X12SA-OP-PSYS:SH-A-OK",
|
||||
"X12SA-OP-PSYS:SH-A-OPEN",
|
||||
"X12SA-OP-PSYS:SH-B-CLOSE",
|
||||
"X12SA-OP-PSYS:SH-B-OK",
|
||||
"X12SA-OP-PSYS:SH-B-OPEN",
|
||||
"X12SA-OP-PUM1-VMCC-1010:PRESSURE",
|
||||
"X12SA-OP-PUM1-VMCC-1010:STATUS",
|
||||
"X12SA-OP-PUM1-VMCP-1010:PRESSURE",
|
||||
"X12SA-OP-PUM1-VMCP-1010:STATUS",
|
||||
"X12SA-OP-PUM1-VPIG-1010:ERROR",
|
||||
"X12SA-OP-PUM1-VPIG-1010:HV",
|
||||
"X12SA-OP-PUM1-VPIG-1010:PRESS-DISP",
|
||||
"X12SA-OP-PUM1-VPIG-1010:SETPOINT",
|
||||
"X12SA-OP-PUM1-VPIG-1010:SETPOINT-STATUS",
|
||||
"X12SA-OP-PUM1-VPIG-1010:VOLTAGE",
|
||||
"X12SA-OP-PUM2-VMFR-2010:PRESSURE",
|
||||
"X12SA-OP-PUM2-VMFR-2010:STATUS",
|
||||
"X12SA-OP-PUM2-VPIG-2010:ERROR",
|
||||
"X12SA-OP-PUM2-VPIG-2010:HV",
|
||||
"X12SA-OP-PUM2-VPIG-2010:PRESS-DISP",
|
||||
"X12SA-OP-PUM2-VPIG-2010:SETPOINT",
|
||||
"X12SA-OP-PUM2-VPIG-2010:SETPOINT-STATUS",
|
||||
"X12SA-OP-PUM2-VPIG-2010:VOLTAGE",
|
||||
"X12SA-OP-PUM3-ETTC-2010:TEMP",
|
||||
"X12SA-OP-PUM3-VMFR-2010:PRESSURE",
|
||||
"X12SA-OP-PUM3-VMFR-2010:STATUS",
|
||||
"X12SA-OP-PUM3-VPIG-2010:ERROR",
|
||||
"X12SA-OP-PUM3-VPIG-2010:HV",
|
||||
"X12SA-OP-PUM3-VPIG-2010:PRESS-DISP",
|
||||
"X12SA-OP-PUM3-VPIG-2010:SETPOINT",
|
||||
"X12SA-OP-PUM3-VPIG-2010:SETPOINT-STATUS",
|
||||
"X12SA-OP-PUM3-VPIG-2010:VOLTAGE",
|
||||
"X12SA-OP-PUM4-ETTC-5010:TEMP",
|
||||
"X12SA-OP-PUM4-VMFR-5010:PRESSURE",
|
||||
"X12SA-OP-PUM4-VMFR-5010:STATUS",
|
||||
"X12SA-OP-PUM4-VPIG-5010:ERROR",
|
||||
"X12SA-OP-PUM4-VPIG-5010:HV",
|
||||
"X12SA-OP-PUM4-VPIG-5010:PRESS-DISP",
|
||||
"X12SA-OP-PUM4-VPIG-5010:SETPOINT",
|
||||
"X12SA-OP-PUM4-VPIG-5010:SETPOINT-STATUS",
|
||||
"X12SA-OP-PUM4-VPIG-5010:VOLTAGE",
|
||||
"X12SA-OP-PUM5-VMFR-5010:PRESSURE",
|
||||
"X12SA-OP-PUM5-VMFR-5010:STATUS",
|
||||
"X12SA-OP-PUM5-VPIG-2010:ERROR",
|
||||
"X12SA-OP-PUM5-VPIG-2010:HV",
|
||||
"X12SA-OP-PUM5-VPIG-2010:PRESS-DISP",
|
||||
"X12SA-OP-PUM5-VPIG-2010:SETPOINT",
|
||||
"X12SA-OP-PUM5-VPIG-2010:SETPOINT-STATUS",
|
||||
"X12SA-OP-PUM5-VPIG-2010:VOLTAGE",
|
||||
"X12SA-OP-SL1-ETTC-2010:TEMP",
|
||||
"X12SA-OP-SL1-ETTC-2020:TEMP",
|
||||
"X12SA-OP-SL2-ETTC-2010:TEMP",
|
||||
"X12SA-OP-SL2-ETTC-2020:TEMP",
|
||||
"X12SA-OP-SL3-ETTC-5010:TEMP",
|
||||
"X12SA-OP-SL3-ETTC-5020:TEMP",
|
||||
"X12SA-OP-SL4-ETTC-5010:TEMP",
|
||||
"X12SA-OP-SL4-ETTC-5020:TEMP",
|
||||
"X12SA-OP-VMMG-0010:PLC_RELAY-B",
|
||||
"X12SA-OP-VMMG-0010:PLC_RELAY-C",
|
||||
"X12SA-OP-VMMG-0010:PLC_RELAY-D",
|
||||
"X12SA-OP-VMMG-0010:PLC_RELAY-E",
|
||||
"X12SA-OP-VMMG-0010:PLC_RELAY-F",
|
||||
"X12SA-OP-VMMG-0020:PLC_RELAY-A",
|
||||
"X12SA-OP-VMMG-0020:PLC_RELAY-C",
|
||||
"X12SA-OP-VMMG-0020:PLC_RELAY-D",
|
||||
"X12SA-OP-VMMG-0020:PLC_RELAY-E",
|
||||
"X12SA-OP-VMMG-0020:PLC_RELAY-F",
|
||||
"X12SA-OP-VVFV-6010:PLC_CLOSE",
|
||||
"X12SA-OP-VVFV-6010:PLC_INRUSH",
|
||||
"X12SA-OP-VVFV-6010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-1010:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-1010:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-1010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-1010:PLC_STATUS",
|
||||
"X12SA-OP-VVPG-2010:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-2010:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-2010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-2010:PLC_STATUS",
|
||||
"X12SA-OP-VVPG-3010:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-3010:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-3010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-3010:PLC_STATUS",
|
||||
"X12SA-OP-VVPG-3020:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-3020:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-3020:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-3020:PLC_STATUS",
|
||||
"X12SA-OP-VVPG-4010:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-4010:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-4010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-4010:PLC_STATUS",
|
||||
"X12SA-OP-VVPG-5010:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-5010:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-5010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-5010:PLC_STATUS",
|
||||
"X12SA-OP-VVPG-6010:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-6010:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-6010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-6010:PLC_STATUS",
|
||||
"X12SA-OP-VVPG-7010:PLC_CLOSE",
|
||||
"X12SA-OP-VVPG-7010:PLC_ERROR",
|
||||
"X12SA-OP-VVPG-7010:PLC_OPEN",
|
||||
"X12SA-OP-VVPG-7010:PLC_STATUS",
|
||||
]
|
||||
@@ -0,0 +1,391 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Transpile the caQtDM EPS synoptic (X_EPS_X12SA-Graphic_new.ui) into an SVG
|
||||
template that mirrors the panel faithfully.
|
||||
|
||||
Key fidelity features (fixes over the first pass):
|
||||
* VISIBILITY RULES. caQtDM elements carry visibility=IfZero/IfNotZero/Calc
|
||||
bound to a channel. A valve is a green OPEN bowtie + a red CLOSED bowtie,
|
||||
each shown only when its channel says so. The first pass drew all of them
|
||||
at once (overlapping red+green+stem = mush). Now every conditional element
|
||||
is emitted with data-vis / data-vis-pv / data-vis-calc, and the frontend
|
||||
shows/hides it from live values, exactly like caQtDM.
|
||||
* DEVICE LABELS. The $(DESC) macro on each include is drawn under the glyph.
|
||||
* STRAIGHT PIPES. Beam-line polylines are snapped to horizontal where they
|
||||
are nearly horizontal, so pipes read as clean lines, not slightly skewed.
|
||||
* READABLE VALUES. Value fields use a larger font with a subtle backing box,
|
||||
matching the panel's readouts.
|
||||
|
||||
Layers in the output SVG:
|
||||
#syn-static fixed structure (pipes, chambers, outlines) + device labels
|
||||
#syn-dynamic shapes recolored / shown-hidden live (data-pv, data-vis*)
|
||||
#syn-text live value fields (data-pv, data-alarm)
|
||||
|
||||
Run standalone:
|
||||
python eps_synoptic_svg.py X_EPS_X12SA-Graphic_new.ui eps_synoptic.svg
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
CANVAS_W = 3071
|
||||
CANVAS_H = 774
|
||||
PIPE_GREY = "#7a828f"
|
||||
LABEL_COL = "#9aa6c0"
|
||||
SNAP_TOL = 3 # px: snap near-horizontal/vertical pipe segments
|
||||
|
||||
_GLYPH_TEMPLATES = {
|
||||
"EPS_Valve.ui", "EPS_Valve_inv.ui", "EPS_Gauge.ui", "EPS_IonPump.ui",
|
||||
"EPS_Temp.ui", "EPS_BST.ui", "EPS_FastValve.ui", "EPS_FastValve_Alarm.ui",
|
||||
"EPS_ChamberBig.ui", "EPS_ChamberSmall.ui", "VTP.ui",
|
||||
"EPS_CoolingValves.ui", "EPS_CoolingValvesSmall.ui",
|
||||
}
|
||||
|
||||
|
||||
def _rgb(c):
|
||||
if c is None:
|
||||
return None
|
||||
try:
|
||||
return f'rgb({c.find("red").text},{c.find("green").text},{c.find("blue").text})'
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _props(w):
|
||||
out = {}
|
||||
for p in w.findall("property"):
|
||||
name = p.get("name")
|
||||
color = p.find("color")
|
||||
if color is not None:
|
||||
out[name] = _rgb(color)
|
||||
continue
|
||||
rect = p.find("rect")
|
||||
if rect is not None:
|
||||
out[name] = tuple(int(rect.find(k).text)
|
||||
for k in ("x", "y", "width", "height"))
|
||||
continue
|
||||
for tag in ("string", "enum", "bool", "number"):
|
||||
e = p.find(tag)
|
||||
if e is not None:
|
||||
out[name] = e.text
|
||||
break
|
||||
return out
|
||||
|
||||
|
||||
def _abs_geom(w, pmap):
|
||||
x = y = ww = hh = 0
|
||||
first = True
|
||||
cur = w
|
||||
while cur is not None:
|
||||
if cur.tag == "widget":
|
||||
g = _props(cur).get("geometry")
|
||||
if isinstance(g, tuple):
|
||||
x += g[0]
|
||||
y += g[1]
|
||||
if first:
|
||||
ww, hh = g[2], g[3]
|
||||
first = False
|
||||
cur = pmap.get(cur)
|
||||
return x, y, ww, hh
|
||||
|
||||
|
||||
def _channel(props, key="channel"):
|
||||
v = props.get(key)
|
||||
return v.replace("$(BML)", "X12SA") if v else None
|
||||
|
||||
|
||||
def _parse_macro(m):
|
||||
d = {}
|
||||
for part in (m or "").split(","):
|
||||
if "=" in part:
|
||||
k, v = part.split("=", 1)
|
||||
d[k.strip()] = v.strip()
|
||||
return d
|
||||
|
||||
|
||||
def _subst(s, macros):
|
||||
if s is None:
|
||||
return None
|
||||
s = s.replace("$(BML)", "X12SA")
|
||||
for k, v in macros.items():
|
||||
s = s.replace(f"$({k})", v)
|
||||
return s.replace("$(BML)", "X12SA")
|
||||
|
||||
|
||||
def _esc(s):
|
||||
return (str(s).replace("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """))
|
||||
|
||||
|
||||
def _vis_attrs(props, macros=None):
|
||||
"""Return SVG data-* attributes encoding a caQtDM visibility rule."""
|
||||
mode = props.get("visibility", "") or ""
|
||||
if "IfZero" not in mode and "IfNotZero" not in mode and "Calc" not in mode:
|
||||
return ""
|
||||
ch = _channel(props)
|
||||
chb = _channel(props, "channelB")
|
||||
chc = _channel(props, "channelC")
|
||||
chd = _channel(props, "channelD")
|
||||
if macros:
|
||||
ch = _subst(ch, macros) if ch else None
|
||||
chb = _subst(chb, macros) if chb else None
|
||||
chc = _subst(chc, macros) if chc else None
|
||||
chd = _subst(chd, macros) if chd else None
|
||||
kind = ("ifzero" if "IfZero" in mode else
|
||||
"ifnotzero" if "IfNotZero" in mode else "calc")
|
||||
a = f' data-vis="{kind}"'
|
||||
if ch:
|
||||
a += f' data-vis-pv="{_esc(ch)}"'
|
||||
if chb:
|
||||
a += f' data-vis-pvb="{_esc(chb)}"'
|
||||
if chc and chc != "None":
|
||||
a += f' data-vis-pvc="{_esc(chc)}"'
|
||||
if chd and chd != "None":
|
||||
a += f' data-vis-pvd="{_esc(chd)}"'
|
||||
if kind == "calc":
|
||||
calc = props.get("visibilityCalc", "")
|
||||
a += f' data-vis-calc="{_esc(calc)}"'
|
||||
return a
|
||||
|
||||
|
||||
def _snap_pipe(coords):
|
||||
"""Snap near-horizontal / near-vertical polyline segments to straight."""
|
||||
if len(coords) < 2:
|
||||
return coords
|
||||
out = [list(coords[0])]
|
||||
for i in range(1, len(coords)):
|
||||
px, py = out[-1]
|
||||
cx, cy = coords[i]
|
||||
if abs(cy - py) <= SNAP_TOL:
|
||||
cy = py
|
||||
elif abs(cx - px) <= SNAP_TOL:
|
||||
cx = px
|
||||
out.append([cx, cy])
|
||||
return [tuple(p) for p in out]
|
||||
|
||||
|
||||
def _shape_from(props, cls, x, y, ww, hh, fg, macros=None):
|
||||
"""Build a shape dict from a caGraphics/caPolyLine/caFrame widget."""
|
||||
if cls == "caPolyLine":
|
||||
pts = props.get("xyPairs") or ""
|
||||
coords = []
|
||||
for pair in pts.split(";"):
|
||||
if "," in pair:
|
||||
a, b = pair.split(",")[:2]
|
||||
coords.append((x + int(a), y + int(b)))
|
||||
if not coords:
|
||||
return None
|
||||
filled = "Filled" in (props.get("fillstyle") or "") or \
|
||||
"Polygon" in (props.get("polystyle") or "")
|
||||
coords = _snap_pipe(coords)
|
||||
return {"kind": "polygon" if filled else "polyline",
|
||||
"points": coords, "stroke": fg,
|
||||
"fill": fg if filled else "none",
|
||||
"width": int(props.get("lineSize") or 2)}
|
||||
form = props.get("form", "") or ""
|
||||
if "Line" in form:
|
||||
# A caQtDM caGraphics Line is drawn axis-aligned through the CENTRE of
|
||||
# its bounding box - horizontal when the box is wider than tall,
|
||||
# vertical otherwise. Drawing it corner-to-corner (as the first pass
|
||||
# did) gave every beam-pipe segment a slope equal to the box height,
|
||||
# which is the skew seen on the pipes.
|
||||
if ww >= hh:
|
||||
cy = y + hh / 2
|
||||
return {"kind": "line", "x1": x, "y1": cy, "x2": x + ww, "y2": cy,
|
||||
"stroke": fg, "width": max(int(props.get("lineSize") or 2), hh)}
|
||||
cx = x + ww / 2
|
||||
return {"kind": "line", "x1": cx, "y1": y, "x2": cx, "y2": y + hh,
|
||||
"stroke": fg, "width": max(int(props.get("lineSize") or 2), ww)}
|
||||
if "Circle" in form:
|
||||
return {"kind": "ellipse", "cx": x + ww / 2, "cy": y + hh / 2,
|
||||
"rx": ww / 2, "ry": hh / 2, "fill": fg}
|
||||
return {"kind": "rect", "x": x, "y": y, "w": ww, "h": hh,
|
||||
"fill": fg, "stroke": (fg if cls == "caFrame" else None)}
|
||||
|
||||
|
||||
def transpile(ui_path):
|
||||
tree = ET.parse(ui_path)
|
||||
root = tree.getroot()
|
||||
pmap = {c: p for p in root.iter() for c in p}
|
||||
ui_dir = os.path.dirname(os.path.abspath(ui_path))
|
||||
|
||||
static, dynamic, texts, labels = [], [], [], []
|
||||
|
||||
def handle(w, macros, ox, oy, sub_pmap):
|
||||
cls = w.get("class")
|
||||
if cls not in ("caGraphics", "caPolyLine", "caFrame",
|
||||
"caLineEdit", "caLabel", "caLed"):
|
||||
return
|
||||
p = _props(w)
|
||||
x, y, ww, hh = _abs_geom(w, sub_pmap)
|
||||
x += ox
|
||||
y += oy
|
||||
mode = p.get("colorMode", "") or ""
|
||||
is_alarm = "Alarm" in mode
|
||||
pv = _channel(p)
|
||||
if macros and pv:
|
||||
pv = _subst(pv, macros)
|
||||
fg = p.get("foreground") or p.get("lineColor") or PIPE_GREY
|
||||
vis = _vis_attrs(p, macros)
|
||||
|
||||
# value fields
|
||||
if cls in ("caLineEdit",) and pv:
|
||||
texts.append({"pv": pv, "x": x, "y": y, "w": ww, "h": hh,
|
||||
"alarm": is_alarm, "vis": vis})
|
||||
return
|
||||
# labels
|
||||
if cls == "caLabel":
|
||||
txt = p.get("text")
|
||||
if macros:
|
||||
txt = _subst(txt, macros)
|
||||
if pv and not txt:
|
||||
texts.append({"pv": pv, "x": x, "y": y, "w": ww, "h": hh,
|
||||
"alarm": is_alarm, "vis": vis})
|
||||
return
|
||||
if txt and "html" not in txt.lower():
|
||||
labels.append({"x": x, "y": y, "w": ww, "h": hh,
|
||||
"text": txt.strip(), "vis": vis})
|
||||
elif txt and "html" in txt.lower():
|
||||
import re
|
||||
inner = re.sub(r"<[^>]+>", "", txt).strip()
|
||||
if inner:
|
||||
labels.append({"x": x, "y": y, "w": ww, "h": hh,
|
||||
"text": inner, "vis": vis})
|
||||
return
|
||||
|
||||
shape = _shape_from(p, cls, x, y, ww, hh, fg, macros)
|
||||
if not shape:
|
||||
return
|
||||
if vis:
|
||||
shape["vis"] = vis
|
||||
if pv:
|
||||
shape["pv"] = pv
|
||||
dynamic.append(shape)
|
||||
elif is_alarm and pv:
|
||||
shape["pv"] = pv
|
||||
dynamic.append(shape)
|
||||
else:
|
||||
static.append(shape)
|
||||
|
||||
# top level
|
||||
for w in root.iter("widget"):
|
||||
if w.get("class") == "caInclude":
|
||||
continue
|
||||
handle(w, None, 0, 0, pmap)
|
||||
|
||||
# includes -> device glyphs
|
||||
cache = {}
|
||||
for w in root.iter("widget"):
|
||||
if w.get("class") != "caInclude":
|
||||
continue
|
||||
p = _props(w)
|
||||
fn = p.get("filename")
|
||||
if fn not in _GLYPH_TEMPLATES:
|
||||
continue
|
||||
macros = _parse_macro(p.get("macro"))
|
||||
ox, oy, gw, gh = _abs_geom(w, pmap)
|
||||
if fn not in cache:
|
||||
try:
|
||||
cache[fn] = ET.parse(os.path.join(ui_dir, fn)).getroot()
|
||||
except Exception:
|
||||
cache[fn] = None
|
||||
sub = cache[fn]
|
||||
if sub is None:
|
||||
continue
|
||||
sub_pmap = {c: pp for pp in sub.iter() for c in pp}
|
||||
for sw in sub.iter("widget"):
|
||||
if sw.get("class") in ("QMainWindow", "QWidget", "caRelatedDisplay",
|
||||
"caMessageButton"):
|
||||
continue
|
||||
handle(sw, macros, ox, oy, sub_pmap)
|
||||
# device label from DESC under the glyph
|
||||
desc = macros.get("DESC", "")
|
||||
if desc:
|
||||
labels.append({"x": ox, "y": oy + gh, "w": gw, "h": 10,
|
||||
"text": desc, "vis": ""})
|
||||
|
||||
return static, dynamic, texts, labels
|
||||
|
||||
|
||||
def _svg_shape(s):
|
||||
pv = f' data-pv="{_esc(s["pv"])}"' if s.get("pv") else ""
|
||||
vis = s.get("vis", "")
|
||||
k = s["kind"]
|
||||
if k == "line":
|
||||
return (f'<line x1="{s["x1"]}" y1="{s["y1"]}" x2="{s["x2"]}" '
|
||||
f'y2="{s["y2"]}" stroke="{s["stroke"]}" stroke-width="{s["width"]}"'
|
||||
f'{pv}{vis}/>')
|
||||
if k in ("polyline", "polygon"):
|
||||
pts = " ".join(f"{a},{b}" for a, b in s["points"])
|
||||
fill = s.get("fill", "none")
|
||||
return (f'<{k} points="{pts}" fill="{fill}" stroke="{s["stroke"]}" '
|
||||
f'stroke-width="{s["width"]}" stroke-linejoin="round"{pv}{vis}/>')
|
||||
if k == "ellipse":
|
||||
return (f'<ellipse cx="{s["cx"]:.1f}" cy="{s["cy"]:.1f}" rx="{s["rx"]:.1f}" '
|
||||
f'ry="{s["ry"]:.1f}" fill="{s["fill"]}"{pv}{vis}/>')
|
||||
if k == "rect":
|
||||
if s.get("stroke"):
|
||||
body = f' fill="none" stroke="{s["stroke"]}"'
|
||||
else:
|
||||
body = f' fill="{s.get("fill", PIPE_GREY)}"'
|
||||
return (f'<rect x="{s["x"]}" y="{s["y"]}" width="{s["w"]}" '
|
||||
f'height="{s["h"]}"{body}{pv}{vis}/>')
|
||||
return ""
|
||||
|
||||
|
||||
def build_svg(ui_path):
|
||||
static, dynamic, texts, labels = transpile(ui_path)
|
||||
out = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {CANVAS_W} '
|
||||
f'{CANVAS_H}" preserveAspectRatio="xMidYMid meet" id="eps-synoptic-svg">']
|
||||
|
||||
out.append('<g id="syn-static">')
|
||||
out.extend(_svg_shape(s) for s in static)
|
||||
# device + section labels
|
||||
for lb in labels:
|
||||
vis = lb.get("vis", "")
|
||||
cx = lb["x"] + lb["w"] / 2
|
||||
cy = lb["y"] + 8
|
||||
out.append(f'<text x="{cx:.0f}" y="{cy:.0f}" text-anchor="middle" '
|
||||
f'font-family="var(--syn-font,monospace)" font-size="11" '
|
||||
f'fill="{LABEL_COL}"{vis}>{_esc(lb["text"])}</text>')
|
||||
out.append('</g>')
|
||||
|
||||
out.append('<g id="syn-dynamic">')
|
||||
out.extend(_svg_shape(s) for s in dynamic)
|
||||
out.append('</g>')
|
||||
|
||||
# value fields: backing box + text, larger for readability
|
||||
out.append('<g id="syn-text">')
|
||||
for t in texts:
|
||||
bx, by, bw, bh = t["x"], t["y"], max(t["w"], 34), max(t["h"], 14)
|
||||
cx = bx + bw / 2
|
||||
cy = by + bh - 3
|
||||
vis = t.get("vis", "")
|
||||
out.append(f'<g{vis}>')
|
||||
out.append(f'<rect x="{bx}" y="{by}" width="{bw}" height="{bh}" '
|
||||
f'rx="2" fill="#000" opacity="0.55"/>')
|
||||
out.append(f'<text x="{cx:.0f}" y="{cy:.0f}" text-anchor="middle" '
|
||||
f'font-family="var(--syn-font,monospace)" font-size="12" '
|
||||
f'font-weight="700" data-pv="{_esc(t["pv"])}" '
|
||||
f'data-alarm="{"1" if t["alarm"] else "0"}" fill="#cdd6f4">-</text>')
|
||||
out.append('</g>')
|
||||
out.append('</g>')
|
||||
|
||||
out.append('</svg>')
|
||||
return "\n".join(out), (len(static), len(dynamic), len(texts), len(labels))
|
||||
|
||||
|
||||
def main():
|
||||
ui = sys.argv[1] if len(sys.argv) > 1 else "X_EPS_X12SA-Graphic_new.ui"
|
||||
dest = sys.argv[2] if len(sys.argv) > 2 else "eps_synoptic.svg"
|
||||
svg, (ns, nd, nt, nl) = build_svg(ui)
|
||||
with open(dest, "w") as fh:
|
||||
fh.write(svg)
|
||||
print(f"{dest}: {ns} static, {nd} dynamic, {nt} values, {nl} labels")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Shared web infrastructure for the flOMNI status pages.
|
||||
|
||||
Contains HttpUploader and LocalHttpServer, lifted from
|
||||
flomni_webpage_generator.py so the experiment-status generator and the
|
||||
EPS/machine-status generator share one implementation.
|
||||
|
||||
Deliberate differences from the original:
|
||||
* No bec_lib import. Falls back to the stdlib logger when bec_lib is
|
||||
absent, so this module works outside a BEC session.
|
||||
* allow_redirects=False on every POST (SSRF hardening: a compromised
|
||||
public server must not be able to redirect an internal process to an
|
||||
arbitrary internal address).
|
||||
* LocalHttpServer takes a default_page so the printed URL is right for
|
||||
whichever page the caller serves.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import http.server
|
||||
import logging
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
try: # inside BEC
|
||||
from bec_lib import bec_logger
|
||||
logger = bec_logger.logger
|
||||
except Exception: # standalone
|
||||
logger = logging.getLogger("web_common")
|
||||
if not logger.handlers:
|
||||
_h = logging.StreamHandler()
|
||||
_h.setFormatter(logging.Formatter("%(asctime)s %(levelname)s %(message)s"))
|
||||
logger.addHandler(_h)
|
||||
logger.setLevel(logging.INFO)
|
||||
|
||||
|
||||
def _epoch() -> float:
|
||||
return time.time()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP uploader (non-blocking, fire-and-forget)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HttpUploader:
|
||||
"""
|
||||
Uploads files from a local directory to a remote server via HTTP POST.
|
||||
|
||||
Uploads run in a daemon thread so they never block the caller's cycle.
|
||||
If an upload is already in progress when the next cycle fires, the new
|
||||
request is dropped (logged at DEBUG) rather than queuing up.
|
||||
"""
|
||||
|
||||
_UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt", ".svg"}
|
||||
_WARN_COOLDOWN_S = 600
|
||||
|
||||
def __init__(self, url: str, timeout: float = 20.0):
|
||||
self._url = url
|
||||
self._timeout = timeout
|
||||
self._uploaded: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._busy = False
|
||||
self._warn_at: dict[str, float] = {}
|
||||
|
||||
def _warn(self, key: str, msg: str) -> None:
|
||||
"""Log a warning at most once per _WARN_COOLDOWN_S for a given key."""
|
||||
now = _epoch()
|
||||
if now - self._warn_at.get(key, 0) >= self._WARN_COOLDOWN_S:
|
||||
self._warn_at[key] = now
|
||||
logger.warning(msg)
|
||||
|
||||
# -- public API ---------------------------------------------------------
|
||||
|
||||
def upload_dir_async(self, directory: Path) -> None:
|
||||
"""Upload ALL eligible files in directory, in a background thread."""
|
||||
self._dispatch(self._upload_files, self._eligible_files(directory), True)
|
||||
|
||||
def upload_changed_async(self, directory: Path) -> None:
|
||||
"""Upload only files whose mtime changed since the last upload."""
|
||||
files = self._changed_files(directory)
|
||||
if files:
|
||||
self._dispatch(self._upload_files, files, False)
|
||||
|
||||
def upload_file_async(self, path: Path) -> None:
|
||||
"""Upload one specific file, bypassing suffix whitelist and mtime check."""
|
||||
self._dispatch(self._upload_files, [Path(path)], True)
|
||||
|
||||
def cleanup_ptycho_images_async(self) -> None:
|
||||
"""Ask the server to delete all S*_*.png / S*_*.jpg files (background)."""
|
||||
self._dispatch(self._do_cleanup)
|
||||
|
||||
# -- internals ----------------------------------------------------------
|
||||
|
||||
def _dispatch(self, fn, *args) -> None:
|
||||
with self._lock:
|
||||
if self._busy:
|
||||
logger.debug("HttpUploader: previous upload still running, skipping")
|
||||
return
|
||||
self._busy = True
|
||||
|
||||
def _run():
|
||||
try:
|
||||
fn(*args)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._busy = False
|
||||
|
||||
threading.Thread(target=_run, name="HttpUploader", daemon=True).start()
|
||||
|
||||
def _eligible_files(self, directory: Path) -> list:
|
||||
try:
|
||||
return [p for p in Path(directory).iterdir()
|
||||
if p.is_file() and p.suffix in self._UPLOAD_SUFFIXES]
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
def _changed_files(self, directory: Path) -> list:
|
||||
out = []
|
||||
for path in self._eligible_files(directory):
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if self._uploaded.get(str(path)) != mtime:
|
||||
out.append(path)
|
||||
return out
|
||||
|
||||
def _upload_files(self, files: list, force: bool = False) -> None:
|
||||
try:
|
||||
import requests as _requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except ImportError:
|
||||
self._warn("no_requests", "HttpUploader: 'requests' library not installed")
|
||||
return
|
||||
|
||||
for path in files:
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if not force and self._uploaded.get(str(path)) == mtime:
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as fh:
|
||||
r = _requests.post(
|
||||
self._url,
|
||||
files={"file": (path.name, fh)},
|
||||
timeout=self._timeout,
|
||||
verify=False, # accept self-signed certs
|
||||
allow_redirects=False, # SSRF hardening
|
||||
)
|
||||
if r.status_code == 200:
|
||||
self._uploaded[str(path)] = mtime
|
||||
self._warn_at.pop(f"upload_{path.name}", None)
|
||||
logger.debug(f"HttpUploader: OK {path.name}")
|
||||
else:
|
||||
self._warn(
|
||||
f"upload_{path.name}",
|
||||
f"HttpUploader: {path.name} -> HTTP {r.status_code}: "
|
||||
f"{r.text[:120]}",
|
||||
)
|
||||
except Exception as exc:
|
||||
self._warn(f"upload_{path.name}",
|
||||
f"HttpUploader: {path.name} failed: {exc}")
|
||||
|
||||
def _do_cleanup(self) -> None:
|
||||
try:
|
||||
import requests as _requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except ImportError:
|
||||
return
|
||||
try:
|
||||
r = _requests.post(
|
||||
self._url,
|
||||
data={"action": "cleanup"},
|
||||
timeout=self._timeout,
|
||||
verify=False, # accept self-signed certs
|
||||
allow_redirects=False, # SSRF hardening
|
||||
)
|
||||
logger.info(f"HttpUploader cleanup: {r.text[:120]}")
|
||||
self._warn_at.pop("cleanup", None)
|
||||
# Forget mtime records for ptycho files so they get re-uploaded
|
||||
with self._lock:
|
||||
to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k]
|
||||
for k in to_remove:
|
||||
self._uploaded.pop(k, None)
|
||||
except Exception as exc:
|
||||
self._warn("cleanup", f"HttpUploader cleanup failed: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local HTTP server
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocalHttpServer:
|
||||
"""
|
||||
Serves a directory over plain HTTP in a daemon thread.
|
||||
|
||||
Uses the stdlib http.server; no extra dependencies. Request logging is
|
||||
suppressed so the console stays clean.
|
||||
"""
|
||||
|
||||
def __init__(self, directory: Path, port: int = 8082,
|
||||
default_page: str = "index.html"):
|
||||
self._directory = Path(directory)
|
||||
self._port = port
|
||||
self._default_page = default_page
|
||||
self._server = None
|
||||
self._thread = None
|
||||
|
||||
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
|
||||
class _QuietHTTPServer(http.server.HTTPServer):
|
||||
# NOTE: handle_error must live on the *server*, not the handler.
|
||||
# Overriding it on the handler class does not suppress
|
||||
# BrokenPipeError, which clients trigger constantly by navigating
|
||||
# away mid-response.
|
||||
def handle_error(self, request, client_address):
|
||||
pass
|
||||
|
||||
def start(self) -> None:
|
||||
handler = functools.partial(self._QuietHandler,
|
||||
directory=str(self._directory))
|
||||
try:
|
||||
self._server = self._QuietHTTPServer(("", self._port), handler)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"LocalHttpServer: cannot bind port {self._port}: {exc}"
|
||||
) from exc
|
||||
self._thread = threading.Thread(target=self._server.serve_forever,
|
||||
name="LocalHttpServer", daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.shutdown()
|
||||
self._server = None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
return f"http://{socket.gethostname()}:{self._port}/{self._default_page}"
|
||||
@@ -83,8 +83,6 @@ changes. Look for "[local-only: account mismatch]" in the startup log / status()
|
||||
"""
|
||||
|
||||
import datetime
|
||||
import functools
|
||||
import http.server
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
@@ -101,6 +99,14 @@ except ImportError:
|
||||
|
||||
from bec_lib import bec_logger
|
||||
|
||||
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.web_common import (
|
||||
HttpUploader,
|
||||
LocalHttpServer,
|
||||
)
|
||||
from csaxs_bec.bec_ipython_client.plugins.OMNY_shared.eps.eps_status_generator import (
|
||||
EpsStatusGenerator,
|
||||
)
|
||||
|
||||
logger = bec_logger.logger
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -361,246 +367,12 @@ def _derive_status(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP Uploader (non-blocking, fire-and-forget)
|
||||
# HttpUploader / LocalHttpServer now live in OMNY_shared/web_common.py,
|
||||
# shared with the EPS/machine-status generator (OMNY_shared/eps/) -- see the
|
||||
# import at the top of this file. That copy also has an SSRF hardening
|
||||
# (allow_redirects=False on upload POSTs) this file didn't have before.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class HttpUploader:
|
||||
"""
|
||||
Uploads files from a local directory to a remote server via HTTP POST.
|
||||
|
||||
Uploads run in a daemon thread so they never block the generator cycle.
|
||||
If an upload is already in progress when the next cycle fires, the new
|
||||
upload request is dropped (logged at DEBUG level) rather than queuing up.
|
||||
|
||||
File tracking:
|
||||
- Tracks mtime of each file; only re-uploads files that have changed.
|
||||
- upload_dir() -- uploads ALL eligible files (called once at start).
|
||||
- upload_changed() -- uploads only files whose mtime has changed.
|
||||
|
||||
Scan change cleanup:
|
||||
- cleanup_ptycho_images() -- sends action=cleanup POST to the server,
|
||||
asking it to delete all S*_*.png and S*_*.jpg files.
|
||||
Called automatically by the generator when the ptycho scan ID changes.
|
||||
|
||||
The server-side upload.php must:
|
||||
- Accept POST with multipart file upload (field name 'file').
|
||||
- Accept POST with action=cleanup to delete ptycho image files.
|
||||
- Enforce IP-based access control (129.129.122.x).
|
||||
- Validate filename with regex to block path traversal.
|
||||
"""
|
||||
|
||||
_UPLOAD_SUFFIXES = {".html", ".json", ".jpg", ".png", ".pdf", ".txt"}
|
||||
|
||||
def __init__(self, url: str, timeout: float = 20.0):
|
||||
self._url = url
|
||||
self._timeout = timeout
|
||||
self._uploaded: dict[str, float] = {} # abs path -> mtime at last upload
|
||||
self._lock = threading.Lock()
|
||||
self._busy = False # True while an upload thread is running
|
||||
self._warn_at: dict[str, float] = {} # key -> epoch of last warning
|
||||
|
||||
_WARN_COOLDOWN_S = 600 # only repeat the same warning once per minute
|
||||
|
||||
def _warn(self, key: str, msg: str) -> None:
|
||||
"""Log a warning at most once per _WARN_COOLDOWN_S for a given key."""
|
||||
now = _epoch()
|
||||
if now - self._warn_at.get(key, 0) >= self._WARN_COOLDOWN_S:
|
||||
self._warn_at[key] = now
|
||||
logger.warning(msg)
|
||||
|
||||
# ── Public API ──────────────────────────────────────────────────────────
|
||||
|
||||
def upload_dir_async(self, directory: Path) -> None:
|
||||
"""Upload ALL eligible files in directory, in a background thread."""
|
||||
files = self._eligible_files(directory)
|
||||
self._dispatch(self._upload_files, files, True)
|
||||
|
||||
def upload_changed_async(self, directory: Path) -> None:
|
||||
"""Upload only changed files in directory, in a background thread."""
|
||||
files = self._changed_files(directory)
|
||||
if files:
|
||||
self._dispatch(self._upload_files, files, False)
|
||||
|
||||
def upload_file_async(self, path: Path) -> None:
|
||||
"""Upload a single specific file, bypassing suffix whitelist and mtime check."""
|
||||
self._dispatch(self._upload_files, [Path(path)], True)
|
||||
|
||||
def cleanup_ptycho_images_async(self) -> None:
|
||||
"""Ask the server to delete all S*_*.png / S*_*.jpg files (background)."""
|
||||
self._dispatch(self._do_cleanup)
|
||||
|
||||
# ── Internal ────────────────────────────────────────────────────────────
|
||||
|
||||
def _dispatch(self, fn, *args) -> None:
|
||||
"""Run fn(*args) in a daemon thread. Drops the request if already busy."""
|
||||
with self._lock:
|
||||
if self._busy:
|
||||
logger.debug("HttpUploader: previous upload still running, skipping")
|
||||
return
|
||||
self._busy = True
|
||||
|
||||
def _run():
|
||||
try:
|
||||
fn(*args)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._busy = False
|
||||
|
||||
t = threading.Thread(target=_run, name="HttpUploader", daemon=True)
|
||||
t.start()
|
||||
|
||||
def _eligible_files(self, directory: Path) -> list:
|
||||
result = []
|
||||
for path in Path(directory).iterdir():
|
||||
if path.is_file() and path.suffix in self._UPLOAD_SUFFIXES:
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
def _changed_files(self, directory: Path) -> list:
|
||||
result = []
|
||||
for path in self._eligible_files(directory):
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
if self._uploaded.get(str(path)) != mtime:
|
||||
result.append(path)
|
||||
return result
|
||||
|
||||
def _upload_files(self, files: list, force: bool = False) -> None:
|
||||
try:
|
||||
import requests as _requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except ImportError:
|
||||
self._warn("no_requests", "HttpUploader: 'requests' library not installed")
|
||||
return
|
||||
|
||||
for path in files:
|
||||
try:
|
||||
mtime = path.stat().st_mtime
|
||||
except OSError:
|
||||
continue
|
||||
# Double-check mtime unless forced (upload_dir uses force=True)
|
||||
if not force and self._uploaded.get(str(path)) == mtime:
|
||||
continue
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
r = _requests.post(
|
||||
self._url,
|
||||
files={"file": (path.name, f)},
|
||||
timeout=self._timeout,
|
||||
verify=False, # accept self-signed / untrusted certs
|
||||
)
|
||||
if r.status_code == 200:
|
||||
self._uploaded[str(path)] = mtime
|
||||
self._warn_at.pop(f"upload_{path.name}", None) # clear on success
|
||||
logger.debug(f"HttpUploader: OK {path.name}")
|
||||
else:
|
||||
self._warn(
|
||||
f"upload_{path.name}",
|
||||
f"HttpUploader: {path.name} -> HTTP {r.status_code}: "
|
||||
f"{r.text[:120]}"
|
||||
)
|
||||
except Exception as exc:
|
||||
self._warn(f"upload_{path.name}", f"HttpUploader: {path.name} failed: {exc}")
|
||||
|
||||
def _do_cleanup(self) -> None:
|
||||
try:
|
||||
import requests as _requests
|
||||
import urllib3
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
except ImportError:
|
||||
return
|
||||
try:
|
||||
r = _requests.post(
|
||||
self._url,
|
||||
data={"action": "cleanup"},
|
||||
timeout=self._timeout,
|
||||
verify=False, # accept self-signed / untrusted certs
|
||||
)
|
||||
logger.info(f"HttpUploader cleanup: {r.text[:120]}")
|
||||
self._warn_at.pop("cleanup", None) # clear on success
|
||||
# Forget mtime records for ptycho files so they get re-uploaded
|
||||
with self._lock:
|
||||
to_remove = [k for k in self._uploaded if "/S" in k or "\\S" in k]
|
||||
for k in to_remove:
|
||||
self._uploaded.pop(k, None)
|
||||
except Exception as exc:
|
||||
self._warn("cleanup", f"HttpUploader cleanup failed: {exc}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Local HTTP server (serves output_dir over http://hostname:port/)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LocalHttpServer:
|
||||
"""
|
||||
Serves the generator's output directory over plain HTTP in a daemon thread.
|
||||
|
||||
Uses Python's built-in http.server — no extra dependencies.
|
||||
Request logging is suppressed so the BEC console stays clean.
|
||||
The server survives stop()/start() cycles: _launch() creates a fresh
|
||||
instance each time start() is called.
|
||||
|
||||
Usage:
|
||||
srv = LocalHttpServer(output_dir, port=8080)
|
||||
srv.start()
|
||||
print(srv.url) # http://hostname:8080/status.html
|
||||
srv.stop()
|
||||
"""
|
||||
|
||||
def __init__(self, directory: Path, port: int = 8080):
|
||||
self._directory = Path(directory)
|
||||
self._port = port
|
||||
self._server = None
|
||||
self._thread = None
|
||||
|
||||
# ── silence the per-request log lines in the iPython console ──────────
|
||||
class _QuietHandler(http.server.SimpleHTTPRequestHandler):
|
||||
def log_message(self, *args):
|
||||
pass
|
||||
# handle_error here does nothing — wrong class
|
||||
|
||||
class _QuietHTTPServer(http.server.HTTPServer):
|
||||
def handle_error(self, request, client_address):
|
||||
pass # suppress BrokenPipeError and all other per-connection noise
|
||||
|
||||
def start(self) -> None:
|
||||
Handler = functools.partial(
|
||||
self._QuietHandler,
|
||||
directory=str(self._directory),
|
||||
)
|
||||
try:
|
||||
self._server = self._QuietHTTPServer(("", self._port), Handler)
|
||||
except OSError as exc:
|
||||
raise RuntimeError(
|
||||
f"LocalHttpServer: cannot bind port {self._port}: {exc}"
|
||||
) from exc
|
||||
self._thread = threading.Thread(
|
||||
target=self._server.serve_forever,
|
||||
name="LocalHttpServer",
|
||||
daemon=True,
|
||||
)
|
||||
self._thread.start()
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._server is not None:
|
||||
self._server.shutdown() # blocks until serve_forever() returns
|
||||
self._server = None
|
||||
|
||||
def is_alive(self) -> bool:
|
||||
return self._thread is not None and self._thread.is_alive()
|
||||
|
||||
@property
|
||||
def port(self) -> int:
|
||||
return self._port
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
"""Best-guess URL for printing. Uses the machine's hostname."""
|
||||
return f"http://{socket.gethostname()}:{self._port}/status.html"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base generator
|
||||
@@ -640,9 +412,24 @@ class WebpageGeneratorBase:
|
||||
subclasses whose global-var names differ (e.g.
|
||||
lamni's tomo_circfov / lamni_stitch_x/y instead of
|
||||
flomni's fovx/fovy / stitch_x/y).
|
||||
HAS_EPS_STATUS -- whether to also start the beamline EPS/machine
|
||||
status pages (OMNY_shared/eps/) alongside the
|
||||
experiment page, serving from the same output dir
|
||||
and port. Unlike HAS_TOMO_QUEUE this is beamline-
|
||||
wide, not per-instrument, so it's expected to stay
|
||||
True for every setup -- kept as a class attribute
|
||||
for symmetry/override-ability, not because any
|
||||
current subclass needs it False. The EPS
|
||||
generator decides real-vs-demo data itself (see
|
||||
eps_status_generator._on_beamline_network),
|
||||
independent of the account-mismatch local_only
|
||||
fallback below. It never uploads on its own --
|
||||
this generator's own uploader already covers the
|
||||
whole shared output dir, EPS files included.
|
||||
"""
|
||||
|
||||
HAS_TOMO_QUEUE = True
|
||||
HAS_EPS_STATUS = True
|
||||
TOMO_TYPES = {
|
||||
1: {"kind": "equally_spaced_grid", "n_subtomos": 8},
|
||||
2: {"kind": "golden_capped"},
|
||||
@@ -682,6 +469,7 @@ class WebpageGeneratorBase:
|
||||
self._mismatch_local_port = mismatch_local_port
|
||||
self._local_server = None # created fresh each _launch()
|
||||
self._local_only = False # True while running the account-mismatch fallback
|
||||
self._eps = None # EpsStatusGenerator, constructed in _launch()
|
||||
|
||||
# Derive companion URLs from upload_url
|
||||
if upload_url is not None:
|
||||
@@ -761,6 +549,7 @@ class WebpageGeneratorBase:
|
||||
self.TOMO_TYPES,
|
||||
self.TQ_PARAM_DISPLAY,
|
||||
logo_filename,
|
||||
has_eps_status=self.HAS_EPS_STATUS,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -775,7 +564,7 @@ class WebpageGeneratorBase:
|
||||
port = self._mismatch_local_port if local_only else self._local_port
|
||||
if self._local_server is not None and self._local_server.is_alive():
|
||||
self._local_server.stop()
|
||||
self._local_server = LocalHttpServer(self._output_dir, port)
|
||||
self._local_server = LocalHttpServer(self._output_dir, port, default_page="status.html")
|
||||
try:
|
||||
self._local_server.start()
|
||||
local_url_msg = f" local={self._local_server.url}"
|
||||
@@ -783,6 +572,36 @@ class WebpageGeneratorBase:
|
||||
local_url_msg = f" local=ERROR({exc})"
|
||||
self._log(VERBOSITY_NORMAL, str(exc), level="warning")
|
||||
|
||||
# EPS/machine-status pages (OMNY_shared/eps/): best-effort add-on,
|
||||
# sharing this output dir and LocalHttpServer (serve=False) so both
|
||||
# page families come up on the same port. upload_url is always None
|
||||
# here -- this generator's own upload_changed_async(self._output_dir)
|
||||
# a few lines below already covers the whole shared directory,
|
||||
# including the eps_* files, every cycle; giving EpsStatusGenerator
|
||||
# its own upload_url as well would spin up a second HttpUploader
|
||||
# re-scanning and re-POSTing the same directory redundantly. Real-vs-
|
||||
# demo data is decided independently by the EPS generator itself
|
||||
# (see eps_status_generator._on_beamline_network), not by local_only,
|
||||
# so a mismatched account on the real beamline network still gets
|
||||
# real EPS data, just served locally.
|
||||
if self.HAS_EPS_STATUS:
|
||||
if self._eps is not None:
|
||||
try:
|
||||
self._eps.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._eps = EpsStatusGenerator(
|
||||
output_dir=self._output_dir,
|
||||
serve=False,
|
||||
upload_url=None,
|
||||
)
|
||||
try:
|
||||
self._eps.start()
|
||||
except Exception as exc:
|
||||
self._log(VERBOSITY_NORMAL,
|
||||
f"EPS status generator failed to start: {exc}", level="warning")
|
||||
self._eps = None
|
||||
|
||||
# Upload static files (html + logo) once at startup
|
||||
if not local_only and self._uploader is not None:
|
||||
self._uploader.upload_dir_async(self._output_dir)
|
||||
@@ -811,6 +630,12 @@ class WebpageGeneratorBase:
|
||||
if self._local_server is not None:
|
||||
self._local_server.stop()
|
||||
self._local_server = None
|
||||
if self._eps is not None:
|
||||
try:
|
||||
self._eps.stop()
|
||||
except Exception:
|
||||
pass
|
||||
self._eps = None
|
||||
self._release_lock()
|
||||
self._local_only = False
|
||||
self._log(VERBOSITY_NORMAL, "WebpageGenerator stopped.")
|
||||
@@ -1572,12 +1397,23 @@ def _render_html(
|
||||
tomo_types: dict = None,
|
||||
tq_param_display: list = None,
|
||||
logo_filename: str | None = None,
|
||||
has_eps_status: bool = True,
|
||||
) -> str:
|
||||
# Falls back to a filename that will 404 (triggering the <img onerror>
|
||||
# text-logo fallback already in the template) when a setup has no logo --
|
||||
# e.g. WebpageGeneratorBase's own default _logo_path() returning None.
|
||||
logo_src = logo_filename or "logo.png"
|
||||
|
||||
# Forward link to the EPS/machine-status page (OMNY_shared/eps/), which
|
||||
# already links back to this page in its own header -- see
|
||||
# eps_status.html. Gated by HAS_EPS_STATUS the same way the tomo-queue
|
||||
# card is gated by HAS_TOMO_QUEUE, just below.
|
||||
eps_link_html = (
|
||||
'<a class="theme-btn" href="eps_status.html" '
|
||||
'title="Beamline / machine status">EPS</a>'
|
||||
if has_eps_status else ""
|
||||
)
|
||||
|
||||
phones_html = "\n".join(
|
||||
f' <div class="phone-row">'
|
||||
f'<span class="phone-label">{label}</span>'
|
||||
@@ -2138,6 +1974,7 @@ def _render_html(
|
||||
<span class="logo-account" id="active-account"></span>
|
||||
</div>
|
||||
<div class="header-right">
|
||||
{eps_link_html}
|
||||
<button class="theme-btn" id="help-btn" onclick="openHelp()" title="What does this page show?">?</button>
|
||||
<div class="theme-switcher">
|
||||
<span class="ts-label">Theme</span>
|
||||
|
||||
Reference in New Issue
Block a user