mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-29 09:23:16 -06:00
Compare commits
106 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9fb914b9e | ||
|
|
ce78d0dde4 | ||
|
|
f206537fed | ||
|
|
139d7d09fa | ||
|
|
852219cb45 | ||
|
|
15448d1c5d | ||
|
|
29a8896aa8 | ||
|
|
101f086204 | ||
|
|
ed3cf72b82 | ||
|
|
d90cfb3986 | ||
|
|
f96b65576f | ||
|
|
ed164647b8 | ||
|
|
ae018b8eea | ||
|
|
071d7cc9c3 | ||
|
|
1e1ed7e29a | ||
|
|
36c4bf523f | ||
|
|
16c381254b | ||
|
|
01e5b10f53 | ||
|
|
9c990ff91f | ||
|
|
a6fe849c67 | ||
|
|
0c0a8dd68a | ||
|
|
aa23cf02dd | ||
|
|
a9cf8747cb | ||
|
|
99cad902f2 | ||
|
|
0ebbd20968 | ||
|
|
184f07c272 | ||
|
|
a36880b147 | ||
|
|
fa3aed720f | ||
|
|
1f2e923005 | ||
|
|
7660d7433e | ||
|
|
89cbcac2cd | ||
|
|
0b138f10c6 | ||
|
|
5f83fc20d3 | ||
|
|
71f8948bdb | ||
|
|
118367837b | ||
|
|
618a7048c6 | ||
|
|
6f0e4a07f6 | ||
|
|
965920cd17 | ||
|
|
e2ff8f47b7 | ||
|
|
9c4b48800f | ||
|
|
0cd2a904e5 | ||
|
|
10726fde42 | ||
|
|
674a34ba45 | ||
|
|
5b13254de3 | ||
|
|
c5e8067b35 | ||
|
|
78a4ee4d37 | ||
|
|
7539603b17 | ||
|
|
647a7de1e6 | ||
|
|
d2feb2a3c5 | ||
|
|
5d24c30ea1 | ||
|
|
cbabbeb78c | ||
|
|
e6ebf2a380 | ||
|
|
1c748b8d3b | ||
|
|
d64244b5ac | ||
|
|
a21843818c | ||
|
|
fbd466b7f2 | ||
|
|
31be2ffe8c | ||
|
|
46e4bc8579 | ||
|
|
7f7dc80a8c | ||
|
|
ed45a9a23d | ||
|
|
02859eb1aa | ||
|
|
57d78dd1fa | ||
|
|
4c38536cc6 | ||
|
|
6ea8100aeb | ||
|
|
400a438fff | ||
|
|
c5550f5bc9 | ||
|
|
289d54f4fa | ||
|
|
c55ca60b56 | ||
|
|
986d94a778 | ||
|
|
1f207c4278 | ||
|
|
79c453cd43 | ||
|
|
efd4d7826c | ||
|
|
d59adfd10c | ||
|
|
6d152a5ccf | ||
|
|
40102b2b41 | ||
|
|
a59b53cc25 | ||
|
|
c8a24d2243 | ||
|
|
f4c5865013 | ||
|
|
728f03beba | ||
|
|
1d3e9acea4 | ||
|
|
c38d8dc0e6 | ||
|
|
c02086e305 | ||
|
|
e1cd8591bb | ||
|
|
1f794ff7b4 | ||
|
|
6add29bf6a | ||
|
|
5bcaca7c51 | ||
|
|
8d03741713 | ||
|
|
f06a87f4be | ||
|
|
3305e79e61 | ||
|
|
538f4a7b03 | ||
|
|
33eaef826c | ||
|
|
2ad9f54b8e | ||
|
|
c1b9c27f3a | ||
|
|
dce0d22763 | ||
|
|
fab4ae909a | ||
|
|
73ca3cf258 | ||
|
|
300d331562 | ||
|
|
11e339dd89 | ||
|
|
bd732f4c48 | ||
|
|
68367cb3a3 | ||
|
|
4b688fcfb1 | ||
|
|
ba8a71c4f5 | ||
|
|
22376710ee | ||
|
|
3ddc209d19 | ||
|
|
2ccf3264a9 | ||
|
|
c8e664e66c |
|
|
@ -11,6 +11,12 @@
|
|||
# instance never emits mail from a domain that isn't yours.
|
||||
SELF_HOSTED=true
|
||||
|
||||
# Hide the Subscription/billing UI (nav item + pricing cards) and bounce #/billing to
|
||||
# the dashboard. Opt-in; default off (billing shown). For instances that bill customers
|
||||
# externally and don't sell plans through the app. UI-only — does not change SELF_HOSTED
|
||||
# or disable any /api/subscription endpoints.
|
||||
HIDE_BILLING=true
|
||||
|
||||
# Close public self-service registration — for instances where all accounts are
|
||||
# provisioned by your team (admin "Add user" / invites). When true, the public
|
||||
# signup route is blocked (OAuth auto-signup with it) AND the login page hides
|
||||
|
|
|
|||
38
.github/workflows/ci.yml
vendored
38
.github/workflows/ci.yml
vendored
|
|
@ -33,6 +33,44 @@ jobs:
|
|||
- run: npm ci
|
||||
- run: npm test
|
||||
|
||||
openapi:
|
||||
name: OpenAPI spec lint
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: '20'
|
||||
- name: Lint the public API spec
|
||||
run: npx --yes @redocly/cli@latest lint docs/openapi.yaml
|
||||
# Contract integrity: the spec documents ONLY the token-reachable public surface.
|
||||
# A JWT-only router (admin/auth/provision/...) appearing here is a security flag,
|
||||
# not a convenience - fail loudly. (The runtime partition test is a separate suite
|
||||
# that will cross-check the spec against the live mount list.)
|
||||
- name: Assert spec is public-only
|
||||
run: |
|
||||
BAD=$(grep -oE '^ /(admin|auth|workspaces|ai|provision|white-label|status|subscription|stripe|teams|player-debug|contact|tokens)\b' docs/openapi.yaml || true)
|
||||
if [ -n "$BAD" ]; then echo "::error::JWT-only path(s) leaked into the public spec:"; echo "$BAD"; exit 1; fi
|
||||
if grep -qE 'unassigned|/prune' docs/openapi.yaml; then echo "::error::token-denied endpoint present in public spec"; exit 1; fi
|
||||
echo "OK: spec is public-only"
|
||||
|
||||
android-test:
|
||||
name: Android unit tests (Kotlin schedule evaluator vectors)
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: '17'
|
||||
- uses: android-actions/setup-android@v3
|
||||
# ScheduleEvalTest reads the SHARED shared/schedule-vectors.json (wired via
|
||||
# the test task in app/build.gradle.kts), so a ScheduleEval.kt change that
|
||||
# breaks the contract fails here.
|
||||
- name: Kotlin evaluator vector conformance
|
||||
working-directory: android
|
||||
run: ./gradlew :app:testDebugUnitTest --no-daemon
|
||||
|
||||
smoke:
|
||||
name: Boot smoke + version check
|
||||
runs-on: ubuntu-latest
|
||||
|
|
|
|||
35
.github/workflows/release.yml
vendored
35
.github/workflows/release.yml
vendored
|
|
@ -62,11 +62,15 @@ jobs:
|
|||
- name: Resolve version + previous tag
|
||||
id: ver
|
||||
run: |
|
||||
echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
|
||||
VERSION="$(cat VERSION)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "tag=${GITHUB_REF_NAME}" >> "$GITHUB_OUTPUT"
|
||||
PREV="$(git describe --tags --abbrev=0 "${GITHUB_REF_NAME}^" 2>/dev/null || true)"
|
||||
echo "prev=$PREV" >> "$GITHUB_OUTPUT"
|
||||
echo "Releasing ${GITHUB_REF_NAME} (version $(cat VERSION)); previous tag: ${PREV:-<none>}"
|
||||
# #80: a version carrying a -suffix (e.g. 1.9.0-rc1) is a pre-release.
|
||||
case "$VERSION" in *-*) PRE=true ;; *) PRE=false ;; esac
|
||||
echo "prerelease=$PRE" >> "$GITHUB_OUTPUT"
|
||||
echo "Releasing ${GITHUB_REF_NAME} (version $VERSION, prerelease=$PRE); previous tag: ${PREV:-<none>}"
|
||||
|
||||
- name: Build Tizen .wgt (unsigned in CI)
|
||||
run: |
|
||||
|
|
@ -83,7 +87,7 @@ jobs:
|
|||
--exclude='*.db' --exclude='*.db-wal' --exclude='*.db-shm' --exclude='*.db.*' \
|
||||
--exclude='server/uploads' --exclude='server/certs' --exclude='server/test' \
|
||||
--exclude='*.apk' \
|
||||
server frontend scripts VERSION README.md LICENSE .env.example ScreenTinker.wgt
|
||||
server frontend scripts docs VERSION README.md LICENSE .env.example ScreenTinker.wgt
|
||||
echo "TARBALL=$OUT" >> "$GITHUB_ENV"
|
||||
ls -la "$OUT"
|
||||
|
||||
|
|
@ -107,7 +111,11 @@ jobs:
|
|||
echo " Sign it with your own Samsung certificate (Tizen Studio + a profile that includes"
|
||||
echo " your TV's DUID) to install, or - easiest - point a Tizen TV browser / URL Launcher"
|
||||
echo " at \`https://<your-instance>/player\` (no signing needed)."
|
||||
if [ "${{ steps.ver.outputs.prerelease }}" = "true" ]; then
|
||||
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (pre-release - \`:latest\` is NOT moved)."
|
||||
else
|
||||
echo "- Docker image: \`ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}\` (also \`:latest\`)."
|
||||
fi
|
||||
echo "- \`ScreenTinker.apk\` - signed Android player (attached during release finalization)."
|
||||
} > RELEASE_NOTES.md
|
||||
cat RELEASE_NOTES.md
|
||||
|
|
@ -116,7 +124,12 @@ jobs:
|
|||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# #80: pre-release tags publish as a GitHub *pre-release* (not "Latest"),
|
||||
# which also keeps the /releases/latest API pointing at the last stable.
|
||||
PRERELEASE_FLAG=""
|
||||
[ "${{ steps.ver.outputs.prerelease }}" = "true" ] && PRERELEASE_FLAG="--prerelease"
|
||||
gh release create "${{ steps.ver.outputs.tag }}" \
|
||||
$PRERELEASE_FLAG \
|
||||
--title "ScreenTinker ${{ steps.ver.outputs.tag }}" \
|
||||
--notes-file RELEASE_NOTES.md \
|
||||
"${TARBALL}" \
|
||||
|
|
@ -129,7 +142,17 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- id: ver
|
||||
run: echo "version=$(cat VERSION)" >> "$GITHUB_OUTPUT"
|
||||
run: |
|
||||
VERSION="$(cat VERSION)"
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
# #80: move :latest only for final releases - a pre-release (1.9.0-rc1) must
|
||||
# not repoint :latest onto untested code (anyone on :latest pulls it on restart).
|
||||
TAGS="ghcr.io/screentinker/screentinker:$VERSION"
|
||||
case "$VERSION" in
|
||||
*-*) echo "Pre-release $VERSION: :latest will NOT be moved" ;;
|
||||
*) TAGS="${TAGS}"$'\n'"ghcr.io/screentinker/screentinker:latest" ;;
|
||||
esac
|
||||
{ echo "tags<<__EOF__"; printf '%s\n' "$TAGS"; echo "__EOF__"; } >> "$GITHUB_OUTPUT"
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
- uses: docker/setup-buildx-action@v3
|
||||
- uses: docker/login-action@v3
|
||||
|
|
@ -142,9 +165,7 @@ jobs:
|
|||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: |
|
||||
ghcr.io/screentinker/screentinker:${{ steps.ver.outputs.version }}
|
||||
ghcr.io/screentinker/screentinker:latest
|
||||
tags: ${{ steps.ver.outputs.tags }}
|
||||
|
||||
# TODO (deferred): build + sign the Android APK in CI. Requires the release
|
||||
# keystore + passwords as encrypted Actions secrets. For now the maintainer
|
||||
|
|
|
|||
136
CHANGELOG.md
Normal file
136
CHANGELOG.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Changelog
|
||||
|
||||
## 1.9.2-beta1 — unreleased
|
||||
|
||||
### Fixed — server resilience (#142)
|
||||
- **A single flapping device can no longer saturate the event loop.** A new
|
||||
load-aware, per-device reconnect throttle (`lib/reconnect-throttle.js`) gates
|
||||
genuine reconnects *before* the heavy register work (DB writes + playlist build).
|
||||
The verdict is per-device; global event-loop lag only multiplies an
|
||||
already-flagged device's backoff and never throttles a healthy one. Hard ceiling
|
||||
+ cold-start warm-up so a full-fleet reconnect after a deploy is never throttled.
|
||||
- **`device_status_log` growth is bounded.** Added
|
||||
`idx_device_status_log_device_ts`, a global retention sweep (`pruneStatusLog`,
|
||||
`STATUS_LOG_RETENTION_DAYS` default 3) covering removed/idle devices and the
|
||||
`offline_timeout` path, and de-duplicated the table's `CREATE TABLE`.
|
||||
- **`content-ack` spam de-duplicated.** Repeated identical
|
||||
`(device_id, content_id, status)` reports are suppressed within
|
||||
`CONTENT_ACK_DEDUP_MS` (default 10s).
|
||||
- **Provisioning cleanup window corrected.** Unclaimed provisioning devices are now
|
||||
swept after 24h (the code used `365 * 86400` — a year — contradicting its own
|
||||
comment).
|
||||
|
||||
### Added — observability (#142)
|
||||
- **Event-loop lag telemetry** via `perf_hooks.monitorEventLoopDelay()`. Sampled to
|
||||
a bounded `event_loop_lag` table (indexed + pruned, `LAG_TELEMETRY_RETENTION_DAYS`)
|
||||
and surfaced on `/api/status` as `loop_lag` (mean/p50/p99/max + band).
|
||||
|
||||
### Maintenance
|
||||
- Operators whose `device_status_log` is already bloated from a pre-1.9.2 deployment
|
||||
should reclaim disk with a **one-time manual `VACUUM`** in a maintenance window;
|
||||
retention now bounds further growth. Auto-VACUUM is intentionally not enabled.
|
||||
See [`docs/maintenance-device-status-log.md`](docs/maintenance-device-status-log.md).
|
||||
|
||||
## 1.9.1-beta3 — unreleased
|
||||
|
||||
### Fixed — Tizen player
|
||||
- **#118 Sticky "Not authenticated" banner.** On TV sleep/wake the socket reconnects and
|
||||
a heartbeat could fire on the fresh, not-yet-registered socket; the server rejected it
|
||||
with `device:auth-error`, which the player showed as a *sticky* toast over still-playing
|
||||
content (and, worse, dropped its saved credentials and re-paired). Heartbeats are now
|
||||
gated on a per-connection `authenticated` flag (set only between `device:registered` and
|
||||
`disconnect`/`auth-error`), the heartbeat timer is stopped on `connect`/`disconnect`/
|
||||
`auth-error`, the stale banner is cleared on `device:registered`, and the `auth-error`
|
||||
toast is non-sticky so any transient case self-clears.
|
||||
- **#119 `app_version` stuck at `1.0.0`.** The hardcoded constant made every Tizen device
|
||||
report `1.0.0` regardless of the installed `.wgt`. The version now resolves at runtime
|
||||
from `config.xml` via the Tizen application API, with a fallback constant that
|
||||
`build-wgt.sh` stamps from `config.xml`'s `version=""`.
|
||||
|
||||
### Added — Tizen player
|
||||
- **Video walls (`wall:sync`).** The Tizen player now supports wall membership: when the
|
||||
payload carries `wall_config`, a new `WallController` positions the stage (vw/vh) as this
|
||||
screen's slice of the wall and drives the single-zone player as leader or follower. The
|
||||
leader broadcasts `wall:sync` at 4Hz; followers align their index and keep their video
|
||||
locked to the leader's clock with a latency-compensated drift controller (hard-seek past
|
||||
0.3s, gentle ±3% playbackRate nudge past 0.05s), and request an immediate position on
|
||||
(re)connect via `wall:sync-request`. Mirrors the web player (the Android player has no
|
||||
wall support). Per-tile `rotation` is not applied yet (web-player parity). Wall emits are
|
||||
gated on auth + connection so a pre-register tick can't trip `device:auth-error`.
|
||||
- **Multi-zone layouts (Android parity).** The Tizen player now renders assigned layouts,
|
||||
not just fullscreen single-zone. A new `ZoneRenderer` (ports the Android `ZoneManager`)
|
||||
positions zones by percent geometry with `z_index`/`fit_mode`/background, groups
|
||||
assignments by `zone_id` (unassigned content goes to the first zone), and rotates each
|
||||
zone independently with the same per-item schedule gating (#74/#75). `app.js` selects the
|
||||
renderer from `payload.layout`; single-zone playback is unchanged. (Video walls
|
||||
`wall:sync` are still Android-only.)
|
||||
- **#121 Remote commands.** Added a `device:command` handler (`refresh`, `launch`,
|
||||
`screen_on`, `screen_off`, plus honest no-op toasts for `update`/`reboot`/`shutdown`,
|
||||
which need B2B/MDM privileges a sideloaded app lacks). Removed the dead `device:reload`
|
||||
listener (the server never emitted it) in favour of `device:command` `refresh`.
|
||||
- **#120 Dashboard preview.** Added `device:screenshot-request` / `device:remote-start` /
|
||||
`device:remote-stop`. Images capture for real; `<video>`/YouTube fall back to a status
|
||||
card because the TV's hardware video plane and cross-origin iframes can't be read into a
|
||||
`<canvas>`. See `tizen/README.md` for the support matrix.
|
||||
- **#122 Updates / boot.** Documented the supported paths — `.wgt` re-sideload or URL
|
||||
Launcher/MDM refresh for updates, and display-level kiosk/URL-Launcher settings for
|
||||
auto-launch on boot (there is no in-app OTA or `config.xml` autostart for a sideloaded
|
||||
consumer TV web app).
|
||||
|
||||
## 1.9.0 — 2026-06-11
|
||||
|
||||
### Added
|
||||
- **Per-playlist-item schedules.** Each playlist item can carry one or more schedule
|
||||
blocks — active days, a start/end time-of-day, and optional start/end dates. An item
|
||||
plays when the screen's local "now" matches at least one block; an item with no
|
||||
blocks always plays. Edit per item via the clock icon in the playlist editor (a badge
|
||||
summarises the schedule on each row).
|
||||
- **#74 dayparting:** time-of-day + day-of-week windows, including overnight windows
|
||||
that cross midnight (a Fri 22:00–02:00 block is active Sat 01:00).
|
||||
- **#75 auto-expire:** inclusive start/end dates; an item past its end date stops
|
||||
showing automatically — even on offline screens, because evaluation is on-device.
|
||||
- All three players (web, Android, Tizen) evaluate schedules client-side against their
|
||||
own clock, so dayparting and expiry work offline. They share one evaluator contract,
|
||||
`shared/schedule-vectors.json` — 39 conformance vectors covering DST (US + AU),
|
||||
overnight-wrap day anchoring, timezone correctness, and date boundaries. CI runs the
|
||||
vectors against the JS evaluator (node) and the Kotlin port (Gradle/JUnit); the Tizen
|
||||
copy is byte-identical to the JS source and checked under node.
|
||||
- Device detail now shows the screen's reported timezone and clock, with a **clock-skew
|
||||
warning** when the device clock differs from the server by more than 2 minutes (a bad
|
||||
device clock makes schedules fire at the wrong local time).
|
||||
|
||||
### Changed — device-level schedule timezone (behaviour change)
|
||||
- Device/group **schedule overrides** (the existing calendar feature) are now evaluated
|
||||
in each device's effective timezone instead of the server's local time. Previously the
|
||||
`schedules.timezone` field was never applied and "07:00" meant the *server's* 07:00.
|
||||
Now "07:00" means the *screen's* 07:00 — which is what was intended.
|
||||
- **Who is affected:** self-hosters whose server timezone differs from their screens'
|
||||
timezone — their existing device schedules will shift to fire at the screens' local
|
||||
time. Single-timezone deployments (server and screens in the same zone) are
|
||||
unaffected. A device with no timezone set and not reporting one falls back to the
|
||||
server clock (unchanged from before).
|
||||
|
||||
### Fixed
|
||||
- **#81 — release APK is now v1 + v2 + v3 signed.** With `minSdk 26`, the Android Gradle
|
||||
Plugin defaulted the v1 (JAR) signature *off*, producing a v2-only APK that some
|
||||
MDM-managed commercial signage (e.g. MAXHUB via the Pivot MDM) silently removes on the
|
||||
next reboot — so screens that power-cycle nightly lost the app and fell back to the
|
||||
setup screen. Setting `enableV1Signing = true` had no effect at minSdk ≥ 24; the release
|
||||
build now re-signs with `apksigner` and a low `--min-sdk-version` to emit the JAR
|
||||
signature alongside v2/v3. Verified to install and run on Android 14+/API 36 as well.
|
||||
|
||||
### Notes
|
||||
- **Scheduling fails open.** If the on-device evaluator ever errors (bad timezone id,
|
||||
malformed block), the item **plays** rather than being hidden. A blank screen is worse
|
||||
than an over-running promo — this is a guarantee, enforced in all three players.
|
||||
- Windows are enforced at **item boundaries**: a long item finishes before the schedule
|
||||
is re-checked, so it can overshoot its window by up to its own duration.
|
||||
- **A single video *with a schedule* now re-renders at each loop boundary** so its window
|
||||
can be re-evaluated; seamless native looping still applies to unscheduled single videos.
|
||||
Deliberate tradeoff — a brief seam each loop for a scheduled lone video, in exchange for
|
||||
its daypart/expiry actually being honoured.
|
||||
- **Re-publish required:** editing a schedule puts the playlist into draft; publish to
|
||||
push schedules to devices. Existing published playlists keep playing unchanged until
|
||||
re-published.
|
||||
- Players that predate this release ignore the new fields and keep playing everything
|
||||
(graceful degradation) — update players to honour schedules.
|
||||
|
|
@ -30,6 +30,9 @@ COPY server/ /app/server/
|
|||
COPY --from=builder /app/server/node_modules /app/server/node_modules
|
||||
COPY frontend/ /app/frontend/
|
||||
COPY VERSION /app/VERSION
|
||||
# the /openapi.yaml route serves ../docs/openapi.yaml (the spec Redoc on /docs fetches);
|
||||
# without this it 404s in the image even though it serves fine from a dev checkout.
|
||||
COPY docs/openapi.yaml /app/docs/openapi.yaml
|
||||
# database.js requires scripts/migrate-multitenancy at boot
|
||||
COPY scripts/ /app/scripts/
|
||||
VOLUME ["/data"]
|
||||
|
|
|
|||
3
Examples/PIP-Air-Quality/.gitignore
vendored
Normal file
3
Examples/PIP-Air-Quality/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
89
Examples/PIP-Air-Quality/README.md
Normal file
89
Examples/PIP-Air-Quality/README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# PiP Air-Quality Widget
|
||||
|
||||
A persistent corner **air-quality widget** for ScreenTinker screens, driven by the
|
||||
**[Open-Meteo Air Quality API](https://open-meteo.com/en/docs/air-quality-api)** — no API key,
|
||||
no signup. Shows the current **US AQI** (color-coded by EPA band) plus the component
|
||||
pollutants (PM2.5 / PM10 / O₃ / NO₂) and refreshes itself in place.
|
||||
|
||||
```
|
||||
Open-Meteo Air Quality ──poll──▶ aqi.js ──POST /api/pip──▶ ScreenTinker ──ws──▶ player
|
||||
(us_aqi, pm2.5, …) (normalise + color) (web overlay) (corner widget)
|
||||
```
|
||||
|
||||
It pushes a `type: web` overlay with `duration: 0` (stays up until cleared) and re-pushes
|
||||
each poll; the player keeps a single overlay slot (last-show-wins) so the widget just updates.
|
||||
On `Ctrl-C` it clears the overlay.
|
||||
|
||||
## How it works
|
||||
|
||||
- **`aqi.js`** — polls Open-Meteo, normalises the response, maps the US AQI to an EPA category
|
||||
+ color, and pushes/refreshes the overlay. Pure helpers (`aqiCategory`, `normalise`,
|
||||
`aqiUrl`, `overlayUri`) are exported for the test.
|
||||
- **`aqi-overlay.html` + `aqi-overlay.js`** — the overlay page rendered in the player's iframe.
|
||||
All data comes from the URL query string; the JS is external (no inline script) so it passes
|
||||
the signage server's CSP (`scriptSrc 'self'`).
|
||||
|
||||
### US EPA AQI bands
|
||||
|
||||
| US AQI | Category | Color |
|
||||
|---|---|---|
|
||||
| 0–50 | Good | `#1f9d55` |
|
||||
| 51–100 | Moderate | `#F2C200` |
|
||||
| 101–150 | Unhealthy (Sensitive) | `#E8730C` |
|
||||
| 151–200 | Unhealthy | `#CC0000` |
|
||||
| 201–300 | Very Unhealthy | `#7B0000` |
|
||||
| 301+ | Hazardous | `#5B0000` |
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Host the overlay page.** Copy both `aqi-overlay.html` and `aqi-overlay.js` into your
|
||||
signage server's frontend directory so they're served same-origin as the player (required by
|
||||
the CSP). They'll be reachable at `https://<your-server>/aqi-overlay.html`.
|
||||
|
||||
2. **Get a `full`-scope API token** (`st_…`) from the dashboard.
|
||||
|
||||
3. **Configure.** Copy `config.example.json` → `config.json` and fill in:
|
||||
- `api_base` — your ScreenTinker server, e.g. `https://signage.example.com`
|
||||
- `api_token` — the `st_…` token
|
||||
- `overlay_base_url` — `https://<your-server>/aqi-overlay.html`
|
||||
- `device_id` — a device **or** group id
|
||||
- `lat` / `lon` / `location_name` — the location to report
|
||||
- optional: `poll_interval_sec` (default 900), `position` (default `top-right`),
|
||||
`width`/`height`, `border_radius`
|
||||
|
||||
4. **Run:**
|
||||
```bash
|
||||
node aqi.js
|
||||
```
|
||||
Leave it running; it refreshes every `poll_interval_sec`. `Ctrl-C` clears the overlay.
|
||||
|
||||
## Test (offline, no network)
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
Checks the EPA band boundaries, the category→color map, and the normaliser against
|
||||
`fixture-aqi.json`. Prints `RESULT: PASS ✅`.
|
||||
|
||||
## Local quick-start (this machine)
|
||||
|
||||
The local dev instance serves the player over self-signed HTTPS, so disable TLS verification:
|
||||
|
||||
```bash
|
||||
# 1. copy the overlay assets into the local server's frontend dir, e.g.:
|
||||
cp aqi-overlay.html aqi-overlay.js /home/owner/Downloads/remote_display/frontend/
|
||||
|
||||
# 2. config.json for the local "testing" player:
|
||||
# api_base https://localhost:3443/
|
||||
# api_token st_REPLACE_WITH_A_FULL_SCOPE_TOKEN
|
||||
# overlay_base_url https://localhost:3443/aqi-overlay.html
|
||||
# device_id DEVICE_OR_GROUP_ID
|
||||
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node aqi.js
|
||||
```
|
||||
|
||||
## Notes
|
||||
|
||||
- Open-Meteo's `us_aqi` is the **overall** US AQI (max of the per-pollutant sub-indices).
|
||||
- The free Open-Meteo API is rate-limited; a 900s (15 min) poll is plenty for air quality.
|
||||
- `config.json` is gitignored (it holds your token).
|
||||
44
Examples/PIP-Air-Quality/aqi-overlay.html
Normal file
44
Examples/PIP-Air-Quality/aqi-overlay.html
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Air Quality</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45);
|
||||
padding: 14px 18px; box-sizing: border-box; border-left: 8px solid #888; }
|
||||
.top { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||
.loc { font-size: clamp(13px, 3.2vw, 18px); font-weight: 600; opacity: .92;
|
||||
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.badge { font-size: clamp(11px, 2.6vw, 15px); font-weight: 800; text-transform: uppercase;
|
||||
letter-spacing: .03em; padding: 3px 9px; border-radius: 999px; color: #111; white-space: nowrap; }
|
||||
.mid { display: flex; align-items: baseline; gap: 10px; margin-top: 2px; }
|
||||
.aqi { font-size: clamp(40px, 13vw, 68px); font-weight: 800; line-height: 1; }
|
||||
.aqilabel { font-size: clamp(11px, 2.6vw, 14px); font-weight: 700; opacity: .7; }
|
||||
.cat { font-size: clamp(13px, 3.2vw, 18px); font-weight: 700; }
|
||||
.grid { margin-top: auto; display: flex; flex-wrap: wrap; gap: 4px 16px;
|
||||
font-size: clamp(11px, 2.4vw, 14px); opacity: .85; padding-top: 8px; }
|
||||
.grid b { font-weight: 700; opacity: 1; }
|
||||
.updated { opacity: .7; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" id="card">
|
||||
<div class="top">
|
||||
<span class="loc" id="loc"></span>
|
||||
<span class="badge" id="badge"></span>
|
||||
</div>
|
||||
<div class="mid">
|
||||
<span class="aqi" id="aqi"></span>
|
||||
<span class="aqilabel">US AQI</span>
|
||||
</div>
|
||||
<div class="cat" id="cat"></div>
|
||||
<div class="grid" id="grid"></div>
|
||||
<div class="grid"><span class="updated" id="updated"></span></div>
|
||||
</div>
|
||||
<script src="aqi-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
38
Examples/PIP-Air-Quality/aqi-overlay.js
Normal file
38
Examples/PIP-Air-Quality/aqi-overlay.js
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads the air-quality fields from the URL query string and populates the widget.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||
var set = function (id, txt) { var el = document.getElementById(id); if (el) el.textContent = txt; };
|
||||
|
||||
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || '888888');
|
||||
|
||||
set('loc', get('location') || 'Air Quality');
|
||||
set('aqi', get('aqi') !== '' ? get('aqi') : '--');
|
||||
set('cat', get('category') || '');
|
||||
|
||||
// Category color drives the AQI number, the left accent, and a pill badge.
|
||||
document.getElementById('aqi').style.color = color;
|
||||
document.getElementById('card').style.borderLeftColor = color;
|
||||
var badge = document.getElementById('badge');
|
||||
if (get('category')) { badge.textContent = get('category'); badge.style.background = color; }
|
||||
|
||||
var parts = [];
|
||||
if (get('pm25') !== '') parts.push('<b>PM2.5</b> ' + esc(get('pm25')));
|
||||
if (get('pm10') !== '') parts.push('<b>PM10</b> ' + esc(get('pm10')));
|
||||
if (get('ozone') !== '') parts.push('<b>O₃</b> ' + esc(get('ozone')));
|
||||
if (get('no2') !== '') parts.push('<b>NO₂</b> ' + esc(get('no2')));
|
||||
document.getElementById('grid').innerHTML = parts.join('');
|
||||
|
||||
var updated = get('updated');
|
||||
if (updated) {
|
||||
var d = new Date(updated);
|
||||
set('updated', isNaN(d) ? ('· ' + updated) : ('· updated ' + d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })));
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return s.replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
})();
|
||||
149
Examples/PIP-Air-Quality/aqi.js
Normal file
149
Examples/PIP-Air-Quality/aqi.js
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
'use strict';
|
||||
|
||||
// Open-Meteo Air Quality -> ScreenTinker PiP air-quality widget.
|
||||
//
|
||||
// Polls air-quality-api.open-meteo.com (NO API KEY) for the current US AQI plus the
|
||||
// component pollutants, and pushes a small persistent web overlay to a screen (or group).
|
||||
// Re-pushes on each poll; the player keeps a single overlay slot (last-show-wins), so the
|
||||
// widget updates in place. Pushed with duration 0 (stays until cleared). Clears on exit.
|
||||
//
|
||||
// node aqi.js [path/to/config.json]
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// US EPA AQI bands -> { label, color }. Boundaries are inclusive of the upper value
|
||||
// (0-50 Good, 51-100 Moderate, ...). 301+ is Hazardous.
|
||||
function aqiCategory(aqi) {
|
||||
const n = Number(aqi);
|
||||
if (!Number.isFinite(n)) return { label: 'Unknown', color: '#888888' };
|
||||
if (n <= 50) return { label: 'Good', color: '#1f9d55' };
|
||||
if (n <= 100) return { label: 'Moderate', color: '#F2C200' };
|
||||
if (n <= 150) return { label: 'Unhealthy (Sensitive)', color: '#E8730C' };
|
||||
if (n <= 200) return { label: 'Unhealthy', color: '#CC0000' };
|
||||
if (n <= 300) return { label: 'Very Unhealthy', color: '#7B0000' };
|
||||
return { label: 'Hazardous', color: '#5B0000' };
|
||||
}
|
||||
|
||||
// Pure normaliser: Open-Meteo air-quality JSON -> the overlay's display view.
|
||||
function normalise(data, cfg = {}) {
|
||||
const cur = (data && data.current) || {};
|
||||
const round = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Math.round(Number(v)));
|
||||
const usAqi = round(cur.us_aqi);
|
||||
const cat = aqiCategory(usAqi);
|
||||
return {
|
||||
location: cfg.location_name || 'Air Quality',
|
||||
usAqi,
|
||||
category: cat.label,
|
||||
color: cat.color,
|
||||
pm25: round(cur.pm2_5),
|
||||
pm10: round(cur.pm10),
|
||||
ozone: round(cur.ozone),
|
||||
no2: round(cur.nitrogen_dioxide),
|
||||
updated: cur.time || '',
|
||||
};
|
||||
}
|
||||
|
||||
function aqiUrl(cfg) {
|
||||
const q = new URLSearchParams({
|
||||
latitude: String(cfg.lat),
|
||||
longitude: String(cfg.lon),
|
||||
current: 'us_aqi,pm2_5,pm10,ozone,nitrogen_dioxide',
|
||||
timezone: 'auto',
|
||||
});
|
||||
return `https://air-quality-api.open-meteo.com/v1/air-quality?${q.toString()}`;
|
||||
}
|
||||
|
||||
function overlayUri(base, view) {
|
||||
const q = new URLSearchParams({
|
||||
location: view.location || '',
|
||||
aqi: view.usAqi == null ? '' : String(view.usAqi),
|
||||
category: view.category || '',
|
||||
color: (view.color || '#888888').replace(/[^0-9a-fA-F]/g, ''),
|
||||
pm25: view.pm25 == null ? '' : String(view.pm25),
|
||||
pm10: view.pm10 == null ? '' : String(view.pm10),
|
||||
ozone: view.ozone == null ? '' : String(view.ozone),
|
||||
no2: view.no2 == null ? '' : String(view.no2),
|
||||
updated: view.updated || '',
|
||||
});
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
module.exports = { aqiCategory, normalise, aqiUrl, overlayUri };
|
||||
|
||||
// ---- live runner (skipped when this file is require()'d by the test) ----
|
||||
if (require.main === module) {
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const API_TOKEN = cfg.api_token;
|
||||
const OVERLAY_BASE = cfg.overlay_base_url;
|
||||
const DEVICE = cfg.device_id;
|
||||
const POLL_SEC = cfg.poll_interval_sec || 900;
|
||||
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE || cfg.lat == null || cfg.lon == null) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, device_id, lat, lon.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let pipId = null;
|
||||
|
||||
async function pipShow(view) {
|
||||
const body = {
|
||||
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, view),
|
||||
position: cfg.position || 'top-right',
|
||||
width: cfg.width || 360, height: cfg.height || 200,
|
||||
duration: 0, opacity: cfg.opacity != null ? cfg.opacity : 1,
|
||||
border_radius: cfg.border_radius != null ? cfg.border_radius : 16,
|
||||
close_button: false,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
return json.pip_id;
|
||||
}
|
||||
|
||||
async function pipClear() {
|
||||
if (!pipId) return;
|
||||
await fetch(`${API_BASE}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const res = await fetch(aqiUrl(cfg), { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`Open-Meteo HTTP ${res.status}`);
|
||||
const view = normalise(await res.json(), cfg);
|
||||
pipId = await pipShow(view);
|
||||
console.log(`[${new Date().toISOString()}] ${view.location}: AQI ${view.usAqi} (${view.category}) pm2.5=${view.pm25} pm10=${view.pm10} pip=${pipId}`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] update error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Air-Quality PiP widget — ${cfg.location_name || `${cfg.lat},${cfg.lon}`}, every ${POLL_SEC}s, ${cfg.position || 'top-right'}`);
|
||||
await tick();
|
||||
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||
async function shutdown() {
|
||||
clearInterval(timer);
|
||||
console.log('\nclearing overlay before exit...');
|
||||
await pipClear();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
main();
|
||||
}
|
||||
16
Examples/PIP-Air-Quality/config.example.json
Normal file
16
Examples/PIP-Air-Quality/config.example.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/aqi-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"lat": 45.5152,
|
||||
"lon": -122.6784,
|
||||
"location_name": "Portland, OR",
|
||||
|
||||
"poll_interval_sec": 900,
|
||||
"position": "top-right",
|
||||
"width": 360,
|
||||
"height": 200,
|
||||
"border_radius": 16
|
||||
}
|
||||
21
Examples/PIP-Air-Quality/fixture-aqi.json
Normal file
21
Examples/PIP-Air-Quality/fixture-aqi.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"latitude": 45.5,
|
||||
"longitude": -122.5,
|
||||
"timezone": "America/Los_Angeles",
|
||||
"current_units": {
|
||||
"time": "iso8601",
|
||||
"us_aqi": "USAQI",
|
||||
"pm2_5": "μg/m³",
|
||||
"pm10": "μg/m³",
|
||||
"ozone": "μg/m³",
|
||||
"nitrogen_dioxide": "μg/m³"
|
||||
},
|
||||
"current": {
|
||||
"time": "2026-06-18T10:00",
|
||||
"us_aqi": 72,
|
||||
"pm2_5": 23.4,
|
||||
"pm10": 31.2,
|
||||
"ozone": 88.0,
|
||||
"nitrogen_dioxide": 12.4
|
||||
}
|
||||
}
|
||||
12
Examples/PIP-Air-Quality/package.json
Normal file
12
Examples/PIP-Air-Quality/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-air-quality",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: a persistent ScreenTinker PiP air-quality widget driven by the keyless Open-Meteo Air Quality API.",
|
||||
"type": "commonjs",
|
||||
"main": "aqi.js",
|
||||
"scripts": {
|
||||
"start": "node aqi.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
58
Examples/PIP-Air-Quality/test.js
Normal file
58
Examples/PIP-Air-Quality/test.js
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
'use strict';
|
||||
|
||||
// Offline test: US EPA AQI band boundaries + the Open-Meteo normaliser, against
|
||||
// fixture-aqi.json. No network, no API token. Prints "RESULT: PASS ✅", exits 0 on success.
|
||||
|
||||
const fs = require('fs');
|
||||
const a = require('./aqi');
|
||||
|
||||
const data = JSON.parse(fs.readFileSync('./fixture-aqi.json', 'utf8'));
|
||||
const view = a.normalise(data, { location_name: 'Portland, OR' });
|
||||
|
||||
console.log('normalised view:');
|
||||
console.log(view);
|
||||
|
||||
console.log('\n--- AQI band boundaries ---');
|
||||
const bands = [
|
||||
[0, 'Good'], [50, 'Good'], [51, 'Moderate'], [100, 'Moderate'],
|
||||
[101, 'Unhealthy (Sensitive)'], [150, 'Unhealthy (Sensitive)'],
|
||||
[151, 'Unhealthy'], [200, 'Unhealthy'],
|
||||
[201, 'Very Unhealthy'], [300, 'Very Unhealthy'], [301, 'Hazardous'], [500, 'Hazardous'],
|
||||
];
|
||||
for (const [n, label] of bands) console.log(`${String(n).padStart(3)} -> ${a.aqiCategory(n).label}`);
|
||||
|
||||
const checks = {
|
||||
'0 -> Good': a.aqiCategory(0).label === 'Good',
|
||||
'50 -> Good (upper bound)': a.aqiCategory(50).label === 'Good',
|
||||
'51 -> Moderate': a.aqiCategory(51).label === 'Moderate',
|
||||
'100 -> Moderate (upper bound)': a.aqiCategory(100).label === 'Moderate',
|
||||
'101 -> Unhealthy (Sensitive)': a.aqiCategory(101).label === 'Unhealthy (Sensitive)',
|
||||
'150 -> Unhealthy (Sensitive) (upper bound)': a.aqiCategory(150).label === 'Unhealthy (Sensitive)',
|
||||
'200 -> Unhealthy (upper bound)': a.aqiCategory(200).label === 'Unhealthy',
|
||||
'201 -> Very Unhealthy': a.aqiCategory(201).label === 'Very Unhealthy',
|
||||
'301 -> Hazardous': a.aqiCategory(301).label === 'Hazardous',
|
||||
'Good color': a.aqiCategory(25).color === '#1f9d55',
|
||||
'Moderate color': a.aqiCategory(72).color === '#F2C200',
|
||||
'Hazardous color': a.aqiCategory(400).color === '#5B0000',
|
||||
'unknown AQI falls back': a.aqiCategory(undefined).label === 'Unknown',
|
||||
|
||||
'usAqi from fixture': view.usAqi === 72,
|
||||
'category from fixture': view.category === 'Moderate',
|
||||
'color matches category': view.color === '#F2C200',
|
||||
'pm25 rounded': view.pm25 === 23,
|
||||
'pm10 rounded': view.pm10 === 31,
|
||||
'ozone rounded': view.ozone === 88,
|
||||
'no2 rounded': view.no2 === 12,
|
||||
'location passthrough': view.location === 'Portland, OR',
|
||||
'updated passthrough': view.updated === '2026-06-18T10:00',
|
||||
};
|
||||
|
||||
console.log('\n--- assertions ---');
|
||||
let ok = true;
|
||||
for (const [name, pass] of Object.entries(checks)) {
|
||||
console.log(`${pass ? '✓' : '✗'} ${name}`);
|
||||
if (!pass) ok = false;
|
||||
}
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
3
Examples/PIP-Announce-Broadcast/.gitignore
vendored
Normal file
3
Examples/PIP-Announce-Broadcast/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
89
Examples/PIP-Announce-Broadcast/README.md
Normal file
89
Examples/PIP-Announce-Broadcast/README.md
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
# PiP Announce / Broadcast
|
||||
|
||||
Flash a one-off text announcement onto a ScreenTinker screen (or a whole group) using
|
||||
the **PiP overlay API**, then clear it whenever you like. Good for fire drills, "back in
|
||||
5 minutes", shift changes, a quick "Welcome, visitors!", or any manual broadcast.
|
||||
|
||||
It pushes a `web` overlay that renders a small dark card (optional coloured title band +
|
||||
big message + a "posted" timestamp). The overlay page reads everything from its URL query
|
||||
string, so there's no server-side state — the message lives entirely in the pushed URL.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
announce.js ──POST /api/pip──▶ server ──WS device:pip-show──▶ player
|
||||
renders <iframe
|
||||
src=message-overlay.html?title&message&color>
|
||||
```
|
||||
|
||||
- `announce.js` builds an overlay URL from `overlay_base_url` + `?title&message&color` and
|
||||
POSTs it to `/api/pip` (`type: "web"`).
|
||||
- The player loads that URL in an iframe overlay. Because the player enforces a strict CSP
|
||||
(`script-src 'self'`), the overlay HTML loads its JS via `<script src="message-overlay.js">`
|
||||
(no inline scripts) and the JS reads the query string.
|
||||
- `duration` controls auto-dismiss: `0` (default) keeps it up until you clear it; any
|
||||
positive value (seconds) auto-clears on the player at that time.
|
||||
|
||||
## Setup
|
||||
|
||||
You need an `st_` API token with the **`full`** scope (PiP is fleet-affecting).
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# edit config.json: api_base, api_token, overlay_base_url, device_id
|
||||
```
|
||||
|
||||
The overlay page is served by the signage server as a **same-origin** static file. Copy the
|
||||
two overlay files into the server's frontend directory and point `overlay_base_url` at them:
|
||||
|
||||
```bash
|
||||
# from the repo root, into the served frontend dir:
|
||||
cp Examples/PIP-Announce-Broadcast/message-overlay.html frontend/
|
||||
cp Examples/PIP-Announce-Broadcast/message-overlay.js frontend/
|
||||
# then in config.json: "overlay_base_url": "https://<your-server>/message-overlay.html"
|
||||
```
|
||||
|
||||
Same-origin matters: the player iframe and the overlay must share the server's origin so
|
||||
the self-signed cert / CSP are honoured.
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# basic broadcast (stays until cleared)
|
||||
node announce.js "Fire drill at 2:00 PM"
|
||||
|
||||
# with a coloured title band, auto-clear after 60s, centered
|
||||
node announce.js "Back in 5 minutes" --title "AT LUNCH" --duration 60 --color "#E8730C" --position center
|
||||
|
||||
# target a specific device or a group (overrides config device_id)
|
||||
node announce.js "All-hands in the atrium" --group <GROUP_ID>
|
||||
|
||||
# clear it
|
||||
node announce.js --clear --device <DEVICE_ID> --pip <PIP_ID>
|
||||
# (omit --pip to clear whatever overlay is showing)
|
||||
```
|
||||
|
||||
Flags: `--title`, `--device`, `--group`, `--duration` (sec), `--color` (#RRGGBB),
|
||||
`--position` (`top-right|top-left|bottom-right|bottom-left|center`), `--config`, `--clear`, `--pip`.
|
||||
|
||||
## Local quick-start (this dev box)
|
||||
|
||||
A web player is already running and paired:
|
||||
|
||||
- `api_base`: `https://localhost:3443/` (self-signed — prefix commands with `NODE_TLS_REJECT_UNAUTHORIZED=0`)
|
||||
- `device_id`: `DEVICE_OR_GROUP_ID`
|
||||
- token: `st_REPLACE_WITH_A_FULL_SCOPE_TOKEN`
|
||||
|
||||
```bash
|
||||
cp Examples/PIP-Announce-Broadcast/message-overlay.html frontend/
|
||||
cp Examples/PIP-Announce-Broadcast/message-overlay.js frontend/
|
||||
cd Examples/PIP-Announce-Broadcast
|
||||
# config.json with the values above and overlay_base_url=https://localhost:3443/message-overlay.html
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node announce.js "Hello from PiP" --title TEST --duration 20
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # offline; exercises the URL builder and arg parser
|
||||
```
|
||||
141
Examples/PIP-Announce-Broadcast/announce.js
Normal file
141
Examples/PIP-Announce-Broadcast/announce.js
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
'use strict';
|
||||
|
||||
// PIP-Announce-Broadcast — flash a one-off announcement onto a ScreenTinker screen
|
||||
// or group via the PiP overlay API, then clear it on demand.
|
||||
//
|
||||
// node announce.js "Fire drill at 2:00 PM" [--title "NOTICE"]
|
||||
// [--device <id> | --group <id>] [--duration 60] [--color "#CC0000"]
|
||||
// [--position center] [--config config.json]
|
||||
// node announce.js --clear [--device <id>] [--pip <pip_id>]
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const POSITIONS = ['top-right', 'top-left', 'bottom-right', 'bottom-left', 'center'];
|
||||
|
||||
// --- pure helpers (exported for the offline test) -------------------------
|
||||
|
||||
// Sanitise a colour to exactly 6 hex digits (no '#'); fall back to CC0000.
|
||||
function sanitizeColor(c) {
|
||||
const hex = String(c || '').replace(/[^0-9a-fA-F]/g, '');
|
||||
return hex.length === 6 ? hex : 'CC0000';
|
||||
}
|
||||
|
||||
// Build the overlay iframe URL: overlay_base_url + ?title&message&color.
|
||||
// Color is sanitised to 6 hex; everything is URL-encoded by URLSearchParams.
|
||||
function buildOverlayUri(base, { title = '', message = '', color = '' } = {}) {
|
||||
const q = new URLSearchParams({
|
||||
title: title || '',
|
||||
message: message || '',
|
||||
color: sanitizeColor(color),
|
||||
});
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
// Minimal flag parser. First non-flag positional is the message.
|
||||
function parseArgs(argv) {
|
||||
const out = { _: [] };
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--clear') out.clear = true;
|
||||
else if (a.startsWith('--')) {
|
||||
const key = a.slice(2);
|
||||
const next = argv[i + 1];
|
||||
if (next === undefined || next.startsWith('--')) out[key] = true;
|
||||
else { out[key] = next; i++; }
|
||||
} else out._.push(a);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// --- runtime --------------------------------------------------------------
|
||||
|
||||
function loadConfig(p) {
|
||||
const configPath = p || path.join(__dirname, 'config.json');
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(`Could not read config at ${configPath}: ${e.message}`);
|
||||
console.error('Copy config.example.json to config.json and fill it in.');
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function postJson(url, token, body) {
|
||||
const res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
return { ok: res.ok, status: res.status, json };
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const cfg = loadConfig(args.config);
|
||||
|
||||
const apiBase = String(cfg.api_base || '').replace(/\/$/, '');
|
||||
const token = cfg.api_token;
|
||||
const target = args.device || args.group || cfg.device_id;
|
||||
|
||||
if (!apiBase || !token) { console.error('config must set api_base and api_token.'); process.exit(1); }
|
||||
if (!target) { console.error('no target: pass --device/--group or set device_id in config.'); process.exit(1); }
|
||||
|
||||
if (args.clear) {
|
||||
const body = { device_id: target };
|
||||
if (args.pip && args.pip !== true) body.pip_id = args.pip;
|
||||
const { ok, status, json } = await postJson(`${apiBase}/api/pip/clear`, token, body);
|
||||
if (!ok) { console.error(`clear failed (${status}): ${json.error || 'unknown'}`); process.exit(1); }
|
||||
console.log(`cleared on ${target} — sent=${json.sent} offline=${json.offline}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = args._[0];
|
||||
if (!message) {
|
||||
console.error('usage: node announce.js "your message" [--title T] [--device ID|--group ID] [--duration N] [--color #RRGGBB] [--position P]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const ov = cfg.overlay || {};
|
||||
const position = args.position || ov.position || 'center';
|
||||
if (!POSITIONS.includes(position)) { console.error(`invalid --position; use one of: ${POSITIONS.join(', ')}`); process.exit(1); }
|
||||
|
||||
const color = args.color || ov.color || '#CC0000';
|
||||
const duration = args.duration != null ? Math.max(0, parseInt(args.duration, 10) || 0) : (ov.duration != null ? ov.duration : 0);
|
||||
const overlayBase = cfg.overlay_base_url;
|
||||
if (!overlayBase) { console.error('config must set overlay_base_url.'); process.exit(1); }
|
||||
|
||||
const uri = buildOverlayUri(overlayBase, {
|
||||
title: (args.title && args.title !== true) ? args.title : (cfg.default_title || ''),
|
||||
message,
|
||||
color,
|
||||
});
|
||||
|
||||
const body = {
|
||||
device_id: target,
|
||||
type: 'web',
|
||||
uri,
|
||||
position,
|
||||
width: ov.width || 900,
|
||||
height: ov.height || 300,
|
||||
duration,
|
||||
border_radius: ov.border_radius != null ? ov.border_radius : 16,
|
||||
opacity: ov.opacity != null ? ov.opacity : 1,
|
||||
close_button: false,
|
||||
title: (args.title && args.title !== true) ? args.title : undefined,
|
||||
};
|
||||
|
||||
const { ok, status, json } = await postJson(`${apiBase}/api/pip`, token, body);
|
||||
if (!ok || !json.pip_id) { console.error(`show failed (${status}): ${json.error || 'unknown'}`); process.exit(1); }
|
||||
console.log(`shown on ${target} (${json.target}) pip=${json.pip_id} dur=${duration || '∞'}s sent=${json.sent} offline=${json.offline}`);
|
||||
console.log(`clear it with: node announce.js --clear --device ${target} --pip ${json.pip_id}`);
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
main().catch((e) => { console.error(e.message || e); process.exit(1); });
|
||||
}
|
||||
|
||||
module.exports = { buildOverlayUri, sanitizeColor, parseArgs, POSITIONS };
|
||||
17
Examples/PIP-Announce-Broadcast/config.example.json
Normal file
17
Examples/PIP-Announce-Broadcast/config.example.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/message-overlay.html",
|
||||
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
"default_title": "NOTICE",
|
||||
|
||||
"overlay": {
|
||||
"position": "center",
|
||||
"width": 900,
|
||||
"height": 300,
|
||||
"border_radius": 16,
|
||||
"color": "#CC0000",
|
||||
"duration": 0
|
||||
}
|
||||
}
|
||||
30
Examples/PIP-Announce-Broadcast/message-overlay.html
Normal file
30
Examples/PIP-Announce-Broadcast/message-overlay.html
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Announcement</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||
.band { padding: 12px 22px; font-weight: 800; letter-spacing: .05em; text-transform: uppercase;
|
||||
font-size: clamp(14px, 2.8vw, 22px); display: none; }
|
||||
.band.show { display: block; }
|
||||
.body { padding: 22px 26px; display: flex; flex-direction: column; gap: 12px; flex: 1; justify-content: center; }
|
||||
.message { font-size: clamp(22px, 5.5vw, 44px); font-weight: 700; line-height: 1.18; }
|
||||
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="band" id="band"></div>
|
||||
<div class="body">
|
||||
<div class="message" id="message"></div>
|
||||
<div class="footer"><span id="updated"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="message-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
Examples/PIP-Announce-Broadcast/message-overlay.js
Normal file
23
Examples/PIP-Announce-Broadcast/message-overlay.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads the announcement fields from the URL query string and populates the card.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||
|
||||
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
|
||||
|
||||
var title = get('title');
|
||||
var band = document.getElementById('band');
|
||||
if (title) {
|
||||
band.textContent = title.toUpperCase();
|
||||
band.style.background = color;
|
||||
band.classList.add('show');
|
||||
}
|
||||
|
||||
document.getElementById('message').textContent = get('message') || 'Announcement';
|
||||
|
||||
// Footer shows when the overlay was rendered, so a static announcement still
|
||||
// reads as "current".
|
||||
var now = new Date();
|
||||
document.getElementById('updated').textContent = isNaN(now) ? '' : ('posted ' + now.toLocaleString());
|
||||
})();
|
||||
12
Examples/PIP-Announce-Broadcast/package.json
Normal file
12
Examples/PIP-Announce-Broadcast/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-announce-broadcast",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: flash a one-off announcement onto a ScreenTinker screen or group via the PiP overlay API.",
|
||||
"type": "commonjs",
|
||||
"main": "announce.js",
|
||||
"scripts": {
|
||||
"start": "node announce.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
42
Examples/PIP-Announce-Broadcast/test.js
Normal file
42
Examples/PIP-Announce-Broadcast/test.js
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
'use strict';
|
||||
|
||||
// Offline test for the pure overlay-URI builder. No network, no config needed.
|
||||
const { buildOverlayUri, sanitizeColor, parseArgs } = require('./announce');
|
||||
|
||||
let ok = true;
|
||||
function check(name, cond) {
|
||||
console.log(`${cond ? '✓' : '✗'} ${name}`);
|
||||
if (!cond) ok = false;
|
||||
}
|
||||
|
||||
// color sanitisation
|
||||
check("sanitizeColor strips '#'", sanitizeColor('#CC0000') === 'CC0000');
|
||||
check('sanitizeColor falls back on garbage', sanitizeColor('not-a-color') === 'CC0000');
|
||||
check('sanitizeColor falls back on short hex', sanitizeColor('#FFF') === 'CC0000');
|
||||
check('sanitizeColor keeps valid 6-hex', sanitizeColor('1a2b3c') === '1a2b3c');
|
||||
|
||||
// uri building + round-trip through URLSearchParams
|
||||
const base = 'https://signage.example.com/message-overlay.html';
|
||||
const msg = 'Fire drill at 2:00 PM — exit via Stairwell B & meet @ lot #3';
|
||||
const uri = buildOverlayUri(base, { title: 'Notice!', message: msg, color: '#CC0000' });
|
||||
const u = new URL(uri);
|
||||
|
||||
check('uri keeps the base path', u.pathname.endsWith('/message-overlay.html'));
|
||||
check('message round-trips exactly', u.searchParams.get('message') === msg);
|
||||
check('title round-trips', u.searchParams.get('title') === 'Notice!');
|
||||
check('color is sanitised in the uri', u.searchParams.get('color') === 'CC0000');
|
||||
check('special chars are encoded (no raw space/&/# in query string)',
|
||||
!/[ #]/.test(u.search) && (u.search.match(/&/g) || []).length === 2);
|
||||
|
||||
// appends with '&' when the base already has a query
|
||||
const uri2 = buildOverlayUri(base + '?v=2', { message: 'hi', color: 'abcdef' });
|
||||
check("appends with '&' when base has a query", uri2.includes('?v=2&') && new URL(uri2).searchParams.get('message') === 'hi');
|
||||
|
||||
// arg parsing
|
||||
const a = parseArgs(['Hello world', '--title', 'NOTICE', '--duration', '60', '--clear']);
|
||||
check('parseArgs captures positional message', a._[0] === 'Hello world');
|
||||
check('parseArgs reads flag values', a.title === 'NOTICE' && a.duration === '60');
|
||||
check('parseArgs sets boolean --clear', a.clear === true);
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
3
Examples/PIP-CAP-AU-Alert-Monitor/.gitignore
vendored
Normal file
3
Examples/PIP-CAP-AU-Alert-Monitor/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
92
Examples/PIP-CAP-AU-Alert-Monitor/README.md
Normal file
92
Examples/PIP-CAP-AU-Alert-Monitor/README.md
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
# CAP-AU → ScreenTinker PiP alert monitor (example)
|
||||
|
||||
Watches a CAP-AU emergency feed (default: the **NSW RFS `majorIncidentsCAP`** feed) and,
|
||||
when a qualifying alert covers a screen's location, pushes a **PiP web overlay** to that
|
||||
screen — then clears it when the alert expires, is cancelled, or leaves the feed.
|
||||
|
||||
It uses the **existing** ScreenTinker PiP API (`POST /api/pip`, `POST /api/pip/clear`).
|
||||
No server changes required.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
CAP-AU feed ──poll──▶ parse (EDXL unwrap) ──▶ gate (AlertLevel + geofence) ──▶ POST /api/pip
|
||||
◀─ clear on expiry/cancel/gone
|
||||
```
|
||||
|
||||
Three non-obvious things this example gets right, learned from the real feed:
|
||||
|
||||
1. **It's EDXL-DE wrapped.** The feed is not a flat list of CAP alerts — each `<alert>`
|
||||
is embedded under `EDXLDistribution > contentObject > xmlContent > embeddedXMLContent`.
|
||||
`cap-parse.js` unwraps that.
|
||||
2. **Gate on `AlertLevel`, not CAP `<severity>`.** RFS leaves `<severity>`/`<urgency>`
|
||||
as `Unknown` for routine incidents. The real urgency lives in a `<parameter>` named
|
||||
`AlertLevel` (`Planned Burn` / `Advice` / `Watch and Act` / `Emergency Warning`).
|
||||
Default threshold shows only `Watch and Act` and `Emergency Warning`, so routine
|
||||
hazard-reduction burns never hit a screen.
|
||||
3. **CAP coordinates are `lat,lon`** — the reverse of GeoJSON's `lon,lat`. The geofence
|
||||
keeps that flip in one place; feeding raw CAP coords into a `lon,lat` library is the
|
||||
classic "matches on the wrong side of the planet" bug.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp config.example.json config.json # then edit it
|
||||
```
|
||||
|
||||
In `config.json`:
|
||||
|
||||
- `api_base` — your ScreenTinker server URL.
|
||||
- `api_token` — an **`st_` API token with the `full` scope** (PiP is fleet-affecting and
|
||||
full-trust, so the route requires it). Create one in the dashboard's API-token section.
|
||||
- `overlay_base_url` — where `alert-overlay.html` is hosted, **reachable by the player**
|
||||
(the player fetches the overlay URL directly). Drop the file on the ScreenTinker host
|
||||
or any static host.
|
||||
- `screens` — each screen's `lat`/`lon` (its physical location, used for the geofence)
|
||||
and the `device_id` (a device **or** group id) to push the overlay to.
|
||||
- `alert_levels` — the AlertLevel threshold (default `["Watch and Act","Emergency Warning"]`).
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm start # uses ./config.json
|
||||
# or
|
||||
node monitor.js /path/to/config.json
|
||||
```
|
||||
|
||||
On `Ctrl-C` it clears any overlays it put up, so a screen never keeps a stale alert.
|
||||
|
||||
## Test the parser (no server needed)
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Runs the EDXL/gate/geofence logic against `fixture-feed.xml` (two real RFS planned burns
|
||||
plus a synthetic Emergency Warning and a distant Watch-and-Act) and asserts that only the
|
||||
in-area Emergency Warning would fire.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|---|---|
|
||||
| `monitor.js` | Poll loop + PiP show/clear lifecycle (dedup by CAP identifier). |
|
||||
| `cap-parse.js` | EDXL unwrap, AlertLevel/field extraction, polygon+circle geofence, gate. |
|
||||
| `alert-overlay.html` | The web overlay the PiP points at; renders from `?level=&headline=&area=…`. |
|
||||
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||
| `fixture-feed.xml` / `test-parse.js` | Offline test of the parser/gate. |
|
||||
|
||||
## Notes / next steps
|
||||
|
||||
- **Targeting model:** one screen → one `device_id` here. For many screens you'd likely
|
||||
drive `screens` from your device inventory (each device's stored location) rather than
|
||||
hand-listing them.
|
||||
- **`msgType` Update:** currently an Update re-shows only if the identifier changed; if RFS
|
||||
reuses an identifier on update you may want to force a re-push (clear + show) to refresh
|
||||
the overlay content.
|
||||
- **Other states/agencies:** point `feed_url` at other CAP-AU sources (state SES/fire
|
||||
services). Field names in `<parameter>` are RFS-specific; other agencies differ, so the
|
||||
`AlertLevel` mapping may need adjusting per source.
|
||||
- This is an example/reference, not a life-safety system. Don't make it the only way people
|
||||
are warned.
|
||||
40
Examples/PIP-CAP-AU-Alert-Monitor/alert-overlay.html
Normal file
40
Examples/PIP-CAP-AU-Alert-Monitor/alert-overlay.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<!doctype html>
|
||||
<html lang="en-AU">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Emergency Alert</title>
|
||||
<style>
|
||||
/* Rendered inside the PiP box; transparent behind the card. Inline <style> is allowed
|
||||
by the server CSP (styleSrc 'self' 'unsafe-inline'); the SCRIPT is external because
|
||||
scriptSrc is 'self' with no 'unsafe-inline' — inline <script> would be blocked. */
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
|
||||
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(18px, 4vw, 30px); }
|
||||
.band .pulse { width: 16px; height: 16px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||
animation: pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||
.body { padding: 18px 24px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
|
||||
.headline { font-size: clamp(20px, 5vw, 38px); font-weight: 700; line-height: 1.15; }
|
||||
.meta { font-size: clamp(13px, 2.6vw, 18px); color: #cfcfcf; display: flex; flex-wrap: wrap; gap: 6px 18px; }
|
||||
.meta b { color: #fff; font-weight: 600; }
|
||||
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||
.agency { opacity: .8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="band" id="band"><span class="pulse"></span><span id="level">ALERT</span></div>
|
||||
<div class="body">
|
||||
<div class="headline" id="headline"></div>
|
||||
<div class="meta" id="meta"></div>
|
||||
<div class="footer"><span class="agency">NSW Rural Fire Service</span> <span id="updated"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<!-- external, same-origin: satisfies scriptSrc 'self' -->
|
||||
<script src="overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
183
Examples/PIP-CAP-AU-Alert-Monitor/cap-parse.js
Normal file
183
Examples/PIP-CAP-AU-Alert-Monitor/cap-parse.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
'use strict';
|
||||
|
||||
// CAP-AU parser for the NSW RFS "majorIncidentsCAP" feed (and other CAP-AU sources that
|
||||
// wrap their alerts the same way). Three jobs:
|
||||
// 1. Unwrap the EDXL-DE envelope and pull out each embedded CAP <alert>.
|
||||
// 2. Normalise the bits we actually gate/render on (AlertLevel lives in <parameter>,
|
||||
// NOT in CAP <severity> — RFS leaves severity "Unknown" for routine incidents).
|
||||
// 3. Geofence: is a given screen's lat/lon inside an alert's <area>? CAP coordinates
|
||||
// are "lat,lon" (note: the REVERSE of GeoJSON's lon,lat) — this module keeps the
|
||||
// flip in one place so callers never have to think about it.
|
||||
|
||||
const { XMLParser } = require('fast-xml-parser');
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true, // EDXLDistribution and alert sit in different namespaces
|
||||
parseTagValue: false, // keep everything as strings; we coerce deliberately
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
// Always work with arrays even when the XML has a single child.
|
||||
function arr(x) {
|
||||
if (x === undefined || x === null) return [];
|
||||
return Array.isArray(x) ? x : [x];
|
||||
}
|
||||
|
||||
// Pull the <parameter> name/value pairs into a flat map. This is where the useful,
|
||||
// already-structured fields live (AlertLevel, IncidentType, Status, ...), so we read
|
||||
// these instead of regexing the HTML-encoded <description> blob.
|
||||
function paramsToMap(info) {
|
||||
const out = {};
|
||||
for (const p of arr(info && info.parameter)) {
|
||||
if (p && p.valueName != null) out[String(p.valueName)] = p.value == null ? '' : String(p.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Parse a CAP "<polygon>" string ("lat,lon lat,lon ...") into [{lat, lon}, ...].
|
||||
function parsePolygon(str) {
|
||||
if (!str) return null;
|
||||
const pts = String(str).trim().split(/\s+/).map((pair) => {
|
||||
const [lat, lon] = pair.split(',').map(Number);
|
||||
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
|
||||
}).filter(Boolean);
|
||||
return pts.length >= 3 ? pts : null;
|
||||
}
|
||||
|
||||
// Parse a CAP "<circle>" string ("lat,lon radiusKm"). RFS often emits radius 0 (a point),
|
||||
// which can never contain anything, so callers should treat a 0-radius circle as "no
|
||||
// usable circle" and rely on the polygon.
|
||||
function parseCircle(str) {
|
||||
if (!str) return null;
|
||||
const [center, radius] = String(str).trim().split(/\s+/);
|
||||
const [lat, lon] = (center || '').split(',').map(Number);
|
||||
const km = Number(radius);
|
||||
if (![lat, lon, km].every(Number.isFinite)) return null;
|
||||
return { lat, lon, km };
|
||||
}
|
||||
|
||||
// Ray-casting point-in-polygon. We map lon -> x and lat -> y so the algorithm is ordinary
|
||||
// planar; that mapping is the ONE place the CAP lat,lon order is reconciled.
|
||||
function pointInPolygon(pt, poly) {
|
||||
const x = pt.lon, y = pt.lat;
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const xi = poly[i].lon, yi = poly[i].lat;
|
||||
const xj = poly[j].lon, yj = poly[j].lat;
|
||||
const intersect = (yi > y) !== (yj > y) &&
|
||||
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function haversineKm(a, b) {
|
||||
const R = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
// Does {lat, lon} fall inside this alert's area? Polygon first; fall back to a non-zero
|
||||
// circle. Returns false when the alert has no usable geometry.
|
||||
function pointInAlertArea(point, alert) {
|
||||
if (alert.polygon && pointInPolygon(point, alert.polygon)) return true;
|
||||
if (alert.circle && alert.circle.km > 0 && haversineKm(point, alert.circle) <= alert.circle.km) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Flatten one embedded CAP <alert> into the shape the monitor works with.
|
||||
function normaliseAlert(a) {
|
||||
const info = Array.isArray(a.info) ? a.info[0] : a.info || {};
|
||||
const area = Array.isArray(info.area) ? info.area[0] : info.area || {};
|
||||
const params = paramsToMap(info);
|
||||
return {
|
||||
identifier: a.identifier != null ? String(a.identifier) : null,
|
||||
msgType: a.msgType || null, // Alert | Update | Cancel
|
||||
sent: a.sent || null,
|
||||
headline: info.headline || params.IncidentName || '(no headline)',
|
||||
event: info.event || null,
|
||||
category: info.category || null,
|
||||
responseType: info.responseType || null, // mostly "Monitor" in this feed
|
||||
severity: info.severity || null, // mostly "Unknown" — do NOT gate on this
|
||||
expires: info.expires || null,
|
||||
web: info.web || null,
|
||||
// RFS-specific, the field that actually carries urgency:
|
||||
alertLevel: params.AlertLevel || null, // Planned Burn | Advice | Watch and Act | Emergency Warning
|
||||
incidentType: params.IncidentType || null,
|
||||
status: params.Status || null,
|
||||
size: params.Fireground || params.Size || null,
|
||||
council: params.CouncilArea || params.Location || null,
|
||||
isFire: (params.IsFire || '').toLowerCase() === 'yes',
|
||||
polygon: parsePolygon(area.polygon),
|
||||
circle: parseCircle(area.circle),
|
||||
areaDesc: area.areaDesc || null,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse a full feed body (EDXL-DE wrapping embedded CAP alerts) into normalised alerts.
|
||||
function parseFeed(xml) {
|
||||
const root = parser.parse(xml);
|
||||
const dist = root.EDXLDistribution || root.Distribution || null;
|
||||
const alerts = [];
|
||||
if (dist) {
|
||||
for (const co of arr(dist.contentObject)) {
|
||||
const embedded = co && co.xmlContent && co.xmlContent.embeddedXMLContent;
|
||||
for (const e of arr(embedded)) {
|
||||
for (const al of arr(e && e.alert)) alerts.push(normaliseAlert(al));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: a bare CAP feed (no EDXL envelope).
|
||||
for (const al of arr(root.alert)) alerts.push(normaliseAlert(al));
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
|
||||
// Has this alert's <expires> passed? (Treats missing/unparseable expiry as "not expired".)
|
||||
function isExpired(alert, now = Date.now()) {
|
||||
if (!alert.expires) return false;
|
||||
const t = Date.parse(alert.expires);
|
||||
return Number.isFinite(t) && t <= now;
|
||||
}
|
||||
|
||||
// The gate: should this alert put something on a screen at `point`?
|
||||
// - msgType must be Alert/Update (Cancel clears, never shows)
|
||||
// - not expired
|
||||
// - AlertLevel is at or above the configured threshold
|
||||
// - the screen falls inside the alert area
|
||||
// Returns { show: bool, reason } so callers can log why something did/didn't fire.
|
||||
const DEFAULT_LEVELS = ['Watch and Act', 'Emergency Warning'];
|
||||
|
||||
function shouldShow(alert, point, opts = {}) {
|
||||
const levels = opts.alertLevels || DEFAULT_LEVELS;
|
||||
const now = opts.now || Date.now();
|
||||
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
|
||||
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
|
||||
if (!alert.alertLevel || !levels.includes(alert.alertLevel)) {
|
||||
return { show: false, reason: `alertLevel "${alert.alertLevel}" below threshold` };
|
||||
}
|
||||
if (!alert.polygon && !(alert.circle && alert.circle.km > 0)) {
|
||||
return { show: false, reason: 'no usable geometry' };
|
||||
}
|
||||
if (!pointInAlertArea(point, alert)) return { show: false, reason: 'outside area' };
|
||||
return { show: true, reason: 'in-area, at/above threshold' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseFeed,
|
||||
normaliseAlert,
|
||||
parsePolygon,
|
||||
parseCircle,
|
||||
pointInPolygon,
|
||||
pointInAlertArea,
|
||||
haversineKm,
|
||||
isExpired,
|
||||
shouldShow,
|
||||
DEFAULT_LEVELS,
|
||||
};
|
||||
29
Examples/PIP-CAP-AU-Alert-Monitor/config.example.json
Normal file
29
Examples/PIP-CAP-AU-Alert-Monitor/config.example.json
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"feed_url": "https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml",
|
||||
"poll_interval_sec": 120,
|
||||
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
|
||||
"overlay_base_url": "https://signage.example.com/alerts/alert-overlay.html",
|
||||
|
||||
"alert_levels": ["Watch and Act", "Emergency Warning"],
|
||||
|
||||
"screens": [
|
||||
{ "name": "Foyer TV", "lat": -33.8688, "lon": 151.2093, "device_id": "DEVICE_OR_GROUP_ID_1" },
|
||||
{ "name": "Cafe board", "lat": -33.7969, "lon": 151.2870, "device_id": "DEVICE_OR_GROUP_ID_2" }
|
||||
],
|
||||
|
||||
"overlay": {
|
||||
"position": "center",
|
||||
"width": 900,
|
||||
"height": 320,
|
||||
"opacity": 1,
|
||||
"border_radius": 16,
|
||||
"colors": {
|
||||
"Emergency Warning": "CC0000",
|
||||
"Watch and Act": "E8730C",
|
||||
"Advice": "F2C200"
|
||||
}
|
||||
}
|
||||
}
|
||||
82
Examples/PIP-CAP-AU-Alert-Monitor/fixture-feed.xml
Normal file
82
Examples/PIP-CAP-AU-Alert-Monitor/fixture-feed.xml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?xml version="1.0" encoding="utf-8" standalone="no"?><?xml-stylesheet href="lib/RFS_EDXL_simple.xsl" type="text/xsl"?>
|
||||
<EDXLDistribution xmlns="urn:oasis:names:tc:emergency:EDXL:DE:1.0">
|
||||
<distributionID>RFSUniqueID:2026-06-18T00:00:00Z</distributionID>
|
||||
<senderID>webmaster@rfs.nsw.gov.au</senderID>
|
||||
<dateTimeSent>2026-06-18T10:00:00+10:00</dateTimeSent>
|
||||
<distributionStatus>Actual</distributionStatus>
|
||||
<distributionType>Report</distributionType>
|
||||
<contentObject>
|
||||
<contentDescription>Information on Aberdeen HR</contentDescription>
|
||||
<xmlContent><embeddedXMLContent>
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>2026-06-17T14:46:00.0000000:662900</identifier>
|
||||
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-17T14:46:00+10:00</sent>
|
||||
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||
<info>
|
||||
<category>Fire</category><event>Fire</event><responseType>Monitor</responseType>
|
||||
<urgency>Unknown</urgency><severity>Unknown</severity><certainty>Observed</certainty>
|
||||
<expires>2026-06-30T21:25:21+10:00</expires>
|
||||
<headline>Aberdeen HR</headline>
|
||||
<parameter><valueName>AlertLevel</valueName><value>Planned Burn</value></parameter>
|
||||
<parameter><valueName>IncidentType</valueName><value>Hazard Reduction</value></parameter>
|
||||
<parameter><valueName>Status</valueName><value>Under control</value></parameter>
|
||||
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||
<area>
|
||||
<areaDesc>STANBOROUGH</areaDesc>
|
||||
<polygon>-29.974,151.103 -29.984,151.103 -29.984,151.108 -29.974,151.108 -29.974,151.103</polygon>
|
||||
<circle>-29.978,151.105 0</circle>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
</embeddedXMLContent></xmlContent>
|
||||
</contentObject>
|
||||
<contentObject>
|
||||
<contentDescription>Emergency Warning - Test Ridge</contentDescription>
|
||||
<xmlContent><embeddedXMLContent>
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>2026-06-18T09:30:00.0000000:670001</identifier>
|
||||
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:30:00+10:00</sent>
|
||||
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||
<info>
|
||||
<category>Fire</category><event>Bushfire</event><responseType>Evacuate</responseType>
|
||||
<urgency>Immediate</urgency><severity>Extreme</severity><certainty>Observed</certainty>
|
||||
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||
<headline>Test Ridge Road Fire</headline>
|
||||
<parameter><valueName>AlertLevel</valueName><value>Emergency Warning</value></parameter>
|
||||
<parameter><valueName>IncidentType</valueName><value>Bush Fire</value></parameter>
|
||||
<parameter><valueName>Status</valueName><value>Out of control</value></parameter>
|
||||
<parameter><valueName>CouncilArea</valueName><value>Testshire</value></parameter>
|
||||
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||
<area>
|
||||
<areaDesc>Test Ridge - 5km around the screen</areaDesc>
|
||||
<polygon>-33.90,151.10 -33.90,151.30 -33.80,151.30 -33.80,151.10 -33.90,151.10</polygon>
|
||||
<circle>-33.85,151.20 8</circle>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
</embeddedXMLContent></xmlContent>
|
||||
</contentObject>
|
||||
<contentObject>
|
||||
<contentDescription>Watch and Act - far away</contentDescription>
|
||||
<xmlContent><embeddedXMLContent>
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>2026-06-18T09:45:00.0000000:670002</identifier>
|
||||
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:45:00+10:00</sent>
|
||||
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||
<info>
|
||||
<category>Fire</category><event>Bushfire</event><responseType>Prepare</responseType>
|
||||
<urgency>Expected</urgency><severity>Severe</severity><certainty>Likely</certainty>
|
||||
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||
<headline>Distant Valley Fire</headline>
|
||||
<parameter><valueName>AlertLevel</valueName><value>Watch and Act</value></parameter>
|
||||
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||
<area>
|
||||
<areaDesc>Distant Valley (far from screen)</areaDesc>
|
||||
<polygon>-31.00,150.00 -31.00,150.10 -30.90,150.10 -30.90,150.00 -31.00,150.00</polygon>
|
||||
<circle>-30.95,150.05 0</circle>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
</embeddedXMLContent></xmlContent>
|
||||
</contentObject>
|
||||
</EDXLDistribution>
|
||||
175
Examples/PIP-CAP-AU-Alert-Monitor/monitor.js
Normal file
175
Examples/PIP-CAP-AU-Alert-Monitor/monitor.js
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
'use strict';
|
||||
|
||||
// CAP-AU -> ScreenTinker PiP monitor.
|
||||
//
|
||||
// Polls a CAP-AU feed (default: the NSW RFS majorIncidentsCAP feed), and for each
|
||||
// configured screen, pushes a PiP web overlay when a qualifying alert covers that
|
||||
// screen's location — then clears it when the alert expires, is cancelled, or drops
|
||||
// out of the feed. It talks to the EXISTING ScreenTinker PiP API (POST /api/pip and
|
||||
// POST /api/pip/clear); it adds no server code.
|
||||
//
|
||||
// node monitor.js [path/to/config.json]
|
||||
//
|
||||
// Requires Node 18+ (uses global fetch). The config needs an st_ API token with the
|
||||
// 'full' scope (PiP is fleet-affecting and full-trust, so the route demands it).
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cap = require('./cap-parse');
|
||||
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try {
|
||||
cfg = JSON.parse(fs.readFileSync(configPath, 'utf8'));
|
||||
} catch (e) {
|
||||
console.error(`Could not read config at ${configPath}: ${e.message}`);
|
||||
console.error('Copy config.example.json to config.json and fill it in.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const FEED_URL = cfg.feed_url || 'https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml';
|
||||
const POLL_SEC = cfg.poll_interval_sec || 120; // RFS refreshes ~every 30 min; 2 min poll is plenty
|
||||
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const API_TOKEN = cfg.api_token;
|
||||
const OVERLAY_BASE = cfg.overlay_base_url; // where alert-overlay.html is hosted, reachable BY THE PLAYER
|
||||
const SCREENS = cfg.screens || []; // [{ name, lat, lon, device_id }]
|
||||
const ALERT_LEVELS = cfg.alert_levels || cap.DEFAULT_LEVELS;
|
||||
const OVERLAY = cfg.overlay || {};
|
||||
|
||||
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || SCREENS.length === 0) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, and at least one screen.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// active overlays: key `${device_id}|${identifier}` -> { pip_id, expiresAt }
|
||||
const active = new Map();
|
||||
const keyFor = (deviceId, identifier) => `${deviceId}|${identifier}`;
|
||||
|
||||
// Colour the overlay by alert level (overridable in config.overlay.colors).
|
||||
const LEVEL_COLORS = Object.assign(
|
||||
{ 'Emergency Warning': 'CC0000', 'Watch and Act': 'E8730C', 'Advice': 'F2C200' },
|
||||
OVERLAY.colors || {},
|
||||
);
|
||||
|
||||
function overlayUri(alert) {
|
||||
const color = LEVEL_COLORS[alert.alertLevel] || 'CC0000';
|
||||
const q = new URLSearchParams({
|
||||
level: alert.alertLevel || '',
|
||||
headline: alert.headline || '',
|
||||
area: alert.areaDesc || alert.council || '',
|
||||
status: alert.status || '',
|
||||
updated: alert.sent || '',
|
||||
color: color,
|
||||
more: alert.web || '',
|
||||
});
|
||||
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
async function pipShow(deviceId, alert) {
|
||||
const body = {
|
||||
device_id: deviceId,
|
||||
type: 'web',
|
||||
uri: overlayUri(alert),
|
||||
position: OVERLAY.position || 'center',
|
||||
width: OVERLAY.width || 900,
|
||||
height: OVERLAY.height || 320,
|
||||
duration: 0, // 0 = until we explicitly clear it
|
||||
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
|
||||
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
|
||||
close_button: false,
|
||||
title: alert.alertLevel || 'Alert',
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
return json.pip_id;
|
||||
}
|
||||
|
||||
async function pipClear(deviceId, pipId) {
|
||||
const res = await fetch(`${API_BASE}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({ device_id: deviceId, pip_id: pipId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
let alerts;
|
||||
try {
|
||||
const res = await fetch(FEED_URL, { headers: { Accept: 'application/xml, text/xml' } });
|
||||
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
|
||||
alerts = cap.parseFeed(await res.text());
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] feed fetch/parse error: ${e.message}`);
|
||||
return; // keep the last state; try again next tick
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
const stillQualifying = new Set(); // keys that should remain shown this tick
|
||||
|
||||
for (const screen of SCREENS) {
|
||||
const point = { lat: screen.lat, lon: screen.lon };
|
||||
for (const alert of alerts) {
|
||||
if (!alert.identifier) continue;
|
||||
const decision = cap.shouldShow(alert, point, { alertLevels: ALERT_LEVELS, now });
|
||||
const key = keyFor(screen.device_id, alert.identifier);
|
||||
if (!decision.show) continue;
|
||||
stillQualifying.add(key);
|
||||
if (active.has(key)) continue; // already on screen
|
||||
try {
|
||||
const pipId = await pipShow(screen.device_id, alert);
|
||||
active.set(key, { pip_id: pipId, expiresAt: Date.parse(alert.expires) || null });
|
||||
console.log(`[${new Date().toISOString()}] SHOW "${alert.headline}" (${alert.alertLevel}) on ${screen.name} [${screen.device_id}] pip=${pipId}`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] show error on ${screen.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear anything active that no longer qualifies (gone from feed, cancelled, expired,
|
||||
// dropped below threshold, or moved out of area).
|
||||
for (const [key, rec] of [...active.entries()]) {
|
||||
if (stillQualifying.has(key)) continue;
|
||||
const [deviceId] = key.split('|');
|
||||
try {
|
||||
await pipClear(deviceId, rec.pip_id);
|
||||
active.delete(key);
|
||||
console.log(`[${new Date().toISOString()}] CLEAR pip=${rec.pip_id} on ${deviceId} (no longer qualifying)`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] clear error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`CAP-AU PiP monitor starting`);
|
||||
console.log(` feed: ${FEED_URL}`);
|
||||
console.log(` poll: every ${POLL_SEC}s`);
|
||||
console.log(` levels: ${ALERT_LEVELS.join(', ')}`);
|
||||
console.log(` screens: ${SCREENS.map(s => `${s.name}(${s.lat},${s.lon})`).join(', ')}`);
|
||||
await tick();
|
||||
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||
|
||||
// On shutdown, clear everything we put up so screens don't keep a stale alert.
|
||||
async function shutdown() {
|
||||
clearInterval(timer);
|
||||
console.log('\nclearing active overlays before exit...');
|
||||
for (const [key, rec] of active.entries()) {
|
||||
const [deviceId] = key.split('|');
|
||||
try { await pipClear(deviceId, rec.pip_id); } catch { /* best effort */ }
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main();
|
||||
28
Examples/PIP-CAP-AU-Alert-Monitor/overlay.js
Normal file
28
Examples/PIP-CAP-AU-Alert-Monitor/overlay.js
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads the alert fields from the URL query string and populates the card.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||
|
||||
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
|
||||
document.getElementById('band').style.background = color;
|
||||
document.getElementById('level').textContent = (get('level') || 'Alert').toUpperCase();
|
||||
document.getElementById('headline').textContent = get('headline') || 'Emergency alert in your area';
|
||||
|
||||
var meta = [];
|
||||
if (get('area')) meta.push('<b>Area:</b> ' + escapeHtml(get('area')));
|
||||
if (get('status')) meta.push('<b>Status:</b> ' + escapeHtml(get('status')));
|
||||
document.getElementById('meta').innerHTML = meta.join('');
|
||||
|
||||
var updated = get('updated');
|
||||
if (updated) {
|
||||
var d = new Date(updated);
|
||||
document.getElementById('updated').textContent = isNaN(d) ? ('· ' + updated) : ('· updated ' + d.toLocaleString('en-AU'));
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return s.replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
})();
|
||||
15
Examples/PIP-CAP-AU-Alert-Monitor/package.json
Normal file
15
Examples/PIP-CAP-AU-Alert-Monitor/package.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"name": "cap-alert-monitor",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: monitor a CAP-AU feed and push emergency alerts to ScreenTinker screens via the PiP API.",
|
||||
"type": "commonjs",
|
||||
"main": "monitor.js",
|
||||
"scripts": {
|
||||
"start": "node monitor.js",
|
||||
"test": "node test-parse.js"
|
||||
},
|
||||
"engines": { "node": ">=18" },
|
||||
"dependencies": {
|
||||
"fast-xml-parser": "^4.5.0"
|
||||
}
|
||||
}
|
||||
43
Examples/PIP-CAP-AU-Alert-Monitor/test-parse.js
Normal file
43
Examples/PIP-CAP-AU-Alert-Monitor/test-parse.js
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
const fs = require('fs');
|
||||
const cap = require('./cap-parse');
|
||||
|
||||
const xml = fs.readFileSync('./fixture-feed.xml', 'utf8');
|
||||
const alerts = cap.parseFeed(xml);
|
||||
|
||||
// A screen physically located inside the Emergency Warning area.
|
||||
const SCREEN = { lat: -33.85, lon: 151.20 };
|
||||
const now = Date.parse('2026-06-18T10:00:00+10:00');
|
||||
|
||||
console.log(`Parsed ${alerts.length} alert(s) from the EDXL envelope:\n`);
|
||||
for (const a of alerts) {
|
||||
const g = cap.shouldShow(a, SCREEN, { now });
|
||||
console.log(`• ${a.headline}`);
|
||||
console.log(` alertLevel=${a.alertLevel} severity(CAP)=${a.severity} msgType=${a.msgType}`);
|
||||
console.log(` geometry: polygon=${a.polygon ? a.polygon.length + 'pts' : 'none'} circle=${a.circle ? a.circle.km + 'km' : 'none'}`);
|
||||
console.log(` => ${g.show ? 'SHOW PiP' : 'skip'} (${g.reason})\n`);
|
||||
}
|
||||
|
||||
// Assertions
|
||||
const byLevel = Object.fromEntries(alerts.map(a => [a.alertLevel, a]));
|
||||
const results = alerts.map(a => ({ h: a.headline, show: cap.shouldShow(a, SCREEN, { now }).show }));
|
||||
const shown = results.filter(r => r.show).map(r => r.h);
|
||||
|
||||
const expectShown = ['Test Ridge Road Fire'];
|
||||
const ok =
|
||||
shown.length === 1 &&
|
||||
shown[0] === 'Test Ridge Road Fire' &&
|
||||
cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason.includes('below threshold') &&
|
||||
cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason === 'outside area';
|
||||
|
||||
console.log('--- assertions ---');
|
||||
console.log('only the in-area Emergency Warning shows:', shown.join(', ') || '(none)');
|
||||
console.log('planned burn filtered by threshold:', cap.shouldShow(byLevel['Planned Burn'], SCREEN, { now }).reason);
|
||||
console.log('distant watch-and-act filtered by geofence:', cap.shouldShow(byLevel['Watch and Act'], SCREEN, { now }).reason);
|
||||
|
||||
// lat/lon flip sanity: the screen point must NOT be found if we naively swap to lon,lat
|
||||
const swapped = { lat: SCREEN.lon, lon: SCREEN.lat };
|
||||
const ew = byLevel['Emergency Warning'];
|
||||
console.log('flip guard (swapped coords should be OUTSIDE):', cap.pointInAlertArea(swapped, ew) ? 'FAIL (matched)' : 'ok (no match)');
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
3
Examples/PIP-Crypto-Ticker/.gitignore
vendored
Normal file
3
Examples/PIP-Crypto-Ticker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
112
Examples/PIP-Crypto-Ticker/README.md
Normal file
112
Examples/PIP-Crypto-Ticker/README.md
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
# PiP Crypto Ticker
|
||||
|
||||
A live cryptocurrency **price ticker** for ScreenTinker screens. Polls
|
||||
[CoinGecko](https://www.coingecko.com/en/api)'s keyless `simple/price` endpoint and
|
||||
pushes a wide ticker-strip overlay via the **PiP API**. Each poll refreshes the same
|
||||
overlay in place; prices update without a flash.
|
||||
|
||||
No API key required. Zero runtime dependencies (Node 18+ global `fetch`).
|
||||
|
||||
```
|
||||
┌────────────────────────────────────────────────────────────────┐
|
||||
│ BTC $64,012.34 ▲ +1.23% • ETH $3,380.10 ▼ -0.46% • SOL … │
|
||||
└────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## How it works
|
||||
|
||||
1. `ticker.js` fetches `GET /api/v3/simple/price?ids=…&vs_currencies=…&include_24hr_change=true`.
|
||||
2. It normalises the response into ordered items and encodes them compactly into the
|
||||
overlay URL's query string (`items=BTC:64012.34:+1.23,…`).
|
||||
3. It pushes a `type: "web"` PiP overlay (`duration: 0`, i.e. persistent) pointing at
|
||||
`ticker-overlay.html`, which renders the strip. Up = green ▲, down = red ▼, flat = grey.
|
||||
4. On the next poll it pushes again — the player keeps a single overlay slot
|
||||
(last-show-wins), so the numbers refresh in place.
|
||||
5. `Ctrl-C` (SIGINT) clears the overlay.
|
||||
|
||||
## Files
|
||||
|
||||
| file | purpose |
|
||||
|------|---------|
|
||||
| `ticker.js` | poller + PiP pusher (and the pure, exported normaliser/encoder) |
|
||||
| `ticker-overlay.html` / `ticker-overlay.js` | the overlay page (served by the signage server) |
|
||||
| `config.example.json` | copy to `config.json` and fill in |
|
||||
| `fixture-prices.json` | a saved CoinGecko response for the offline test |
|
||||
| `test.js` | offline test — no network, no PiP push |
|
||||
|
||||
## Setup
|
||||
|
||||
The overlay page must be served **same-origin** with the signage server (the player
|
||||
loads it in an iframe, and the server CSP only allows same-origin scripts). Copy the
|
||||
two overlay files into the server's static frontend directory:
|
||||
|
||||
```sh
|
||||
cp ticker-overlay.html ticker-overlay.js /path/to/screentinker/frontend/
|
||||
```
|
||||
|
||||
Then they're reachable at `https://<your-server>/ticker-overlay.html`.
|
||||
|
||||
Create a **full-scope** `st_` API token in the dashboard (Settings → API tokens), then:
|
||||
|
||||
```sh
|
||||
cp config.example.json config.json
|
||||
# edit config.json: api_base, api_token, overlay_base_url, device_id, coins
|
||||
node ticker.js
|
||||
```
|
||||
|
||||
`device_id` may be a single device **or** a device group id.
|
||||
|
||||
### Config
|
||||
|
||||
| key | meaning |
|
||||
|-----|---------|
|
||||
| `api_base` | signage server base URL |
|
||||
| `api_token` | full-scope `st_` token |
|
||||
| `overlay_base_url` | URL of the served `ticker-overlay.html` |
|
||||
| `device_id` | target device or group id |
|
||||
| `vs_currency` | `usd`, `eur`, `gbp`, … |
|
||||
| `coins` | array of `{ id, symbol }` — `id` is the CoinGecko id |
|
||||
| `poll_interval_sec` | refresh cadence (default 120; respect CoinGecko rate limits) |
|
||||
| `position` | `bottom-right` (default), `top-left`, … |
|
||||
| `width` / `height` | overlay box px (default 1100×110) |
|
||||
|
||||
## Local quick-start (this machine)
|
||||
|
||||
A local ScreenTinker instance is already running on `https://localhost:3443` with a
|
||||
paired web player (device `DEVICE_OR_GROUP_ID`). It uses a self-signed
|
||||
cert, so set `NODE_TLS_REJECT_UNAUTHORIZED=0`.
|
||||
|
||||
```sh
|
||||
# 1. serve the overlay assets from the local frontend dir
|
||||
cp ticker-overlay.html ticker-overlay.js /home/owner/Downloads/remote_display/frontend/
|
||||
|
||||
# 2. config.json
|
||||
cat > config.json <<'JSON'
|
||||
{
|
||||
"api_base": "https://localhost:3443/",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://localhost:3443/ticker-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
"vs_currency": "usd",
|
||||
"coins": [
|
||||
{ "id": "bitcoin", "symbol": "BTC" },
|
||||
{ "id": "ethereum", "symbol": "ETH" },
|
||||
{ "id": "solana", "symbol": "SOL" }
|
||||
],
|
||||
"poll_interval_sec": 120,
|
||||
"position": "bottom-right"
|
||||
}
|
||||
JSON
|
||||
|
||||
# 3. run
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node ticker.js
|
||||
```
|
||||
|
||||
## Test (offline)
|
||||
|
||||
```sh
|
||||
npm test
|
||||
```
|
||||
|
||||
Validates price/percent formatting, up/down/flat direction, and that the compact
|
||||
`items` encoding round-trips through the overlay's decoder. Prints `RESULT: PASS ✅`.
|
||||
21
Examples/PIP-Crypto-Ticker/config.example.json
Normal file
21
Examples/PIP-Crypto-Ticker/config.example.json
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/ticker-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"vs_currency": "usd",
|
||||
"coins": [
|
||||
{ "id": "bitcoin", "symbol": "BTC" },
|
||||
{ "id": "ethereum", "symbol": "ETH" },
|
||||
{ "id": "solana", "symbol": "SOL" },
|
||||
{ "id": "cardano", "symbol": "ADA" }
|
||||
],
|
||||
|
||||
"poll_interval_sec": 120,
|
||||
"position": "bottom-right",
|
||||
"width": 1100,
|
||||
"height": 110,
|
||||
"border_radius": 14,
|
||||
"opacity": 1
|
||||
}
|
||||
6
Examples/PIP-Crypto-Ticker/fixture-prices.json
Normal file
6
Examples/PIP-Crypto-Ticker/fixture-prices.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"bitcoin": { "usd": 64012.34, "usd_24h_change": 1.2345 },
|
||||
"ethereum": { "usd": 3380.1, "usd_24h_change": -0.4567 },
|
||||
"solana": { "usd": 152.4, "usd_24h_change": 0.002 },
|
||||
"cardano": { "usd": 0.3821, "usd_24h_change": -2.8 }
|
||||
}
|
||||
12
Examples/PIP-Crypto-Ticker/package.json
Normal file
12
Examples/PIP-Crypto-Ticker/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-crypto-ticker",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: poll CoinGecko (keyless) and push a live crypto price ticker to ScreenTinker screens via the PiP API.",
|
||||
"type": "commonjs",
|
||||
"main": "ticker.js",
|
||||
"scripts": {
|
||||
"start": "node ticker.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
62
Examples/PIP-Crypto-Ticker/test.js
Normal file
62
Examples/PIP-Crypto-Ticker/test.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
'use strict';
|
||||
|
||||
// Offline test: no network, no PiP push. Proves the normaliser formats prices and
|
||||
// changes, derives direction from the 24h change, and that the compact items
|
||||
// encoding round-trips through the overlay's decoder.
|
||||
|
||||
const fs = require('fs');
|
||||
const t = require('./ticker');
|
||||
|
||||
const raw = JSON.parse(fs.readFileSync('./fixture-prices.json', 'utf8'));
|
||||
const coins = [
|
||||
{ id: 'bitcoin', symbol: 'BTC' },
|
||||
{ id: 'ethereum', symbol: 'ETH' },
|
||||
{ id: 'solana', symbol: 'SOL' },
|
||||
{ id: 'cardano', symbol: 'ADA' },
|
||||
];
|
||||
|
||||
const items = t.normalise(raw, { coins, vs_currency: 'usd' });
|
||||
|
||||
console.log('Normalised ticker items:\n');
|
||||
for (const i of items) {
|
||||
console.log(`• ${i.symbol} ${i.priceStr} ${i.changeStr} (${i.dir})`);
|
||||
}
|
||||
|
||||
const encoded = t.encodeItems(items);
|
||||
const decoded = t.decodeItems(encoded);
|
||||
console.log(`\nencoded: ${encoded}\n`);
|
||||
|
||||
function eq(a, b, msg) { if (a !== b) { console.error(` ✗ ${msg}: got ${JSON.stringify(a)} want ${JSON.stringify(b)}`); return false; } return true; }
|
||||
|
||||
let ok = true;
|
||||
// order + count preserved
|
||||
ok = eq(items.length, 4, 'item count') && ok;
|
||||
ok = eq(items.map(i => i.symbol).join(','), 'BTC,ETH,SOL,ADA', 'symbol order') && ok;
|
||||
|
||||
// formatting: thousands separators, decimal precision by magnitude
|
||||
ok = eq(items[0].priceStr, '64,012.34', 'BTC thousands+2dp') && ok;
|
||||
ok = eq(items[0].changeStr, '+1.23%', 'BTC change sign') && ok;
|
||||
ok = eq(items[0].dir, 'up', 'BTC dir') && ok;
|
||||
|
||||
ok = eq(items[1].priceStr, '3,380.10', 'ETH trailing zero') && ok;
|
||||
ok = eq(items[1].dir, 'down', 'ETH dir (negative)') && ok;
|
||||
|
||||
// near-zero change rounds to flat
|
||||
ok = eq(items[2].changeStr, '+0.00%', 'SOL ~0 change') && ok;
|
||||
ok = eq(items[2].dir, 'flat', 'SOL dir flat') && ok;
|
||||
|
||||
// sub-$1 coin gets extra decimals, no thousands grouping
|
||||
ok = eq(items[3].priceStr, '0.3821', 'ADA 4dp sub-dollar') && ok;
|
||||
ok = eq(items[3].dir, 'down', 'ADA dir') && ok;
|
||||
|
||||
// round-trip: decoded display fields match the normaliser's
|
||||
ok = eq(decoded.length, items.length, 'decoded count') && ok;
|
||||
for (let k = 0; k < items.length; k++) {
|
||||
ok = eq(decoded[k].symbol, items[k].symbol, `rt[${k}] symbol`) && ok;
|
||||
ok = eq(decoded[k].priceStr, items[k].priceStr, `rt[${k}] priceStr`) && ok;
|
||||
ok = eq(decoded[k].changeStr, items[k].changeStr, `rt[${k}] changeStr`) && ok;
|
||||
ok = eq(decoded[k].dir, items[k].dir, `rt[${k}] dir`) && ok;
|
||||
}
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
31
Examples/PIP-Crypto-Ticker/ticker-overlay.html
Normal file
31
Examples/PIP-Crypto-Ticker/ticker-overlay.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Crypto Ticker</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
display: flex; align-items: stretch; }
|
||||
.strip { flex: 1; display: flex; align-items: center; gap: 10px;
|
||||
background: #14161a; color: #fff; border-radius: 14px; overflow: hidden;
|
||||
padding: 0 16px; box-shadow: 0 8px 28px rgba(0,0,0,.45); }
|
||||
.row { display: flex; align-items: center; gap: clamp(14px, 3vw, 34px);
|
||||
overflow: hidden; white-space: nowrap; width: 100%; }
|
||||
.chip { display: inline-flex; align-items: baseline; gap: 8px; }
|
||||
.sym { font-weight: 800; letter-spacing: .04em; font-size: clamp(15px, 3.2vw, 26px); }
|
||||
.price { font-weight: 600; font-variant-numeric: tabular-nums; font-size: clamp(15px, 3.2vw, 26px); }
|
||||
.chg { font-weight: 700; font-variant-numeric: tabular-nums; font-size: clamp(13px, 2.6vw, 20px); }
|
||||
.up { color: #2ecc71; }
|
||||
.down { color: #ff5b5b; }
|
||||
.flat { color: #9aa0a6; }
|
||||
.dot { color: #3a3f47; }
|
||||
.empty { color: #9aa0a6; font-size: clamp(14px, 3vw, 22px); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="strip"><div class="row" id="row"></div></div>
|
||||
<script src="ticker-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
62
Examples/PIP-Crypto-Ticker/ticker-overlay.js
Normal file
62
Examples/PIP-Crypto-Ticker/ticker-overlay.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Parses the compact `items` query (SYMBOL:rawprice:signedchange, comma-joined) and
|
||||
// renders a horizontal ticker strip. Mirrors decodeItems() in ticker.js.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var items = (q.get('items') || '').trim();
|
||||
var cur = (q.get('cur') || 'usd').toLowerCase();
|
||||
var CUR = { usd: '$', eur: '€', gbp: '£', jpy: '¥', aud: 'A$', cad: 'C$' };
|
||||
var sym = CUR[cur] || '';
|
||||
|
||||
function addThousands(numStr) {
|
||||
var neg = numStr.charAt(0) === '-';
|
||||
var s = neg ? numStr.slice(1) : numStr;
|
||||
var parts = s.split('.');
|
||||
parts[0] = parts[0].replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return (neg ? '-' : '') + parts.join('.');
|
||||
}
|
||||
function dirOf(chg) {
|
||||
var r = Number(parseFloat(chg).toFixed(2));
|
||||
return r > 0 ? 'up' : (r < 0 ? 'down' : 'flat');
|
||||
}
|
||||
function arrow(dir) { return dir === 'up' ? '▲' : dir === 'down' ? '▼' : '■'; }
|
||||
|
||||
var row = document.getElementById('row');
|
||||
var toks = items ? items.split(',').filter(Boolean) : [];
|
||||
if (toks.length === 0) {
|
||||
var e = document.createElement('span');
|
||||
e.className = 'empty';
|
||||
e.textContent = 'No market data';
|
||||
row.appendChild(e);
|
||||
return;
|
||||
}
|
||||
|
||||
toks.forEach(function (tok, idx) {
|
||||
var p = tok.split(':');
|
||||
var symbol = p[0] || '';
|
||||
var priceRaw = p[1] || '0';
|
||||
var chg = p[2] || '+0.00';
|
||||
var dir = dirOf(chg);
|
||||
|
||||
var chip = document.createElement('span');
|
||||
chip.className = 'chip';
|
||||
|
||||
var s = document.createElement('span');
|
||||
s.className = 'sym'; s.textContent = symbol;
|
||||
|
||||
var pr = document.createElement('span');
|
||||
pr.className = 'price'; pr.textContent = sym + addThousands(priceRaw);
|
||||
|
||||
var c = document.createElement('span');
|
||||
c.className = 'chg ' + dir; c.textContent = arrow(dir) + ' ' + chg + '%';
|
||||
|
||||
chip.appendChild(s); chip.appendChild(pr); chip.appendChild(c);
|
||||
row.appendChild(chip);
|
||||
|
||||
if (idx < toks.length - 1) {
|
||||
var dot = document.createElement('span');
|
||||
dot.className = 'dot'; dot.textContent = '•';
|
||||
row.appendChild(dot);
|
||||
}
|
||||
});
|
||||
})();
|
||||
209
Examples/PIP-Crypto-Ticker/ticker.js
Normal file
209
Examples/PIP-Crypto-Ticker/ticker.js
Normal file
|
|
@ -0,0 +1,209 @@
|
|||
'use strict';
|
||||
|
||||
// Crypto price ticker -> ScreenTinker PiP overlay.
|
||||
//
|
||||
// Polls CoinGecko's keyless simple/price endpoint and pushes a wide "ticker strip"
|
||||
// web overlay to a device or group. Each poll refreshes the same overlay slot
|
||||
// (last-show-wins on the player), so prices update in place. The overlay is
|
||||
// persistent (duration 0) and is cleared on SIGINT/SIGTERM.
|
||||
//
|
||||
// node ticker.js [path/to/config.json]
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---- pure helpers (exported for offline tests) --------------------------------
|
||||
|
||||
const CUR_SYMBOL = { usd: '$', eur: '€', gbp: '£', jpy: '¥', aud: 'A$', cad: 'C$' };
|
||||
|
||||
// Decimals scale with magnitude: cheap coins need more precision than BTC.
|
||||
function priceDecimals(p) {
|
||||
const a = Math.abs(Number(p) || 0);
|
||||
if (a >= 1) return 2;
|
||||
if (a >= 0.01) return 4;
|
||||
return 6;
|
||||
}
|
||||
|
||||
// Group the integer part with thousands separators; keep the fractional part as-is.
|
||||
function addThousands(numStr) {
|
||||
const neg = numStr.startsWith('-');
|
||||
const s = neg ? numStr.slice(1) : numStr;
|
||||
const [int, frac] = s.split('.');
|
||||
const grouped = int.replace(/\B(?=(\d{3})+(?!\d))/g, ',');
|
||||
return (neg ? '-' : '') + grouped + (frac != null ? '.' + frac : '');
|
||||
}
|
||||
|
||||
// Raw (delimiter-safe) numeric price string: fixed decimals, NO thousands commas.
|
||||
function priceRaw(p) { return (Number(p) || 0).toFixed(priceDecimals(p)); }
|
||||
|
||||
// Display price string: thousands-separated.
|
||||
function formatPrice(p) { return addThousands(priceRaw(p)); }
|
||||
|
||||
// Signed change, 2 decimals, no % (compact for the query). e.g. "+1.23", "-0.45".
|
||||
function signedChange(c) {
|
||||
const n = Number(c) || 0;
|
||||
return (n >= 0 ? '+' : '') + n.toFixed(2);
|
||||
}
|
||||
|
||||
// Display change with % suffix. e.g. "+1.23%".
|
||||
function formatChange(c) { return signedChange(c) + '%'; }
|
||||
|
||||
// Direction from the rounded 2-decimal change, so it matches what's displayed.
|
||||
function dirOf(c) {
|
||||
const r = Number((Number(c) || 0).toFixed(2));
|
||||
if (r > 0) return 'up';
|
||||
if (r < 0) return 'down';
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
// CoinGecko simple/price response -> normalised items, preserving config coin order.
|
||||
// raw[coinId][vs] = price ; raw[coinId][vs+"_24h_change"] = pct change
|
||||
function normalise(raw, opts = {}) {
|
||||
const vs = (opts.vs_currency || 'usd').toLowerCase();
|
||||
const coins = opts.coins || [];
|
||||
const out = [];
|
||||
for (const coin of coins) {
|
||||
const entry = raw && raw[coin.id];
|
||||
if (!entry || entry[vs] == null) continue;
|
||||
const price = Number(entry[vs]);
|
||||
const change = Number(entry[`${vs}_24h_change`]) || 0;
|
||||
out.push({
|
||||
symbol: coin.symbol || coin.id.toUpperCase(),
|
||||
price,
|
||||
priceStr: formatPrice(price),
|
||||
change24h: change,
|
||||
changeStr: formatChange(change),
|
||||
dir: dirOf(change),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Compact, comma/colon-delimited encoding for the overlay query string.
|
||||
// "BTC:64012.34:+1.23,ETH:3380.10:-0.45"
|
||||
function encodeItems(items) {
|
||||
return items.map(i => `${i.symbol}:${priceRaw(i.price)}:${signedChange(i.change24h)}`).join(',');
|
||||
}
|
||||
|
||||
// Inverse of encodeItems — mirrors the parser in ticker-overlay.js. Returns the
|
||||
// display-ready shape so a test can prove the round-trip survives.
|
||||
function decodeItems(s) {
|
||||
if (!s) return [];
|
||||
return s.split(',').filter(Boolean).map(tok => {
|
||||
const [symbol, priceRawStr, chg] = tok.split(':');
|
||||
return {
|
||||
symbol,
|
||||
priceStr: addThousands(priceRawStr),
|
||||
changeStr: chg + '%',
|
||||
dir: dirOf(parseFloat(chg)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// ---- live runner --------------------------------------------------------------
|
||||
|
||||
function cgUrl(coins, vs) {
|
||||
const ids = coins.map(c => c.id).join(',');
|
||||
const q = new URLSearchParams({ ids, vs_currencies: vs, include_24hr_change: 'true' });
|
||||
return `https://api.coingecko.com/api/v3/simple/price?${q.toString()}`;
|
||||
}
|
||||
|
||||
function overlayUri(base, items, vs) {
|
||||
const q = new URLSearchParams({ items: encodeItems(items), cur: vs });
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const API_TOKEN = cfg.api_token;
|
||||
const OVERLAY_BASE = cfg.overlay_base_url;
|
||||
const DEVICE = cfg.device_id;
|
||||
const COINS = cfg.coins || [];
|
||||
const VS = (cfg.vs_currency || 'usd').toLowerCase();
|
||||
const POLL_SEC = cfg.poll_interval_sec || 120;
|
||||
const POS = cfg.position || 'bottom-right';
|
||||
const WIDTH = cfg.width || 1100;
|
||||
const HEIGHT = cfg.height || 110;
|
||||
|
||||
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE || COINS.length === 0) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, device_id, and at least one coin.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let pipId = null;
|
||||
|
||||
async function show(items) {
|
||||
const body = {
|
||||
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, items, VS),
|
||||
position: POS, width: WIDTH, height: HEIGHT, duration: 0,
|
||||
opacity: cfg.opacity != null ? cfg.opacity : 1,
|
||||
border_radius: cfg.border_radius != null ? cfg.border_radius : 14,
|
||||
close_button: false,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
pipId = json.pip_id;
|
||||
return items;
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
if (!pipId) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
let raw;
|
||||
try {
|
||||
const res = await fetch(cgUrl(COINS, VS), { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`CoinGecko HTTP ${res.status}`);
|
||||
raw = await res.json();
|
||||
} catch (e) { console.error(`[${new Date().toISOString()}] fetch error: ${e.message}`); return; }
|
||||
|
||||
const items = normalise(raw, { coins: COINS, vs_currency: VS });
|
||||
if (items.length === 0) { console.error(`[${new Date().toISOString()}] no prices for configured coins`); return; }
|
||||
try {
|
||||
await show(items);
|
||||
const line = items.map(i => `${i.symbol} ${CUR_SYMBOL[VS] || ''}${i.priceStr} ${i.changeStr}`).join(' | ');
|
||||
console.log(`[${new Date().toISOString()}] SHOW ticker pip=${pipId} :: ${line}`);
|
||||
} catch (e) { console.error(`[${new Date().toISOString()}] show error: ${e.message}`); }
|
||||
}
|
||||
|
||||
console.log(`Crypto ticker starting — ${COINS.map(c => c.symbol).join(', ')} in ${VS.toUpperCase()}, poll every ${POLL_SEC}s`);
|
||||
await tick();
|
||||
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(timer);
|
||||
console.log('\nclearing ticker overlay before exit...');
|
||||
await clear();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
priceDecimals, addThousands, priceRaw, formatPrice,
|
||||
signedChange, formatChange, dirOf, normalise, encodeItems, decodeItems,
|
||||
cgUrl, overlayUri,
|
||||
};
|
||||
|
||||
if (require.main === module) main();
|
||||
3
Examples/PIP-Event-Countdown/.gitignore
vendored
Normal file
3
Examples/PIP-Event-Countdown/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
103
Examples/PIP-Event-Countdown/README.md
Normal file
103
Examples/PIP-Event-Countdown/README.md
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
# PiP Event Countdown
|
||||
|
||||
Push a **live, self-clearing countdown overlay** to a ScreenTinker screen (or group) with
|
||||
the PiP API. The overlay ticks down `DD : HH : MM : SS` in real time and — the fun part —
|
||||
**removes itself the instant the target time arrives**. There is no clearing poll: the
|
||||
script sets the PiP `duration` to "seconds until the target", so the player drops the
|
||||
overlay at exactly zero and shows a quick 🎉 first.
|
||||
|
||||
Great for: New Year's Eve, product launches, store opening / closing, shift changes,
|
||||
webinar "starts in…", conference session timers, "back in 15 minutes".
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
countdown.js --(POST /api/pip, type:web, duration = seconds-to-target)--> player
|
||||
|
|
||||
overlay_base_url/countdown-overlay.html?target=<ms>&title=<text> |
|
||||
v
|
||||
countdown-overlay.js ticks the clock every second; at zero shows 🎉 <title>
|
||||
...and the player auto-removes the PiP at the same moment (duration elapsed)
|
||||
```
|
||||
|
||||
`countdown.js` is a **one-shot** push — it doesn't stay running. Re-run it to change the
|
||||
target or title; the player keeps last-show-wins, so the new overlay replaces the old.
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `countdown.js` | Computes seconds-to-target and pushes one PiP. `--clear` removes it early. |
|
||||
| `countdown-overlay.html` / `countdown-overlay.js` | The overlay page the player loads in an iframe. Must be served by your ScreenTinker host (same-origin with the player). |
|
||||
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||
| `test.js` | Offline unit test of the date math (`npm test`). |
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Mint a token.** In the dashboard create an API token with the **`full`** scope
|
||||
(PiP is fleet-affecting and can render arbitrary web content, so it requires `full`).
|
||||
|
||||
2. **Serve the overlay assets.** Copy `countdown-overlay.html` and `countdown-overlay.js`
|
||||
into the directory your ScreenTinker server serves at the web root (the same place
|
||||
`index.html` is served from — the `frontend/` dir in this repo). They must be reachable
|
||||
at `overlay_base_url`, and **same-origin** with the player so the server's CSP
|
||||
(`script-src 'self'`) allows `countdown-overlay.js`. (Inline scripts are blocked by the
|
||||
CSP — that's why the JS is a separate file.)
|
||||
|
||||
3. **Configure.**
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# edit config.json: api_base, api_token, overlay_base_url, device_id, target, title
|
||||
```
|
||||
|
||||
4. **Run.**
|
||||
```bash
|
||||
node countdown.js
|
||||
# or override target/title on the CLI:
|
||||
node countdown.js "2026-07-04T21:00:00-05:00" "Fireworks!"
|
||||
# clear it early:
|
||||
node countdown.js --clear
|
||||
```
|
||||
|
||||
## config.json
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `api_base` | Base URL of your ScreenTinker server, e.g. `https://signage.example.com`. |
|
||||
| `api_token` | A `full`-scope `st_…` token. |
|
||||
| `overlay_base_url` | Public URL of `countdown-overlay.html` (served by your host). |
|
||||
| `device_id` | A device **or** group id to show on. |
|
||||
| `target` | Target datetime, any `Date.parse`-able string (ISO 8601 recommended, include a TZ offset). |
|
||||
| `title` | Heading shown above the clock, and the 🎉 message at zero. |
|
||||
| `position` | `center` (default), `top-right`, `top-left`, `bottom-right`, `bottom-left`. |
|
||||
|
||||
## Local quick-start (this repo's dev instance)
|
||||
|
||||
The dev server runs at `https://localhost:3443/` with a self-signed cert, so disable TLS
|
||||
verification for the run. Copy the overlay assets into the served `frontend/` dir first so
|
||||
`https://localhost:3443/countdown-overlay.html` resolves.
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# config.json:
|
||||
# "api_base": "https://localhost:3443/"
|
||||
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
|
||||
# "overlay_base_url": "https://localhost:3443/countdown-overlay.html"
|
||||
# "device_id": "DEVICE_OR_GROUP_ID"
|
||||
# "target": a time ~2 minutes out, e.g. "2026-06-18T19:42:00-05:00"
|
||||
# "title": "Demo"
|
||||
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node countdown.js
|
||||
```
|
||||
|
||||
Watch the screen count down and disappear on its own at zero. (`config.json` is
|
||||
git-ignored so your token never gets committed.)
|
||||
|
||||
## Notes & limits
|
||||
|
||||
- The PiP `duration` caps at **24h (86400s)**. For a target more than a day out the
|
||||
overlay still shows, but it can't auto-clear at zero — re-run within 24h of the target
|
||||
for the self-clear effect. The script warns you when the target is beyond the cap.
|
||||
- PiP is **ephemeral**: it isn't part of the device's saved layout, so a player reboot
|
||||
clears it. Re-run `countdown.js` after a reboot if needed.
|
||||
- Offline devices are reported, not queued — show it while the screen is online.
|
||||
11
Examples/PIP-Event-Countdown/config.example.json
Normal file
11
Examples/PIP-Event-Countdown/config.example.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/countdown-overlay.html",
|
||||
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"target": "2026-12-31T23:59:59-06:00",
|
||||
"title": "Happy New Year",
|
||||
"position": "center"
|
||||
}
|
||||
41
Examples/PIP-Event-Countdown/countdown-overlay.html
Normal file
41
Examples/PIP-Event-Countdown/countdown-overlay.html
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Countdown</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 14px; background: #14161c; color: #fff; border-radius: 16px; overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 20px 24px; box-sizing: border-box; }
|
||||
.title { font-size: clamp(20px, 5vw, 40px); font-weight: 800; letter-spacing: .02em; text-align: center;
|
||||
line-height: 1.1; }
|
||||
.clock { display: flex; gap: clamp(10px, 3vw, 28px); align-items: flex-start; }
|
||||
.unit { display: flex; flex-direction: column; align-items: center; min-width: clamp(48px, 12vw, 96px); }
|
||||
.num { font-variant-numeric: tabular-nums; font-weight: 800; font-size: clamp(34px, 11vw, 88px);
|
||||
line-height: 1; color: #fff; }
|
||||
.lbl { margin-top: 8px; font-size: clamp(11px, 2.4vw, 16px); text-transform: uppercase; letter-spacing: .12em;
|
||||
color: #9aa0aa; }
|
||||
.sep { font-weight: 800; font-size: clamp(28px, 9vw, 70px); line-height: 1; color: #4b5160; padding-top: 2px; }
|
||||
.done .num, .done .sep { color: #58d68d; }
|
||||
.celebrate { font-size: clamp(30px, 8vw, 68px); font-weight: 800; text-align: center; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card" id="card">
|
||||
<div class="title" id="title">Countdown</div>
|
||||
<div class="clock" id="clock">
|
||||
<div class="unit"><span class="num" id="d">00</span><span class="lbl">Days</span></div>
|
||||
<span class="sep">:</span>
|
||||
<div class="unit"><span class="num" id="h">00</span><span class="lbl">Hours</span></div>
|
||||
<span class="sep">:</span>
|
||||
<div class="unit"><span class="num" id="m">00</span><span class="lbl">Min</span></div>
|
||||
<span class="sep">:</span>
|
||||
<div class="unit"><span class="num" id="s">00</span><span class="lbl">Sec</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="countdown-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
53
Examples/PIP-Event-Countdown/countdown-overlay.js
Normal file
53
Examples/PIP-Event-Countdown/countdown-overlay.js
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads ?target (epoch ms) and ?title from the URL and ticks a live DD:HH:MM:SS clock.
|
||||
// When the target arrives it switches to a celebratory state. The PiP itself is removed
|
||||
// by the player at the same moment (duration = seconds-to-target), so this is the visual
|
||||
// that the viewer sees right before it vanishes.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var target = parseInt(q.get('target'), 10);
|
||||
var title = (q.get('title') || 'Countdown').trim();
|
||||
|
||||
document.getElementById('title').textContent = title;
|
||||
|
||||
var pad = function (n) { return (n < 10 ? '0' : '') + n; };
|
||||
var elD = document.getElementById('d');
|
||||
var elH = document.getElementById('h');
|
||||
var elM = document.getElementById('m');
|
||||
var elS = document.getElementById('s');
|
||||
var clock = document.getElementById('clock');
|
||||
var card = document.getElementById('card');
|
||||
|
||||
function tick() {
|
||||
var secs = Math.ceil((target - Date.now()) / 1000);
|
||||
if (!isFinite(target)) { return; }
|
||||
if (secs <= 0) {
|
||||
celebrate();
|
||||
return;
|
||||
}
|
||||
var s = secs;
|
||||
var days = Math.floor(s / 86400); s -= days * 86400;
|
||||
var hours = Math.floor(s / 3600); s -= hours * 3600;
|
||||
var mins = Math.floor(s / 60); s -= mins * 60;
|
||||
elD.textContent = pad(days);
|
||||
elH.textContent = pad(hours);
|
||||
elM.textContent = pad(mins);
|
||||
elS.textContent = pad(s);
|
||||
}
|
||||
|
||||
var celebrated = false;
|
||||
function celebrate() {
|
||||
if (celebrated) { return; }
|
||||
celebrated = true;
|
||||
clearInterval(timer);
|
||||
clock.classList.add('done');
|
||||
elD.textContent = '00'; elH.textContent = '00'; elM.textContent = '00'; elS.textContent = '00';
|
||||
var c = document.createElement('div');
|
||||
c.className = 'celebrate';
|
||||
c.textContent = '🎉 ' + title;
|
||||
card.appendChild(c);
|
||||
}
|
||||
|
||||
tick();
|
||||
var timer = setInterval(tick, 1000);
|
||||
})();
|
||||
156
Examples/PIP-Event-Countdown/countdown.js
Normal file
156
Examples/PIP-Event-Countdown/countdown.js
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
'use strict';
|
||||
|
||||
// Countdown -> ScreenTinker PiP. Pushes ONE live countdown overlay to a device or
|
||||
// group and lets the player auto-clear it the instant the target time arrives, using
|
||||
// the PiP `duration` field (duration = seconds-to-target, so no clear poll is needed).
|
||||
//
|
||||
// node countdown.js [path/to/config.json]
|
||||
// node countdown.js "2026-12-31T23:59:59-06:00" "Happy New Year" # CLI override
|
||||
// node countdown.js [config] --clear # clear it early
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const PIP_DUR_MAX = 86400; // PiP API duration cap (seconds)
|
||||
|
||||
// --- pure, testable helpers (no I/O, explicit `now` so tests are deterministic) ---
|
||||
|
||||
// Whole seconds from `now` until `target` (both epoch ms), rounded UP so the last
|
||||
// partial second still counts. <= 0 means the moment has already passed.
|
||||
function secondsToTarget(target, now) {
|
||||
return Math.ceil((target - now) / 1000);
|
||||
}
|
||||
|
||||
// Split a non-negative second count into d/h/m/s. Negative clamps to zero.
|
||||
function breakdown(seconds) {
|
||||
let s = Math.max(0, Math.floor(seconds));
|
||||
const days = Math.floor(s / 86400); s -= days * 86400;
|
||||
const hours = Math.floor(s / 3600); s -= hours * 3600;
|
||||
const minutes = Math.floor(s / 60); s -= minutes * 60;
|
||||
return { days, hours, minutes, seconds: s };
|
||||
}
|
||||
|
||||
// PiP duration to request: seconds-to-target, but never above the API cap. For targets
|
||||
// more than 24h out the overlay won't auto-clear at zero (it'd hit the cap first); the
|
||||
// CLI warns in that case. 0 would mean "until cleared", which we never want here.
|
||||
function durationForTarget(seconds) {
|
||||
return Math.max(1, Math.min(seconds, PIP_DUR_MAX));
|
||||
}
|
||||
|
||||
module.exports = { secondsToTarget, breakdown, durationForTarget, PIP_DUR_MAX };
|
||||
|
||||
// --- CLI ---
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const clear = args.includes('--clear');
|
||||
const positional = args.filter(a => !a.startsWith('--'));
|
||||
|
||||
// First positional that isn't an ISO date is treated as the config path.
|
||||
let cfgPath = path.join(__dirname, 'config.json');
|
||||
let cliTarget = null, cliTitle = null;
|
||||
if (positional.length && Number.isFinite(Date.parse(positional[0]))) {
|
||||
cliTarget = positional[0];
|
||||
cliTitle = positional[1] || null;
|
||||
} else if (positional.length) {
|
||||
cfgPath = positional[0];
|
||||
if (positional[1] && Number.isFinite(Date.parse(positional[1]))) {
|
||||
cliTarget = positional[1];
|
||||
cliTitle = positional[2] || null;
|
||||
}
|
||||
}
|
||||
|
||||
let cfg = {};
|
||||
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
|
||||
catch (e) {
|
||||
if (!cliTarget) { console.error(`Could not read config at ${cfgPath}: ${e.message}`); process.exit(1); }
|
||||
}
|
||||
|
||||
const apiBase = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const apiToken = cfg.api_token;
|
||||
const overlayBase = cfg.overlay_base_url;
|
||||
const deviceId = cfg.device_id;
|
||||
const targetIso = cliTarget || cfg.target;
|
||||
const title = cliTitle || cfg.title || 'Countdown';
|
||||
const position = cfg.position || 'center';
|
||||
|
||||
if (!apiBase || !apiToken || !deviceId) {
|
||||
console.error('config must set api_base, api_token, and device_id.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (clear) { return doClear(apiBase, apiToken, deviceId); }
|
||||
|
||||
if (!overlayBase) { console.error('config must set overlay_base_url for a countdown overlay.'); process.exit(1); }
|
||||
const targetMs = Date.parse(targetIso);
|
||||
if (!Number.isFinite(targetMs)) { console.error(`invalid target datetime: ${targetIso}`); process.exit(1); }
|
||||
|
||||
const now = Date.now();
|
||||
const secs = secondsToTarget(targetMs, now);
|
||||
if (secs <= 0) {
|
||||
console.log(`"${title}" target ${targetIso} has already passed — nothing to show.`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (secs > PIP_DUR_MAX) {
|
||||
const b = breakdown(secs);
|
||||
console.warn(`note: target is ${b.days}d ${b.hours}h away (> 24h). The overlay will show but auto-clear caps at 24h; re-run within 24h of the target for the self-clear-at-zero effect.`);
|
||||
}
|
||||
|
||||
showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs });
|
||||
}
|
||||
|
||||
function overlayUri(overlayBase, targetMs, title) {
|
||||
const q = new URLSearchParams({ target: String(targetMs), title: title || '' });
|
||||
return `${overlayBase}${overlayBase.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
async function showCountdown({ apiBase, apiToken, deviceId, overlayBase, targetMs, title, position, secs }) {
|
||||
const duration = durationForTarget(secs);
|
||||
const body = {
|
||||
device_id: deviceId,
|
||||
type: 'web',
|
||||
uri: overlayUri(overlayBase, targetMs, title),
|
||||
position,
|
||||
width: 820,
|
||||
height: 300,
|
||||
duration,
|
||||
border_radius: 16,
|
||||
close_button: false,
|
||||
title,
|
||||
};
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||
const b = breakdown(secs);
|
||||
console.log(`SHOW "${title}" pip=${json.pip_id} target=${new Date(targetMs).toISOString()}`);
|
||||
console.log(`auto-clears in ${secs}s (${b.days}d ${b.hours}h ${b.minutes}m ${b.seconds}s) — player drops it at zero, no clear call needed.`);
|
||||
} catch (e) {
|
||||
console.error(`pip show failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function doClear(apiBase, apiToken, deviceId) {
|
||||
try {
|
||||
const res = await fetch(`${apiBase}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||
console.log(`CLEAR sent to ${deviceId} (sent=${json.sent ?? '?'} offline=${json.offline ?? '?'})`);
|
||||
} catch (e) {
|
||||
console.error(`pip clear failed: ${e.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
12
Examples/PIP-Event-Countdown/package.json
Normal file
12
Examples/PIP-Event-Countdown/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-event-countdown",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: push a live, self-clearing countdown overlay to ScreenTinker screens via the PiP API.",
|
||||
"type": "commonjs",
|
||||
"main": "countdown.js",
|
||||
"scripts": {
|
||||
"start": "node countdown.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
44
Examples/PIP-Event-Countdown/test.js
Normal file
44
Examples/PIP-Event-Countdown/test.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use strict';
|
||||
|
||||
// Offline unit test for the pure countdown helpers. No network, no player.
|
||||
const { secondsToTarget, breakdown, durationForTarget, PIP_DUR_MAX } = require('./countdown');
|
||||
|
||||
let ok = true;
|
||||
function check(name, cond) {
|
||||
console.log(`${cond ? '•' : '✗'} ${name}`);
|
||||
if (!cond) ok = false;
|
||||
}
|
||||
|
||||
// Fixed reference instant so the test is deterministic.
|
||||
const now = Date.parse('2026-06-18T12:00:00-05:00');
|
||||
|
||||
// 1 day, 2 hours, 3 minutes, 4 seconds in the future.
|
||||
const futureSecs = 1 * 86400 + 2 * 3600 + 3 * 60 + 4; // 93784
|
||||
const future = now + futureSecs * 1000;
|
||||
|
||||
const s1 = secondsToTarget(future, now);
|
||||
check(`secondsToTarget future = ${futureSecs}`, s1 === futureSecs);
|
||||
|
||||
const b1 = breakdown(s1);
|
||||
check('breakdown days/hours/min/sec', b1.days === 1 && b1.hours === 2 && b1.minutes === 3 && b1.seconds === 4);
|
||||
|
||||
// Round UP: 1.4s out still counts as 2 whole seconds remaining.
|
||||
check('secondsToTarget rounds up partial second', secondsToTarget(now + 1400, now) === 2);
|
||||
|
||||
// Past target -> non-positive.
|
||||
check('past target <= 0', secondsToTarget(now - 5000, now) <= 0);
|
||||
|
||||
// Exactly now -> 0.
|
||||
check('exactly now == 0', secondsToTarget(now, now) === 0);
|
||||
|
||||
// breakdown clamps negatives to zero.
|
||||
const bz = breakdown(-50);
|
||||
check('breakdown clamps negative to 0', bz.days === 0 && bz.hours === 0 && bz.minutes === 0 && bz.seconds === 0);
|
||||
|
||||
// duration clamp: under the cap is unchanged, over the cap is clamped, zero floors to 1.
|
||||
check('durationForTarget passes through under cap', durationForTarget(3600) === 3600);
|
||||
check('durationForTarget clamps to 24h cap', durationForTarget(PIP_DUR_MAX + 999) === PIP_DUR_MAX);
|
||||
check('durationForTarget floors to >=1', durationForTarget(0) === 1);
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
3
Examples/PIP-Fundraiser-Thermometer/.gitignore
vendored
Normal file
3
Examples/PIP-Fundraiser-Thermometer/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
96
Examples/PIP-Fundraiser-Thermometer/README.md
Normal file
96
Examples/PIP-Fundraiser-Thermometer/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# PiP Fundraiser Thermometer
|
||||
|
||||
Pushes a **goal-progress "thermometer"** overlay to a ScreenTinker screen (or group) via
|
||||
the PiP API. Reads a tiny JSON progress doc, computes the percentage, and shows a filling
|
||||
bar with the amount raised, the goal, and the percent. It re-pushes on every poll so the
|
||||
bar updates in place, and clears the overlay when you stop it.
|
||||
|
||||
```
|
||||
progress.json ──poll──▶ thermo.js ──POST /api/pip──▶ ScreenTinker ──▶ screen
|
||||
{raised,goal} (web overlay, duration 0 = persistent)
|
||||
```
|
||||
|
||||
Great for lobby displays, telethons, membership drives, "miles walked", etc.
|
||||
|
||||
## Data source
|
||||
|
||||
A small JSON document, from a local file **or** a URL:
|
||||
|
||||
```json
|
||||
{ "campaign": "Community Garden", "raised": 12450, "goal": 20000, "currency": "USD" }
|
||||
```
|
||||
|
||||
- `source_file` — a path (relative to this dir or absolute). Update the file and the next
|
||||
poll picks it up.
|
||||
- `source_url` — any endpoint returning that JSON (e.g. a Google Sheet published as JSON,
|
||||
a CRM webhook target, your own little script). If both are set, `source_url` wins.
|
||||
|
||||
Supported currency symbols: USD/CAD/AUD/NZD `$`, EUR `€`, GBP `£`, JPY `¥`, INR `₹`.
|
||||
Anything else renders as `CODE 1,234`.
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Host the overlay page.** Copy both overlay files into the ScreenTinker server's
|
||||
frontend directory so they're served same-origin (the server's CSP only allows the
|
||||
external `<script src>` when it's same-origin):
|
||||
|
||||
```
|
||||
cp thermo-overlay.html thermo-overlay.js /path/to/screentinker/frontend/
|
||||
```
|
||||
|
||||
They'll be served at `https://<your-server>/thermo-overlay.html`.
|
||||
|
||||
2. **Create your config:**
|
||||
|
||||
```
|
||||
cp config.example.json config.json
|
||||
```
|
||||
|
||||
Set `api_base`, `api_token` (an `st_` token with the **`full`** scope), `device_id`
|
||||
(a device **or** group id), `overlay_base_url` (the hosted `thermo-overlay.html`), and
|
||||
either `source_file` or `source_url`. Optional: `position` (default `bottom-left`),
|
||||
`width`/`height`, `poll_interval_sec` (default 60), `currency`.
|
||||
|
||||
3. **Run it:**
|
||||
|
||||
```
|
||||
npm start
|
||||
# or: node thermo.js config.json
|
||||
```
|
||||
|
||||
Stop with Ctrl-C — it clears the overlay on the way out.
|
||||
|
||||
## Local quick-start (this repo's dev server)
|
||||
|
||||
The local ScreenTinker dev instance serves on `https://localhost:3443` with a self-signed
|
||||
cert, so prefix commands with `NODE_TLS_REJECT_UNAUTHORIZED=0`:
|
||||
|
||||
```bash
|
||||
cp thermo-overlay.html thermo-overlay.js ../../frontend/ # serve same-origin
|
||||
cp config.example.json config.json
|
||||
# edit config.json:
|
||||
# "api_base": "https://localhost:3443/",
|
||||
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
# "overlay_base_url": "https://localhost:3443/thermo-overlay.html",
|
||||
# "device_id": "DEVICE_OR_GROUP_ID",
|
||||
# "source_file": "progress.example.json"
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node thermo.js config.json
|
||||
```
|
||||
|
||||
Edit `progress.example.json` (bump `raised`) and watch the bar climb on the next poll.
|
||||
When `raised >= goal` the overlay shows **Goal reached! 🎉**.
|
||||
|
||||
## Test
|
||||
|
||||
```
|
||||
npm test
|
||||
```
|
||||
|
||||
Offline unit tests for the money formatter and the progress math
|
||||
(`62.25%` → label `62%`, clamps over 100%, divide-by-zero-safe goal). Prints `RESULT: PASS`.
|
||||
|
||||
## Notes
|
||||
|
||||
- PiP overlays are **ephemeral** — a player reboot drops them; the next poll re-pushes.
|
||||
- `device_id` may be a group id to fan out to every screen in the group.
|
||||
- Cents are dropped on purpose (whole units read better on a wall display).
|
||||
16
Examples/PIP-Fundraiser-Thermometer/config.example.json
Normal file
16
Examples/PIP-Fundraiser-Thermometer/config.example.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/thermo-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"source_file": "progress.example.json",
|
||||
"_source_url_alt": "https://example.com/fundraiser.json",
|
||||
|
||||
"currency": "USD",
|
||||
"poll_interval_sec": 60,
|
||||
|
||||
"position": "bottom-left",
|
||||
"width": 460,
|
||||
"height": 360
|
||||
}
|
||||
12
Examples/PIP-Fundraiser-Thermometer/package.json
Normal file
12
Examples/PIP-Fundraiser-Thermometer/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-fundraiser-thermometer",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: push a fundraiser goal-progress thermometer overlay to ScreenTinker screens via the PiP API.",
|
||||
"type": "commonjs",
|
||||
"main": "thermo.js",
|
||||
"scripts": {
|
||||
"start": "node thermo.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"campaign": "Community Garden",
|
||||
"raised": 12450,
|
||||
"goal": 20000,
|
||||
"currency": "USD"
|
||||
}
|
||||
54
Examples/PIP-Fundraiser-Thermometer/test.js
Normal file
54
Examples/PIP-Fundraiser-Thermometer/test.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
'use strict';
|
||||
|
||||
const t = require('./thermo');
|
||||
|
||||
const checks = [];
|
||||
const eq = (name, got, want) => checks.push({ name, ok: got === want, got, want });
|
||||
|
||||
// money formatting
|
||||
eq('formatMoney USD', t.formatMoney(12450, 'USD'), '$12,450');
|
||||
eq('formatMoney EUR', t.formatMoney(12450, 'EUR'), '€12,450');
|
||||
eq('formatMoney GBP', t.formatMoney(1234567, 'GBP'), '£1,234,567');
|
||||
eq('formatMoney unknown code', t.formatMoney(2500, 'BTC'), 'BTC 2,500');
|
||||
eq('formatMoney small', t.formatMoney(999, 'USD'), '$999');
|
||||
eq('formatMoney rounds', t.formatMoney(12450.7, 'USD'), '$12,451');
|
||||
eq('groupThousands', t.groupThousands(1000000), '1,000,000');
|
||||
|
||||
// progress
|
||||
const p1 = t.computeProgress({ raised: 12450, goal: 20000 });
|
||||
eq('pct 12450/20000', p1.pct, 62.25);
|
||||
eq('pctLabel 12450/20000', p1.pctLabel, '62%');
|
||||
|
||||
const p2 = t.computeProgress({ raised: 25000, goal: 20000 });
|
||||
eq('clamp over 100 pct', p2.pct, 100);
|
||||
eq('clamp over 100 label', p2.pctLabel, '100%');
|
||||
|
||||
const p3 = t.computeProgress({ raised: 500, goal: 0 });
|
||||
eq('goal 0 -> 0 pct', p3.pct, 0);
|
||||
eq('goal 0 -> 0 label', p3.pctLabel, '0%');
|
||||
|
||||
const p4 = t.computeProgress({ raised: 0, goal: 20000 });
|
||||
eq('zero raised', p4.pct, 0);
|
||||
|
||||
// normalise + uri
|
||||
const v = t.normalise({ campaign: 'Community Garden', raised: 12450, goal: 20000, currency: 'USD' });
|
||||
eq('normalise campaign', v.campaign, 'Community Garden');
|
||||
eq('normalise raisedLabel', v.raisedLabel, '$12,450');
|
||||
eq('normalise goalLabel', v.goalLabel, '$20,000');
|
||||
eq('normalise pctLabel', v.pctLabel, '62%');
|
||||
|
||||
const uri = t.overlayUri('https://s/thermo-overlay.html', v);
|
||||
const parsed = new URL(uri);
|
||||
eq('uri campaign round-trips', parsed.searchParams.get('campaign'), 'Community Garden');
|
||||
eq('uri raised round-trips', parsed.searchParams.get('raised'), '$12,450');
|
||||
eq('uri pct round-trips', parsed.searchParams.get('pct'), '62.25');
|
||||
|
||||
let pass = 0;
|
||||
for (const c of checks) {
|
||||
console.log(`${c.ok ? '✓' : '✗'} ${c.name}` + (c.ok ? '' : ` got=${JSON.stringify(c.got)} want=${JSON.stringify(c.want)}`));
|
||||
if (c.ok) pass++;
|
||||
}
|
||||
const ok = pass === checks.length;
|
||||
console.log(`\n${pass}/${checks.length} checks`);
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
46
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.html
Normal file
46
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.html
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Fundraiser Thermometer</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45);
|
||||
padding: 18px 22px; box-sizing: border-box; }
|
||||
.campaign { font-size: clamp(16px, 4vw, 26px); font-weight: 800; letter-spacing: .01em; line-height: 1.15; }
|
||||
.stage { flex: 1; display: flex; align-items: stretch; gap: 18px; margin: 14px 0 10px; }
|
||||
/* vertical thermometer */
|
||||
.thermo { width: 30%; min-width: 64px; display: flex; align-items: flex-end; }
|
||||
.tube { position: relative; width: 100%; height: 100%; background: #2c2c2c; border-radius: 999px;
|
||||
overflow: hidden; border: 2px solid #3a3a3a; }
|
||||
.fill { position: absolute; left: 0; right: 0; bottom: 0; height: 0;
|
||||
background: linear-gradient(0deg, #1f9d55, #36d07f);
|
||||
transition: height 1.1s cubic-bezier(.22,.9,.31,1); }
|
||||
.readout { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 6px; }
|
||||
.pct { font-size: clamp(34px, 12vw, 72px); font-weight: 800; line-height: 1; color: #36d07f; }
|
||||
.pct.done { color: #ffd24a; }
|
||||
.amounts { font-size: clamp(15px, 3.4vw, 22px); }
|
||||
.amounts b { font-weight: 800; }
|
||||
.amounts .of { color: #b9b9b9; font-weight: 500; }
|
||||
.footer { font-size: clamp(12px, 2.4vw, 16px); color: #9a9a9a; }
|
||||
.done-banner { color: #ffd24a; font-weight: 800; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="campaign" id="campaign">Fundraiser</div>
|
||||
<div class="stage">
|
||||
<div class="thermo"><div class="tube"><div class="fill" id="fill"></div></div></div>
|
||||
<div class="readout">
|
||||
<div class="pct" id="pct">0%</div>
|
||||
<div class="amounts"><b id="raised">$0</b> <span class="of">of</span> <b id="goal">$0</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="footer" id="footer"></div>
|
||||
</div>
|
||||
<script src="thermo-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
32
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.js
Normal file
32
Examples/PIP-Fundraiser-Thermometer/thermo-overlay.js
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads the fundraiser fields from the URL query string and fills the thermometer.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||
|
||||
var pct = Math.max(0, Math.min(100, parseFloat(get('pct')) || 0));
|
||||
var pctLabel = get('pctLabel') || (Math.round(pct) + '%');
|
||||
var done = pct >= 100;
|
||||
|
||||
document.getElementById('campaign').textContent = get('campaign') || 'Fundraiser';
|
||||
document.getElementById('raised').textContent = get('raised') || '0';
|
||||
document.getElementById('goal').textContent = get('goal') || '0';
|
||||
|
||||
var pctEl = document.getElementById('pct');
|
||||
pctEl.textContent = pctLabel;
|
||||
if (done) pctEl.classList.add('done');
|
||||
|
||||
var footer = document.getElementById('footer');
|
||||
if (done) {
|
||||
footer.className = 'footer done-banner';
|
||||
footer.textContent = 'Goal reached! 🎉';
|
||||
} else {
|
||||
footer.textContent = 'Thank you for your support';
|
||||
}
|
||||
|
||||
// Animate the fill from 0 to pct after first paint.
|
||||
var fill = document.getElementById('fill');
|
||||
requestAnimationFrame(function () {
|
||||
requestAnimationFrame(function () { fill.style.height = pct + '%'; });
|
||||
});
|
||||
})();
|
||||
170
Examples/PIP-Fundraiser-Thermometer/thermo.js
Normal file
170
Examples/PIP-Fundraiser-Thermometer/thermo.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
'use strict';
|
||||
|
||||
// Fundraiser "thermometer" -> ScreenTinker PiP overlay.
|
||||
//
|
||||
// Reads a tiny JSON progress doc ({ campaign, raised, goal, currency }) from a local
|
||||
// file or a URL, computes the percentage, and pushes a persistent web overlay showing
|
||||
// a filling thermometer bar. Re-pushes each poll so the bar updates in place (the player
|
||||
// keeps a single overlay slot, last-show-wins). Clears the overlay on exit.
|
||||
//
|
||||
// node thermo.js [path/to/config.json]
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Currency symbols we render inline; anything else falls back to "CODE 1,234".
|
||||
const CURRENCY_SYMBOLS = { USD: '$', CAD: '$', AUD: '$', NZD: '$', EUR: '€', GBP: '£', JPY: '¥', INR: '₹' };
|
||||
|
||||
// Group an integer with thousands separators without locale surprises.
|
||||
function groupThousands(n) {
|
||||
const neg = n < 0;
|
||||
const digits = String(Math.abs(Math.round(n)));
|
||||
let out = '';
|
||||
for (let i = 0; i < digits.length; i++) {
|
||||
if (i > 0 && (digits.length - i) % 3 === 0) out += ',';
|
||||
out += digits[i];
|
||||
}
|
||||
return (neg ? '-' : '') + out;
|
||||
}
|
||||
|
||||
// "$12,450" / "€12,450" / "BTC 12,450" (whole units; cents are noise on a wall display).
|
||||
function formatMoney(amount, currency) {
|
||||
const code = String(currency || 'USD').toUpperCase();
|
||||
const sym = CURRENCY_SYMBOLS[code];
|
||||
const num = groupThousands(Number(amount) || 0);
|
||||
return sym ? `${sym}${num}` : `${code} ${num}`;
|
||||
}
|
||||
|
||||
// pct is raised/goal clamped to 0..100; pctLabel is the rounded whole-percent string.
|
||||
// Divide-by-zero-safe: goal <= 0 yields 0%.
|
||||
function computeProgress({ raised, goal }) {
|
||||
const r = Number(raised) || 0;
|
||||
const g = Number(goal) || 0;
|
||||
let pct = 0;
|
||||
if (g > 0) pct = (r / g) * 100;
|
||||
pct = Math.max(0, Math.min(100, pct));
|
||||
pct = Math.round(pct * 100) / 100; // keep 2dp for a smooth bar fill
|
||||
return { pct, pctLabel: `${Math.round(pct)}%` };
|
||||
}
|
||||
|
||||
// Raw progress doc -> the fields the overlay displays.
|
||||
function normalise(data, fallbackCurrency) {
|
||||
const currency = data.currency || fallbackCurrency || 'USD';
|
||||
const { pct, pctLabel } = computeProgress(data);
|
||||
return {
|
||||
campaign: data.campaign || 'Fundraiser',
|
||||
raisedLabel: formatMoney(data.raised, currency),
|
||||
goalLabel: formatMoney(data.goal, currency),
|
||||
currency,
|
||||
pct,
|
||||
pctLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function overlayUri(base, view) {
|
||||
const q = new URLSearchParams({
|
||||
campaign: view.campaign,
|
||||
raised: view.raisedLabel,
|
||||
goal: view.goalLabel,
|
||||
pct: String(view.pct),
|
||||
pctLabel: view.pctLabel,
|
||||
currency: view.currency,
|
||||
});
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
module.exports = { groupThousands, formatMoney, computeProgress, normalise, overlayUri };
|
||||
|
||||
// ---- runtime (skipped when imported by the test) ----
|
||||
if (require.main === module) {
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const API_TOKEN = cfg.api_token;
|
||||
const OVERLAY_BASE = cfg.overlay_base_url;
|
||||
const DEVICE = cfg.device_id;
|
||||
const POLL_SEC = cfg.poll_interval_sec || 60;
|
||||
const POSITION = cfg.position || 'bottom-left';
|
||||
const WIDTH = cfg.width || 460;
|
||||
const HEIGHT = cfg.height || 360;
|
||||
|
||||
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (!cfg.source_file && !cfg.source_url) {
|
||||
console.error('config must set source_file or source_url.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
let activePip = null;
|
||||
|
||||
async function readProgress() {
|
||||
if (cfg.source_url) {
|
||||
const res = await fetch(cfg.source_url, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) throw new Error(`source HTTP ${res.status}`);
|
||||
return await res.json();
|
||||
}
|
||||
const p = path.isAbsolute(cfg.source_file) ? cfg.source_file : path.join(__dirname, cfg.source_file);
|
||||
return JSON.parse(fs.readFileSync(p, 'utf8'));
|
||||
}
|
||||
|
||||
async function pipShow(view) {
|
||||
const body = {
|
||||
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, view),
|
||||
position: POSITION, width: WIDTH, height: HEIGHT,
|
||||
duration: 0, border_radius: 16, close_button: false,
|
||||
title: view.campaign,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
return json.pip_id;
|
||||
}
|
||||
|
||||
async function pipClear() {
|
||||
if (!activePip) return;
|
||||
try {
|
||||
await fetch(`${API_BASE}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({ device_id: DEVICE, pip_id: activePip }),
|
||||
});
|
||||
} catch { /* best effort */ }
|
||||
activePip = null;
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
try {
|
||||
const view = normalise(await readProgress(), cfg.currency);
|
||||
activePip = await pipShow(view);
|
||||
console.log(`[${new Date().toISOString()}] SHOW "${view.campaign}" ${view.raisedLabel} of ${view.goalLabel} (${view.pctLabel}) pip=${activePip}`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Fundraiser thermometer starting — poll every ${POLL_SEC}s, source=${cfg.source_url || cfg.source_file}`);
|
||||
await tick();
|
||||
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||
async function shutdown() {
|
||||
clearInterval(timer);
|
||||
console.log('\nclearing overlay before exit...');
|
||||
await pipClear();
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
main();
|
||||
}
|
||||
3
Examples/PIP-Incident-Webhook/.gitignore
vendored
Normal file
3
Examples/PIP-Incident-Webhook/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
108
Examples/PIP-Incident-Webhook/README.md
Normal file
108
Examples/PIP-Incident-Webhook/README.md
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
# PiP Incident Webhook
|
||||
|
||||
An **event-driven** PiP example: a tiny webhook receiver that turns your monitoring
|
||||
stack's alerts into a floating ScreenTinker overlay — perfect for an engineering wall
|
||||
TV or NOC screen.
|
||||
|
||||
- alert **firing** → red overlay appears (kept until cleared)
|
||||
- alert **resolved** → overlay disappears
|
||||
|
||||
Unlike the CAP / NOAA examples (which *poll* a feed), nothing happens here until your
|
||||
alerting system **pushes** to `POST /webhook`. Zero runtime dependencies — just Node 18+
|
||||
(`http` + global `fetch`).
|
||||
|
||||
## Payload shapes
|
||||
|
||||
It accepts either:
|
||||
|
||||
**Generic** (great for `curl`, cron jobs, custom scripts):
|
||||
```json
|
||||
{ "status": "firing", "key": "db-down", "title": "Primary DB unreachable", "detail": "conn refused on 5432", "severity": "critical" }
|
||||
```
|
||||
|
||||
**Prometheus Alertmanager** (point a `webhook_config` straight at it):
|
||||
```json
|
||||
{ "status": "firing", "alerts": [
|
||||
{ "status": "firing", "fingerprint": "abc123",
|
||||
"labels": { "alertname": "HighCPU", "severity": "warning", "instance": "web-1" },
|
||||
"annotations": { "summary": "CPU > 90%", "description": "web-1 hot for 5m" } }
|
||||
]}
|
||||
```
|
||||
|
||||
`severity` drives the band colour: `critical`→dark red, `warning`→orange, `info`→amber,
|
||||
anything else→red. The `key` (or Alertmanager `fingerprint`) is what matches a later
|
||||
*resolve* back to the overlay it should clear.
|
||||
|
||||
## Setup
|
||||
|
||||
1. `cp config.example.json config.json` and fill in:
|
||||
- `api_token` — an `st_` API token with the **`full`** scope.
|
||||
- `api_base` / `overlay_base_url` — your signage server.
|
||||
- `device_id` — a device **or** group id.
|
||||
- `shared_secret` *(optional)* — if set, callers must send it as the `X-Webhook-Secret`
|
||||
header or `?secret=` query param.
|
||||
2. **Serve the overlay assets.** The overlay is a `web` PiP rendered in an iframe, so the
|
||||
player fetches `overlay_base_url` directly. Copy `incident-overlay.html` and
|
||||
`incident-overlay.js` into the directory your signage server serves at the web root
|
||||
(e.g. the server's `frontend/` dir) so that `https://<server>/incident-overlay.html`
|
||||
resolves. They must be **same-origin** with the player (the server CSP only allows
|
||||
same-origin scripts — that's why the JS is an external `incident-overlay.js`, not inline).
|
||||
3. `node server.js` (or `npm start`).
|
||||
|
||||
## Local quick-start (this repo's dev server)
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# edit config.json:
|
||||
# "api_base": "https://localhost:3443/"
|
||||
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
|
||||
# "overlay_base_url": "https://localhost:3443/incident-overlay.html"
|
||||
# "device_id": "DEVICE_OR_GROUP_ID"
|
||||
|
||||
# copy the overlay assets into the server's web root (served same-origin as the player):
|
||||
cp incident-overlay.html incident-overlay.js ../../frontend/
|
||||
|
||||
# self-signed cert on localhost -> let Node accept it:
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node server.js
|
||||
```
|
||||
|
||||
Then drive it with `curl`:
|
||||
|
||||
```bash
|
||||
# fire a critical incident -> red overlay appears on the player
|
||||
curl -s localhost:8088/webhook -H 'Content-Type: application/json' -d \
|
||||
'{"status":"firing","key":"db-down","title":"Primary DB unreachable","detail":"conn refused on 5432","severity":"critical"}'
|
||||
|
||||
# ...later, resolve it -> overlay clears
|
||||
curl -s localhost:8088/webhook -H 'Content-Type: application/json' -d \
|
||||
'{"status":"resolved","key":"db-down"}'
|
||||
|
||||
# health
|
||||
curl -s localhost:8088/healthz
|
||||
```
|
||||
|
||||
`Ctrl-C` clears any still-showing overlays before exiting.
|
||||
|
||||
> Heads-up: this dev box has a shared player. If someone else is demoing on
|
||||
> `d7c88aa0-…`, point `device_id` at your own device/group instead.
|
||||
|
||||
## Wire up Alertmanager
|
||||
|
||||
```yaml
|
||||
# alertmanager.yml
|
||||
route:
|
||||
receiver: signage
|
||||
receivers:
|
||||
- name: signage
|
||||
webhook_configs:
|
||||
- url: http://YOUR_HOST:8088/webhook
|
||||
send_resolved: true # so "resolved" clears the overlay
|
||||
```
|
||||
|
||||
If you set a `shared_secret`, append it to the URL: `...:8088/webhook?secret=YOUR_SECRET`.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
npm test # offline; exercises both payload shapes + the colour map
|
||||
```
|
||||
15
Examples/PIP-Incident-Webhook/config.example.json
Normal file
15
Examples/PIP-Incident-Webhook/config.example.json
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"listen_port": 8088,
|
||||
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/incident-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"position": "top-right",
|
||||
"source_label": "Monitoring",
|
||||
|
||||
"shared_secret": null,
|
||||
|
||||
"overlay": { "width": 760, "height": 280, "border_radius": 16, "opacity": 1 }
|
||||
}
|
||||
40
Examples/PIP-Incident-Webhook/incident-overlay.html
Normal file
40
Examples/PIP-Incident-Webhook/incident-overlay.html
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Incident</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
|
||||
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(16px, 3.4vw, 26px); }
|
||||
.band .pulse { width: 15px; height: 15px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||
animation: pulse 1.1s ease-in-out infinite; flex: none; }
|
||||
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||
.band .badge { margin-left: auto; font-size: clamp(11px, 2vw, 14px); font-weight: 700;
|
||||
letter-spacing: .08em; padding: 3px 10px; border-radius: 999px; background: rgba(0,0,0,.28); }
|
||||
.body { padding: 16px 24px 18px; display: flex; flex-direction: column; gap: 8px; flex: 1; }
|
||||
.title { font-size: clamp(19px, 4.4vw, 34px); font-weight: 700; line-height: 1.15; }
|
||||
.detail { font-size: clamp(14px, 2.8vw, 20px); color: #d6d6d6; line-height: 1.3;
|
||||
overflow: hidden; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; }
|
||||
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="band" id="band">
|
||||
<span class="pulse"></span><span id="level">INCIDENT</span>
|
||||
<span class="badge" id="badge"></span>
|
||||
</div>
|
||||
<div class="body">
|
||||
<div class="title" id="title"></div>
|
||||
<div class="detail" id="detail"></div>
|
||||
<div class="footer"><span id="source"></span> <span id="updated"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="incident-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
23
Examples/PIP-Incident-Webhook/incident-overlay.js
Normal file
23
Examples/PIP-Incident-Webhook/incident-overlay.js
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads the incident fields from the URL query string and paints the card.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||
|
||||
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || 'CC0000');
|
||||
document.getElementById('band').style.background = color;
|
||||
|
||||
var sev = (get('severity') || 'alert');
|
||||
document.getElementById('level').textContent = (get('level') || 'INCIDENT').toUpperCase();
|
||||
document.getElementById('badge').textContent = sev.toUpperCase();
|
||||
|
||||
document.getElementById('title').textContent = get('title') || 'Service incident';
|
||||
document.getElementById('detail').textContent = get('detail') || '';
|
||||
document.getElementById('source').textContent = get('source') || '';
|
||||
|
||||
var updated = get('updated');
|
||||
if (updated) {
|
||||
var d = new Date(updated);
|
||||
document.getElementById('updated').textContent = isNaN(d) ? ('· ' + updated) : ('· ' + d.toLocaleString());
|
||||
}
|
||||
})();
|
||||
12
Examples/PIP-Incident-Webhook/package.json
Normal file
12
Examples/PIP-Incident-Webhook/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-incident-webhook",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: an inbound webhook receiver (Alertmanager / generic) that pushes a red ScreenTinker PiP overlay on incident firing and clears it on resolve.",
|
||||
"type": "commonjs",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
235
Examples/PIP-Incident-Webhook/server.js
Normal file
235
Examples/PIP-Incident-Webhook/server.js
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
'use strict';
|
||||
|
||||
// Event-driven PiP: an inbound webhook receiver. Instead of polling a feed, it waits for
|
||||
// your monitoring stack to PUSH it incidents, then shows / clears a ScreenTinker PiP overlay
|
||||
// in real time:
|
||||
// - status "firing" -> POST /api/pip (red overlay, kept until cleared)
|
||||
// - status "resolved" -> POST /api/pip/clear
|
||||
//
|
||||
// Accepts two payload shapes on POST /webhook:
|
||||
// (a) generic { status:"firing"|"resolved", key, title, detail, severity }
|
||||
// (b) Alertmanager{ status, alerts:[{ status, labels:{alertname,severity,...},
|
||||
// annotations:{summary,description}, fingerprint }] }
|
||||
//
|
||||
// node server.js [path/to/config.json]
|
||||
//
|
||||
// Node 18+ (built-in http + global fetch). Needs an st_ API token with the 'full' scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const http = require('http');
|
||||
|
||||
// --- pure logic (unit-tested in test.js; no network) -------------------------------------
|
||||
|
||||
// severity -> overlay band colour (#RRGGBB, the PiP colour contract).
|
||||
const SEV_COLORS = { critical: '7B0000', warning: 'E8730C', info: 'F2C200' };
|
||||
const DEFAULT_COLOR = 'CC0000';
|
||||
|
||||
function colorFor(severity) {
|
||||
return SEV_COLORS[String(severity || '').toLowerCase()] || DEFAULT_COLOR;
|
||||
}
|
||||
|
||||
// Map "firing"/"resolved" (and Alertmanager's per-alert status) to our two states.
|
||||
function stateOf(status) {
|
||||
return String(status || '').toLowerCase() === 'resolved' ? 'resolved' : 'firing';
|
||||
}
|
||||
|
||||
// Normalise either payload shape into a flat list of incidents:
|
||||
// { key, state:"firing"|"resolved", title, detail, severity }
|
||||
// `key` is the stable identity used to match a later resolve to its overlay.
|
||||
function normalise(payload) {
|
||||
const p = payload || {};
|
||||
const out = [];
|
||||
|
||||
if (Array.isArray(p.alerts)) {
|
||||
// Alertmanager group webhook. Each alert may carry its own status; fall back to the
|
||||
// group status. fingerprint is Alertmanager's stable per-alert id.
|
||||
for (const a of p.alerts) {
|
||||
const labels = a.labels || {};
|
||||
const ann = a.annotations || {};
|
||||
const name = labels.alertname || ann.summary || 'alert';
|
||||
out.push({
|
||||
key: a.fingerprint || `${name}:${JSON.stringify(labels.instance || labels.job || '')}`,
|
||||
state: stateOf(a.status || p.status),
|
||||
title: ann.summary || name,
|
||||
detail: ann.description || '',
|
||||
severity: (labels.severity || 'warning').toLowerCase(),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Generic single-incident shape.
|
||||
const name = p.title || p.key || 'incident';
|
||||
out.push({
|
||||
key: p.key || name,
|
||||
state: stateOf(p.status),
|
||||
title: p.title || name,
|
||||
detail: p.detail || '',
|
||||
severity: (p.severity || 'warning').toLowerCase(),
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// Build the overlay iframe URL from an incident.
|
||||
function overlayUri(base, inc, sourceLabel, nowIso) {
|
||||
const q = new URLSearchParams({
|
||||
level: 'incident',
|
||||
title: inc.title || '',
|
||||
detail: inc.detail || '',
|
||||
severity: inc.severity || '',
|
||||
color: colorFor(inc.severity),
|
||||
source: sourceLabel || '',
|
||||
updated: nowIso || '',
|
||||
});
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
module.exports = { colorFor, stateOf, normalise, overlayUri, SEV_COLORS, DEFAULT_COLOR };
|
||||
|
||||
// --- server (only when run directly) -----------------------------------------------------
|
||||
|
||||
if (require.main === module) {
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
const PORT = cfg.listen_port || 8088;
|
||||
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const API_TOKEN = cfg.api_token;
|
||||
const OVERLAY_BASE = cfg.overlay_base_url;
|
||||
const DEVICE = cfg.device_id;
|
||||
const POSITION = cfg.position || 'top-right';
|
||||
const SOURCE_LABEL = cfg.source_label || 'Monitoring';
|
||||
const SECRET = cfg.shared_secret || null;
|
||||
const OVERLAY = cfg.overlay || {};
|
||||
|
||||
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, and device_id.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// key -> pip_id of the overlay currently showing for that incident.
|
||||
const active = new Map();
|
||||
const nowIso = () => new Date().toISOString();
|
||||
|
||||
async function pipShow(inc) {
|
||||
const body = {
|
||||
device_id: DEVICE, type: 'web', uri: overlayUri(OVERLAY_BASE, inc, SOURCE_LABEL, nowIso()),
|
||||
position: POSITION,
|
||||
width: OVERLAY.width || 760, height: OVERLAY.height || 280,
|
||||
duration: 0, // keep until we clear it on resolve
|
||||
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
|
||||
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
|
||||
close_button: false,
|
||||
title: inc.title,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
return json.pip_id;
|
||||
}
|
||||
|
||||
async function pipClear(pipId) {
|
||||
const res = await fetch(`${API_BASE}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({ device_id: DEVICE, pip_id: pipId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleIncidents(incidents) {
|
||||
const summary = { fired: 0, cleared: 0, skipped: 0 };
|
||||
for (const inc of incidents) {
|
||||
if (!inc.key) { summary.skipped++; continue; }
|
||||
try {
|
||||
if (inc.state === 'firing') {
|
||||
if (active.has(inc.key)) { // refresh: clear the old card, show the new
|
||||
try { await pipClear(active.get(inc.key)); } catch { /* best effort */ }
|
||||
}
|
||||
const pipId = await pipShow(inc);
|
||||
active.set(inc.key, pipId);
|
||||
summary.fired++;
|
||||
console.log(`[${nowIso()}] FIRING "${inc.title}" (${inc.severity}) key=${inc.key} pip=${pipId}`);
|
||||
} else {
|
||||
const pipId = active.get(inc.key);
|
||||
if (pipId) {
|
||||
await pipClear(pipId);
|
||||
active.delete(inc.key);
|
||||
summary.cleared++;
|
||||
console.log(`[${nowIso()}] RESOLVED key=${inc.key} pip=${pipId} (cleared)`);
|
||||
} else {
|
||||
summary.skipped++;
|
||||
console.log(`[${nowIso()}] RESOLVED key=${inc.key} (nothing showing)`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
summary.skipped++;
|
||||
console.error(`[${nowIso()}] error for key=${inc.key}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
function authOk(req, url) {
|
||||
if (!SECRET) return true;
|
||||
const hdr = req.headers['x-webhook-secret'];
|
||||
const qs = url.searchParams.get('secret');
|
||||
return hdr === SECRET || qs === SECRET;
|
||||
}
|
||||
|
||||
function readBody(req, cap = 1_000_000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let n = 0; const chunks = [];
|
||||
req.on('data', (c) => { n += c.length; if (n > cap) { reject(new Error('body too large')); req.destroy(); } else chunks.push(c); });
|
||||
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
||||
req.on('error', reject);
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = new URL(req.url, `http://localhost:${PORT}`);
|
||||
const send = (code, obj) => { res.writeHead(code, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
|
||||
|
||||
if (req.method === 'GET' && url.pathname === '/healthz') {
|
||||
return send(200, { ok: true, active: active.size });
|
||||
}
|
||||
if (req.method !== 'POST' || url.pathname !== '/webhook') {
|
||||
return send(404, { error: 'POST /webhook or GET /healthz' });
|
||||
}
|
||||
if (!authOk(req, url)) return send(401, { error: 'bad or missing shared secret' });
|
||||
|
||||
let payload;
|
||||
try { payload = JSON.parse(await readBody(req) || '{}'); }
|
||||
catch (e) { return send(400, { error: `invalid JSON: ${e.message}` }); }
|
||||
|
||||
const incidents = normalise(payload);
|
||||
const summary = await handleIncidents(incidents);
|
||||
send(200, { ok: true, received: incidents.length, ...summary });
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Incident webhook receiver listening on :${PORT}`);
|
||||
console.log(` POST /webhook (generic or Alertmanager JSON)${SECRET ? ' [shared secret required]' : ''}`);
|
||||
console.log(` GET /healthz`);
|
||||
console.log(` -> device ${DEVICE} @ ${API_BASE}, overlay ${OVERLAY_BASE}, position ${POSITION}`);
|
||||
});
|
||||
|
||||
async function shutdown() {
|
||||
console.log('\nclearing active overlays before exit...');
|
||||
for (const pipId of active.values()) { try { await pipClear(pipId); } catch { /* best effort */ } }
|
||||
server.close(() => process.exit(0));
|
||||
setTimeout(() => process.exit(0), 1500).unref();
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
55
Examples/PIP-Incident-Webhook/test.js
Normal file
55
Examples/PIP-Incident-Webhook/test.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
'use strict';
|
||||
|
||||
// Offline unit test for the pure normalise()/colorFor()/overlayUri() logic. No network.
|
||||
const { normalise, colorFor, overlayUri } = require('./server');
|
||||
|
||||
let ok = true;
|
||||
const check = (cond, msg) => { console.log(`${cond ? '✓' : '✗'} ${msg}`); if (!cond) ok = false; };
|
||||
|
||||
// --- generic shape: firing -----------------------------------------------------------------
|
||||
const gFire = normalise({ status: 'firing', key: 'db-down', title: 'Primary DB unreachable', detail: 'conn refused on 5432', severity: 'critical' });
|
||||
check(gFire.length === 1, 'generic firing -> 1 incident');
|
||||
check(gFire[0].key === 'db-down', 'generic key preserved');
|
||||
check(gFire[0].state === 'firing', 'generic state=firing');
|
||||
check(gFire[0].title === 'Primary DB unreachable', 'generic title');
|
||||
check(gFire[0].severity === 'critical', 'generic severity');
|
||||
|
||||
// --- generic shape: resolved ---------------------------------------------------------------
|
||||
const gRes = normalise({ status: 'RESOLVED', key: 'db-down' });
|
||||
check(gRes[0].state === 'resolved', 'generic resolved (case-insensitive) -> state=resolved');
|
||||
check(gRes[0].key === 'db-down', 'generic resolved key matches the firing key');
|
||||
|
||||
// --- Alertmanager shape: mixed firing + resolved -------------------------------------------
|
||||
const am = normalise({
|
||||
status: 'firing',
|
||||
alerts: [
|
||||
{ status: 'firing', fingerprint: 'abc123',
|
||||
labels: { alertname: 'HighCPU', severity: 'warning', instance: 'web-1' },
|
||||
annotations: { summary: 'CPU > 90%', description: 'web-1 hot for 5m' } },
|
||||
{ status: 'resolved', fingerprint: 'def456',
|
||||
labels: { alertname: 'DiskFull', severity: 'critical' },
|
||||
annotations: { summary: 'Disk 99%', description: '/var almost full' } },
|
||||
],
|
||||
});
|
||||
check(am.length === 2, 'alertmanager -> 2 incidents');
|
||||
check(am[0].key === 'abc123' && am[0].state === 'firing', 'AM[0] fingerprint key + firing');
|
||||
check(am[0].title === 'CPU > 90%' && am[0].detail === 'web-1 hot for 5m', 'AM[0] summary/description mapped');
|
||||
check(am[0].severity === 'warning', 'AM[0] severity from labels');
|
||||
check(am[1].key === 'def456' && am[1].state === 'resolved', 'AM[1] resolved per-alert status overrides group');
|
||||
check(am[1].severity === 'critical', 'AM[1] severity critical');
|
||||
|
||||
// --- severity -> colour --------------------------------------------------------------------
|
||||
check(colorFor('critical') === '7B0000', 'colour critical');
|
||||
check(colorFor('warning') === 'E8730C', 'colour warning');
|
||||
check(colorFor('info') === 'F2C200', 'colour info');
|
||||
check(colorFor('weird') === 'CC0000', 'colour default fallback');
|
||||
check(colorFor() === 'CC0000', 'colour missing -> default');
|
||||
|
||||
// --- overlay uri ---------------------------------------------------------------------------
|
||||
const uri = overlayUri('https://x/incident-overlay.html', am[0], 'Alertmanager', '2026-06-18T10:00:00Z');
|
||||
check(uri.startsWith('https://x/incident-overlay.html?'), 'uri keeps base + adds query');
|
||||
check(/color=E8730C/.test(uri), 'uri carries severity colour');
|
||||
check(/title=CPU/.test(uri), 'uri carries title');
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
3
Examples/PIP-News-Ticker/.gitignore
vendored
Normal file
3
Examples/PIP-News-Ticker/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
96
Examples/PIP-News-Ticker/README.md
Normal file
96
Examples/PIP-News-Ticker/README.md
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
# PIP News Ticker
|
||||
|
||||
A scrolling RSS/Atom headline ticker pushed to a ScreenTinker screen (or group) via the
|
||||
PiP overlay API. Polls any feed, extracts the latest headlines, and renders a continuous
|
||||
right-to-left strip along the bottom of the screen. Keyless and zero-dependency.
|
||||
|
||||
```
|
||||
RSS/Atom feed ──poll──> news.js ──POST /api/pip (type:web)──> player
|
||||
│ │
|
||||
parse headlines iframe loads news-overlay.html
|
||||
join with separator scrolls the strip seamlessly
|
||||
```
|
||||
|
||||
The overlay is **persistent** (`duration: 0`) and refreshed on every poll (the player keeps a
|
||||
single overlay slot, last-show-wins), so headlines update in place. The ticker is cleared when
|
||||
you stop the script (Ctrl-C).
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `news.js` | Poller + PiP pusher. Hand-rolled RSS/Atom parser (`parseHeadlines`, `feedLabel`). |
|
||||
| `news-overlay.html` / `news-overlay.js` | The strip overlay. Served same-origin; reads `?text`/`?label`/`?sep`; external JS (no inline) so the server CSP allows it. |
|
||||
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||
| `fixture-feed.xml`, `test.js` | Offline test (no network). |
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Host the overlay.** Copy both overlay files into the signage server's web root so they're
|
||||
served from the same origin as the player (the server applies `Content-Security-Policy:
|
||||
script-src 'self'`, which is why the JS is external rather than inline):
|
||||
|
||||
```sh
|
||||
cp news-overlay.html news-overlay.js /path/to/screentinker/frontend/
|
||||
```
|
||||
|
||||
They'll be reachable at `https://<your-server>/news-overlay.html`.
|
||||
|
||||
2. **Create an API token** with the `full` scope (PiP is a fleet-affecting, full-trust action).
|
||||
|
||||
3. **Configure.** Copy `config.example.json` to `config.json` and set `api_base`, `api_token`,
|
||||
`overlay_base_url`, `device_id` (a device **or** group id), and your `feed_url`. Optional:
|
||||
`label` (left chip text; defaults to the feed's channel title), `max_items`, `separator`,
|
||||
`poll_interval_sec`, and overlay geometry (`position`, `width`, `height`).
|
||||
|
||||
4. **Run.**
|
||||
|
||||
```sh
|
||||
npm start # or: node news.js
|
||||
```
|
||||
|
||||
Stop with Ctrl-C to clear the ticker.
|
||||
|
||||
## Local quick-start (self-signed dev server)
|
||||
|
||||
Against a local ScreenTinker dev instance with a self-signed certificate:
|
||||
|
||||
```sh
|
||||
cp news-overlay.html news-overlay.js /path/to/screentinker/frontend/
|
||||
|
||||
cat > config.json <<'JSON'
|
||||
{
|
||||
"api_base": "https://localhost:3443/",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://localhost:3443/news-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
"feed_url": "https://feeds.bbci.co.uk/news/rss.xml",
|
||||
"position": "bottom-right",
|
||||
"width": 1200,
|
||||
"height": 90,
|
||||
"poll_interval_sec": 300
|
||||
}
|
||||
JSON
|
||||
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node news.js
|
||||
```
|
||||
|
||||
`NODE_TLS_REJECT_UNAUTHORIZED=0` is only for trusting the dev box's self-signed cert — don't
|
||||
use it against production.
|
||||
|
||||
## Test
|
||||
|
||||
```sh
|
||||
npm test
|
||||
```
|
||||
|
||||
Runs `test.js` against `fixture-feed.xml` (offline): verifies headline extraction order,
|
||||
CDATA/entity decoding, `max_items` capping, channel-title labelling, and overlay-URI round-trip.
|
||||
Prints `RESULT: PASS ✅`.
|
||||
|
||||
## Notes
|
||||
|
||||
- The parser handles RSS (`<item><title>`) and Atom (`<entry><title>`), decodes CDATA and common
|
||||
XML entities, and strips stray markup from titles. It's deliberately tolerant rather than a full
|
||||
XML parser, so it copes with the messy real-world feeds you'll point it at.
|
||||
- Headline text is rendered with `textContent` only — feed content is never injected as HTML.
|
||||
18
Examples/PIP-News-Ticker/config.example.json
Normal file
18
Examples/PIP-News-Ticker/config.example.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/news-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"feed_url": "https://feeds.bbci.co.uk/news/rss.xml",
|
||||
"label": null,
|
||||
"max_items": 12,
|
||||
"separator": " • ",
|
||||
"poll_interval_sec": 300,
|
||||
|
||||
"position": "bottom-right",
|
||||
"width": 1200,
|
||||
"height": 90,
|
||||
"border_radius": 12,
|
||||
"opacity": 1
|
||||
}
|
||||
28
Examples/PIP-News-Ticker/fixture-feed.xml
Normal file
28
Examples/PIP-News-Ticker/fixture-feed.xml
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Demo Newsroom</title>
|
||||
<link>https://example.com/</link>
|
||||
<description>Fixture feed for the news ticker offline test</description>
|
||||
<item>
|
||||
<title>City council approves new transit line</title>
|
||||
<link>https://example.com/1</link>
|
||||
</item>
|
||||
<item>
|
||||
<title><![CDATA[Markets rally as <b>tech</b> shares climb]]></title>
|
||||
<link>https://example.com/2</link>
|
||||
</item>
|
||||
<item>
|
||||
<title>Storms & flooding expected this weekend</title>
|
||||
<link>https://example.com/3</link>
|
||||
</item>
|
||||
<item>
|
||||
<title>Local team wins championship 3–2</title>
|
||||
<link>https://example.com/4</link>
|
||||
</item>
|
||||
<item>
|
||||
<title>Library extends weekend hours</title>
|
||||
<link>https://example.com/5</link>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
31
Examples/PIP-News-Ticker/news-overlay.html
Normal file
31
Examples/PIP-News-Ticker/news-overlay.html
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>News Ticker</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; overflow: hidden; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.strip { flex: 1; display: flex; align-items: stretch; background: #1a1a1a; color: #fff;
|
||||
border-radius: 12px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,.45); }
|
||||
.chip { display: flex; align-items: center; gap: 10px; padding: 0 18px; background: #CC0000;
|
||||
font-weight: 800; letter-spacing: .06em; text-transform: uppercase;
|
||||
font-size: clamp(14px, 2.4vw, 22px); white-space: nowrap; flex: 0 0 auto; }
|
||||
.chip .pulse { width: 12px; height: 12px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||
animation: pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||
.viewport { position: relative; flex: 1; overflow: hidden; display: flex; align-items: center; }
|
||||
.track { position: absolute; white-space: nowrap; will-change: transform;
|
||||
font-size: clamp(16px, 2.8vw, 26px); font-weight: 600; line-height: 1; }
|
||||
.track .sep { color: #CC0000; padding: 0 2px; font-weight: 800; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="strip">
|
||||
<div class="chip"><span class="pulse"></span><span id="label">NEWS</span></div>
|
||||
<div class="viewport"><div class="track" id="track"></div></div>
|
||||
</div>
|
||||
<script src="news-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
60
Examples/PIP-News-Ticker/news-overlay.js
Normal file
60
Examples/PIP-News-Ticker/news-overlay.js
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads the headline string from the query and scrolls it right-to-left, seamlessly.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var text = (q.get('text') || '').trim();
|
||||
var label = (q.get('label') || 'NEWS').trim();
|
||||
var sep = q.get('sep') || ' • ';
|
||||
|
||||
document.getElementById('label').textContent = label;
|
||||
|
||||
var track = document.getElementById('track');
|
||||
var viewport = track.parentNode;
|
||||
|
||||
// Build one "run" of the content (separator-joined headlines). Splitting on the
|
||||
// separator lets us colour the dividers without trusting feed markup (textContent only).
|
||||
function buildRun(container) {
|
||||
var parts = text.length ? text.split(sep) : ['(no headlines)'];
|
||||
parts.forEach(function (p, i) {
|
||||
if (i > 0) {
|
||||
var s = document.createElement('span');
|
||||
s.className = 'sep';
|
||||
s.textContent = sep;
|
||||
container.appendChild(s);
|
||||
}
|
||||
var span = document.createElement('span');
|
||||
span.textContent = p;
|
||||
container.appendChild(span);
|
||||
});
|
||||
}
|
||||
|
||||
// Two identical runs back-to-back → when the first scrolls fully off, reset by one
|
||||
// run width for a seamless loop.
|
||||
buildRun(track);
|
||||
var gap = document.createElement('span');
|
||||
gap.textContent = sep;
|
||||
gap.className = 'sep';
|
||||
track.appendChild(gap);
|
||||
var runWidth = 0;
|
||||
|
||||
function measureAndStart() {
|
||||
runWidth = track.scrollWidth; // width of a single run (+ trailing sep)
|
||||
buildRun(track); // append the second copy for the wrap
|
||||
var x = viewport.clientWidth; // start just off the right edge
|
||||
var speed = 90; // px/sec
|
||||
var last = null;
|
||||
function frame(ts) {
|
||||
if (last == null) last = ts;
|
||||
var dt = (ts - last) / 1000; last = ts;
|
||||
x -= speed * dt;
|
||||
if (x <= -runWidth) x += runWidth; // wrap by exactly one run
|
||||
track.style.transform = 'translateX(' + x + 'px)';
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
requestAnimationFrame(frame);
|
||||
}
|
||||
|
||||
// Wait a tick so fonts/layout settle before measuring.
|
||||
if (document.readyState === 'complete') measureAndStart();
|
||||
else window.addEventListener('load', measureAndStart);
|
||||
})();
|
||||
166
Examples/PIP-News-Ticker/news.js
Normal file
166
Examples/PIP-News-Ticker/news.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
'use strict';
|
||||
|
||||
// RSS/Atom headline ticker -> ScreenTinker PiP. Polls a feed, extracts headlines,
|
||||
// and pushes a persistent scrolling strip overlay to a device/group. Refreshes the
|
||||
// strip on each poll (player single-slot, last-show-wins) and clears on exit.
|
||||
//
|
||||
// node news.js [path/to/config.json]
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
// Zero dependencies — the feed parser is hand-rolled and tolerant of RSS and Atom.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---- pure helpers (exported for the offline test) -------------------------
|
||||
|
||||
// Decode CDATA sections and the handful of XML entities feeds actually use.
|
||||
function decodeText(s) {
|
||||
if (s == null) return '';
|
||||
let t = String(s);
|
||||
// pull CDATA payloads out verbatim
|
||||
t = t.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1');
|
||||
// strip any stray tags (some feeds put markup in titles)
|
||||
t = t.replace(/<[^>]+>/g, '');
|
||||
// named + numeric entities
|
||||
t = t
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (_, h) => String.fromCodePoint(parseInt(h, 16)))
|
||||
.replace(/&#(\d+);/g, (_, d) => String.fromCodePoint(parseInt(d, 10)))
|
||||
.replace(/&/g, '&'); // ampersand last, so &lt; -> < not <
|
||||
return t.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
// Grab the first <title>…</title> inside a block (RSS item / Atom entry).
|
||||
function firstTitle(block) {
|
||||
const m = block.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
||||
return m ? decodeText(m[1]) : '';
|
||||
}
|
||||
|
||||
// Tolerant headline extraction. Handles RSS (<item>) and Atom (<entry>); falls back
|
||||
// gracefully if a feed is malformed. Returns up to maxItems non-empty titles in order.
|
||||
function parseHeadlines(xml, maxItems = 12) {
|
||||
const text = String(xml || '');
|
||||
let blocks = text.match(/<item\b[\s\S]*?<\/item>/gi);
|
||||
if (!blocks || blocks.length === 0) blocks = text.match(/<entry\b[\s\S]*?<\/entry>/gi);
|
||||
const out = [];
|
||||
for (const b of blocks || []) {
|
||||
const title = firstTitle(b);
|
||||
if (title) out.push(title);
|
||||
if (out.length >= maxItems) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Feed channel/source title, used as the left-hand chip label when present.
|
||||
function feedLabel(xml) {
|
||||
const text = String(xml || '');
|
||||
// RSS: channel > title (the first <title> before any <item>)
|
||||
const beforeItem = text.split(/<item\b/i)[0];
|
||||
const ch = beforeItem.match(/<title\b[^>]*>([\s\S]*?)<\/title>/i);
|
||||
if (ch) return decodeText(ch[1]);
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildOverlayUri(base, { text, label, sep }) {
|
||||
const q = new URLSearchParams({ text: text || '', label: label || '', sep: sep || ' • ' });
|
||||
return `${base}${base.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
// ---- live runner ----------------------------------------------------------
|
||||
|
||||
function loadConfig() {
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
|
||||
if (!cfg.api_base || !cfg.api_token || !cfg.overlay_base_url || !cfg.device_id || !cfg.feed_url) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, device_id, and feed_url.');
|
||||
process.exit(1);
|
||||
}
|
||||
return cfg;
|
||||
}
|
||||
|
||||
async function pipShow(cfg, uri) {
|
||||
const base = cfg.api_base.replace(/\/$/, '');
|
||||
const body = {
|
||||
device_id: cfg.device_id,
|
||||
type: 'web',
|
||||
uri,
|
||||
position: cfg.position || 'bottom-right',
|
||||
width: cfg.width || 1200,
|
||||
height: cfg.height || 90,
|
||||
duration: 0, // persistent until we clear it
|
||||
border_radius: cfg.border_radius != null ? cfg.border_radius : 12,
|
||||
opacity: cfg.opacity != null ? cfg.opacity : 1,
|
||||
};
|
||||
const res = await fetch(`${base}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_token}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
return json.pip_id;
|
||||
}
|
||||
|
||||
async function pipClear(cfg, pipId) {
|
||||
const base = cfg.api_base.replace(/\/$/, '');
|
||||
await fetch(`${base}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${cfg.api_token}` },
|
||||
body: JSON.stringify({ device_id: cfg.device_id, pip_id: pipId }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const cfg = loadConfig();
|
||||
const maxItems = cfg.max_items || 12;
|
||||
const sep = cfg.separator || ' • ';
|
||||
const pollSec = cfg.poll_interval_sec || 300;
|
||||
let currentPip = null;
|
||||
|
||||
console.log(`News ticker starting — feed=${cfg.feed_url}`);
|
||||
console.log(` poll: every ${pollSec}s max headlines: ${maxItems} target: ${cfg.device_id}`);
|
||||
|
||||
async function tick() {
|
||||
let xml;
|
||||
try {
|
||||
const res = await fetch(cfg.feed_url, { headers: { Accept: 'application/rss+xml, application/atom+xml, application/xml, text/xml' } });
|
||||
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
|
||||
xml = await res.text();
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] feed fetch error: ${e.message}`);
|
||||
return;
|
||||
}
|
||||
const headlines = parseHeadlines(xml, maxItems);
|
||||
if (headlines.length === 0) { console.error(`[${new Date().toISOString()}] no headlines parsed`); return; }
|
||||
const label = cfg.label || feedLabel(xml) || 'NEWS';
|
||||
const text = headlines.join(sep);
|
||||
const uri = buildOverlayUri(cfg.overlay_base_url, { text, label, sep });
|
||||
try {
|
||||
currentPip = await pipShow(cfg, uri);
|
||||
console.log(`[${new Date().toISOString()}] SHOW ${headlines.length} headline(s) pip=${currentPip}`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
await tick();
|
||||
const timer = setInterval(tick, pollSec * 1000);
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(timer);
|
||||
if (currentPip) { console.log('\nclearing ticker before exit...'); await pipClear(cfg, currentPip); }
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { decodeText, firstTitle, parseHeadlines, feedLabel, buildOverlayUri };
|
||||
12
Examples/PIP-News-Ticker/package.json
Normal file
12
Examples/PIP-News-Ticker/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-news-ticker",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: scroll RSS/Atom headlines across a ScreenTinker screen via the PiP API.",
|
||||
"type": "commonjs",
|
||||
"main": "news.js",
|
||||
"scripts": {
|
||||
"start": "node news.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
44
Examples/PIP-News-Ticker/test.js
Normal file
44
Examples/PIP-News-Ticker/test.js
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
'use strict';
|
||||
|
||||
// Offline test for the news-ticker parser. No network, no PiP push.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { parseHeadlines, feedLabel, decodeText, buildOverlayUri } = require('./news');
|
||||
|
||||
const xml = fs.readFileSync(path.join(__dirname, 'fixture-feed.xml'), 'utf8');
|
||||
let pass = true;
|
||||
const checks = [];
|
||||
function check(name, cond, got) {
|
||||
checks.push({ name, cond, got });
|
||||
if (!cond) pass = false;
|
||||
}
|
||||
|
||||
const all = parseHeadlines(xml, 12);
|
||||
check('extracts all 5 items', all.length === 5, all.length);
|
||||
check('order preserved (#1)', all[0] === 'City council approves new transit line', all[0]);
|
||||
check('CDATA decoded + tags stripped', all[1] === 'Markets rally as tech shares climb', all[1]);
|
||||
check('ampersand entity decoded', all[2] === 'Storms & flooding expected this weekend', all[2]);
|
||||
check('numeric entity (–) decoded', all[3] === 'Local team wins championship 3–2', all[3]);
|
||||
check('last item present', all[4] === 'Library extends weekend hours', all[4]);
|
||||
|
||||
const capped = parseHeadlines(xml, 3);
|
||||
check('max_items caps the list', capped.length === 3, capped.length);
|
||||
|
||||
const label = feedLabel(xml);
|
||||
check('channel title used as label', label === 'Demo Newsroom', label);
|
||||
|
||||
// decodeText: ampersand applied last so escaped entities survive
|
||||
check('escaped < survives', decodeText('a &lt; b') === 'a < b', decodeText('a &lt; b'));
|
||||
|
||||
// uri round-trips through URLSearchParams
|
||||
const uri = buildOverlayUri('https://signage.example.com/news-overlay.html', {
|
||||
text: 'A • B & C', label: 'NEWS', sep: ' • ',
|
||||
});
|
||||
const parsed = new URLSearchParams(uri.split('?')[1]);
|
||||
check('uri text round-trips', parsed.get('text') === 'A • B & C', parsed.get('text'));
|
||||
check('uri label round-trips', parsed.get('label') === 'NEWS', parsed.get('label'));
|
||||
check('uri uses ? join once', (uri.match(/\?/g) || []).length === 1, uri);
|
||||
|
||||
for (const c of checks) console.log(`${c.cond ? '✓' : '✗'} ${c.name}${c.cond ? '' : ` (got: ${JSON.stringify(c.got)})`}`);
|
||||
console.log(`\nRESULT: ${pass ? 'PASS ✅' : 'FAIL ❌'}`);
|
||||
process.exit(pass ? 0 : 1);
|
||||
3
Examples/PIP-QR-Rotator/.gitignore
vendored
Normal file
3
Examples/PIP-QR-Rotator/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
110
Examples/PIP-QR-Rotator/README.md
Normal file
110
Examples/PIP-QR-Rotator/README.md
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
# PiP QR Rotator
|
||||
|
||||
Rotate **scannable QR codes** through a corner of your ScreenTinker screens via the PiP
|
||||
API — Guest Wi-Fi, the lunch menu, a feedback survey, a "scan to download" link, the event
|
||||
schedule, a checkout/tip link… anything a phone camera should grab.
|
||||
|
||||
The QR codes are generated **client-side, in the overlay itself** — no QR web service, no
|
||||
image hosting, no external libraries, no network calls. That keeps it fast, private, and
|
||||
compliant with the player's Content-Security-Policy (`script-src 'self'`).
|
||||
|
||||
```
|
||||
qr.js --(POST /api/pip, type:web)--> player
|
||||
uri = qr-overlay.html?data=<payload>&label=<caption>
|
||||
|
|
||||
qr-overlay.js encodes <payload> into a QR matrix and paints it on a <canvas>
|
||||
every rotate_interval_sec, qr.js pushes the next entry (player = last-show-wins)
|
||||
```
|
||||
|
||||
## Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `qr.js` | Rotates through `config.entries`, pushing each as a PiP overlay. `--clear` removes it. |
|
||||
| `qr-overlay.html` / `qr-overlay.js` | The overlay page the player loads in an iframe. **Generates the QR client-side.** Must be served by your ScreenTinker host (same-origin with the player). |
|
||||
| `config.example.json` | Copy to `config.json` and fill in. |
|
||||
| `test.js` | Offline unit test (`npm test`) — pure helpers + the QR encoder's Reed-Solomon core. |
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Mint a token.** In the dashboard create an API token with the **`full`** scope (PiP
|
||||
is fleet-affecting and renders web content, so it requires `full`).
|
||||
|
||||
2. **Serve the overlay assets.** Copy `qr-overlay.html` and `qr-overlay.js` into the
|
||||
directory your ScreenTinker server serves at the web root (its `frontend/` dir), so they
|
||||
live at `https://<your-host>/qr-overlay.html`. They **must** be same-origin with the
|
||||
player — the server applies a CSP that only allows same-origin scripts, which is exactly
|
||||
why the QR is drawn by `qr-overlay.js` (no CDN).
|
||||
|
||||
3. **Configure.** `cp config.example.json config.json` and set `api_base`, `api_token`,
|
||||
`overlay_base_url` (the URL from step 2), `device_id` (a device **or** a group id), and
|
||||
your `entries`.
|
||||
|
||||
4. **Run.** `node qr.js` — it pushes the first code immediately, then rotates every
|
||||
`rotate_interval_sec`. `Ctrl-C` clears the overlay.
|
||||
|
||||
## Configuration
|
||||
|
||||
| Key | Meaning |
|
||||
|-----|---------|
|
||||
| `entries` | Array of `{ label, data }`. `data` is the QR payload (required); `label` is the caption shown under it. |
|
||||
| `rotate_interval_sec` | Seconds between entries (default `15`). A single entry just stays up. |
|
||||
| `position` | `top-left`, `top-right`, `bottom-left`, `bottom-right` (default), or `center`. |
|
||||
| `width` / `height` | Overlay box px (default `360` × `420` — tall so the caption fits under the code). |
|
||||
| `border_radius`, `opacity` | Optional overlay styling. |
|
||||
|
||||
### QR payload cookbook
|
||||
|
||||
| Use | `data` value |
|
||||
|-----|--------------|
|
||||
| Open a link | `https://example.com/menu` |
|
||||
| **Join Wi-Fi** (auto-connect) | `WIFI:T:WPA;S:<ssid>;P:<password>;;` — for an open network use `WIFI:T:nopass;S:<ssid>;;` |
|
||||
| Pre-filled email | `mailto:hi@example.com?subject=Feedback` |
|
||||
| Phone number | `tel:+15551234567` |
|
||||
| Plain text | any text |
|
||||
|
||||
> Wi-Fi note: special characters in the SSID/password (`\ ; , : "`) must be backslash-escaped
|
||||
> per the Wi-Fi QR spec, e.g. `P:p\;w\:d`.
|
||||
|
||||
## Local quick-start (this repo)
|
||||
|
||||
The local ScreenTinker instance serves on `https://localhost:3443/` (self-signed) and the
|
||||
registered player is device `DEVICE_OR_GROUP_ID`.
|
||||
|
||||
```bash
|
||||
# from the repo root: serve the overlay assets same-origin with the player
|
||||
cp Examples/PIP-QR-Rotator/qr-overlay.html Examples/PIP-QR-Rotator/qr-overlay.js frontend/
|
||||
|
||||
# then in this dir:
|
||||
cp config.example.json config.json
|
||||
# edit config.json:
|
||||
# "api_base": "https://localhost:3443/"
|
||||
# "api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN"
|
||||
# "overlay_base_url": "https://localhost:3443/qr-overlay.html"
|
||||
# "device_id": "DEVICE_OR_GROUP_ID"
|
||||
|
||||
# self-signed cert -> let Node accept it for this run
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node qr.js
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Runs offline (no network, no player): validates the rotation/URL helpers and verifies the
|
||||
embedded QR encoder's Reed-Solomon math against the published QR generator polynomials, plus
|
||||
structural checks (finder/timing patterns, version sizing). For the real proof, point it at
|
||||
a screen and **scan it with your phone**.
|
||||
|
||||
## Notes & limits
|
||||
|
||||
- The encoder is a compact **byte-mode** implementation of the QR spec (ISO/IEC 18004),
|
||||
based on Nayuki's reference algorithm (MIT). Byte mode handles any UTF-8 payload; it
|
||||
auto-selects the smallest version and the best mask, and boosts the error-correction level
|
||||
for free when there's spare capacity (more robust scanning).
|
||||
- Keep payloads reasonably short for at-a-distance scanning — long URLs make a denser code.
|
||||
Use a link shortener for long destinations.
|
||||
- Like all PiP overlays, this is **ephemeral**: a player reboot drops it (re-run to restore),
|
||||
and the script clears it on `Ctrl-C`.
|
||||
18
Examples/PIP-QR-Rotator/config.example.json
Normal file
18
Examples/PIP-QR-Rotator/config.example.json
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/qr-overlay.html",
|
||||
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"rotate_interval_sec": 15,
|
||||
"position": "bottom-right",
|
||||
"width": 360,
|
||||
"height": 420,
|
||||
|
||||
"entries": [
|
||||
{ "label": "Guest Wi-Fi", "data": "WIFI:T:WPA;S:Lobby-Guest;P:welcome123;;" },
|
||||
{ "label": "Today's Lunch Menu", "data": "https://example.com/menu" },
|
||||
{ "label": "Tell us how we're doing", "data": "https://example.com/survey" }
|
||||
]
|
||||
}
|
||||
12
Examples/PIP-QR-Rotator/package.json
Normal file
12
Examples/PIP-QR-Rotator/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-qr-rotator",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: rotate scannable QR codes (Wi-Fi, menu, survey, links) on ScreenTinker screens via the PiP API. QR codes are generated client-side — no network, no dependencies.",
|
||||
"type": "commonjs",
|
||||
"main": "qr.js",
|
||||
"scripts": {
|
||||
"start": "node qr.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
32
Examples/PIP-QR-Rotator/qr-overlay.html
Normal file
32
Examples/PIP-QR-Rotator/qr-overlay.html
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Scan Me</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 16px; background: #14161c; color: #fff; border-radius: 16px; overflow: hidden;
|
||||
box-shadow: 0 10px 40px rgba(0,0,0,.45); padding: 22px; box-sizing: border-box; }
|
||||
.panel { background: #fff; border-radius: 14px; padding: 14px; line-height: 0;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,.25); }
|
||||
.panel canvas { display: block; width: clamp(160px, 46vh, 360px); height: auto; image-rendering: pixelated; }
|
||||
.label { font-size: clamp(16px, 4.5vw, 28px); font-weight: 700; text-align: center; line-height: 1.15;
|
||||
max-width: 95%; }
|
||||
.hint { font-size: clamp(11px, 2.6vw, 15px); color: #9aa0aa; letter-spacing: .14em; text-transform: uppercase; }
|
||||
.placeholder { color: #9aa0aa; font-size: clamp(14px, 4vw, 22px); text-align: center; padding: 30px;
|
||||
display: flex; align-items: center; justify-content: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="panel"><canvas id="qr"></canvas></div>
|
||||
<div class="label" id="label"></div>
|
||||
<div class="hint">📷 Scan with your camera</div>
|
||||
<div class="placeholder" id="placeholder" style="display:none">No QR data</div>
|
||||
</div>
|
||||
<script src="qr-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
361
Examples/PIP-QR-Rotator/qr-overlay.js
Normal file
361
Examples/PIP-QR-Rotator/qr-overlay.js
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// QR Rotator overlay — generates the QR code CLIENT-SIDE, with NO network calls and NO
|
||||
// external libraries, so it satisfies the player's CSP (scriptSrc 'self') and works
|
||||
// fully offline. Reads ?data (the QR payload) and ?label (caption) from the URL.
|
||||
//
|
||||
// The encoder is a compact byte-mode implementation of the QR Code spec (ISO/IEC 18004),
|
||||
// based on Nayuki's "QR Code generator" reference algorithm (MIT License). Byte mode is
|
||||
// used for everything, so any UTF-8 payload works (URLs, WIFI: strings, plain text).
|
||||
//
|
||||
// It also exports its internals via module.exports when require()'d in Node, so the
|
||||
// offline test can verify the Reed-Solomon / encoder core without needing a decoder.
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// ---------- GF(256) arithmetic & Reed-Solomon (Nayuki) ----------
|
||||
function rsMul(x, y) {
|
||||
var z = 0;
|
||||
for (var i = 7; i >= 0; i--) {
|
||||
z = (z << 1) ^ ((z >>> 7) * 0x11D);
|
||||
z ^= ((y >>> i) & 1) * x;
|
||||
}
|
||||
return z & 0xFF;
|
||||
}
|
||||
function rsDivisor(degree) {
|
||||
if (degree < 1 || degree > 255) throw new RangeError('degree out of range');
|
||||
var result = [];
|
||||
for (var i = 0; i < degree - 1; i++) result.push(0);
|
||||
result.push(1);
|
||||
var root = 1;
|
||||
for (i = 0; i < degree; i++) {
|
||||
for (var j = 0; j < result.length; j++) {
|
||||
result[j] = rsMul(result[j], root);
|
||||
if (j + 1 < result.length) result[j] ^= result[j + 1];
|
||||
}
|
||||
root = rsMul(root, 0x02);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function rsRemainder(data, divisor) {
|
||||
var result = divisor.map(function () { return 0; });
|
||||
for (var k = 0; k < data.length; k++) {
|
||||
var factor = data[k] ^ result.shift();
|
||||
result.push(0);
|
||||
for (var i = 0; i < divisor.length; i++) result[i] ^= rsMul(divisor[i], factor);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// ---------- spec tables: [ecl 0..3 = L,M,Q,H][version 1..40] ----------
|
||||
var ECC_CW = [
|
||||
[-1,7,10,15,20,26,18,20,24,30,18,20,24,26,30,22,24,28,30,28,28,28,28,30,30,26,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],
|
||||
[-1,10,16,26,18,24,16,18,22,22,26,30,22,22,24,24,28,28,26,26,26,26,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28,28],
|
||||
[-1,13,22,18,26,18,24,18,22,20,24,28,26,24,20,30,24,28,28,26,30,28,30,30,30,30,28,30,30,30,30,30,30,30,30,30,30,30,30,30,30],
|
||||
[-1,17,28,22,16,22,28,26,26,24,28,24,28,22,24,24,30,28,28,26,28,30,24,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30,30]
|
||||
];
|
||||
var ECC_BLOCKS = [
|
||||
[-1,1,1,1,1,1,2,2,2,2,4,4,4,4,4,6,6,6,6,7,8,8,9,9,10,12,12,12,13,14,15,16,17,18,19,19,20,21,22,24,25],
|
||||
[-1,1,1,1,2,2,4,4,4,5,5,5,8,9,9,10,10,11,13,14,16,17,17,18,20,21,23,25,26,28,29,31,33,35,37,38,40,43,45,47,49],
|
||||
[-1,1,1,2,2,4,4,6,6,8,8,8,10,12,16,12,17,16,18,21,20,23,23,25,27,29,34,34,35,38,40,43,45,48,51,53,56,59,62,65,68],
|
||||
[-1,1,1,2,4,4,4,5,6,8,8,11,11,16,16,18,16,19,21,25,25,25,34,30,32,35,37,40,42,45,48,51,54,57,60,63,66,70,74,77,81]
|
||||
];
|
||||
var ECL_FORMAT = [1, 0, 3, 2]; // 2-bit format value for L,M,Q,H
|
||||
var ECL_INDEX = { L: 0, M: 1, Q: 2, H: 3 };
|
||||
|
||||
function numRawDataModules(ver) {
|
||||
var result = (16 * ver + 128) * ver + 64;
|
||||
if (ver >= 2) {
|
||||
var numAlign = Math.floor(ver / 7) + 2;
|
||||
result -= (25 * numAlign - 10) * numAlign - 55;
|
||||
if (ver >= 7) result -= 36;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
function numDataCodewords(ver, ecl) {
|
||||
return Math.floor(numRawDataModules(ver) / 8) - ECC_CW[ecl][ver] * ECC_BLOCKS[ecl][ver];
|
||||
}
|
||||
function alignmentPositions(ver) {
|
||||
if (ver === 1) return [];
|
||||
var numAlign = Math.floor(ver / 7) + 2;
|
||||
var step = (ver === 32) ? 26 : Math.ceil((ver * 4 + 4) / (numAlign * 2 - 2)) * 2;
|
||||
var size = ver * 4 + 17;
|
||||
var result = [6];
|
||||
for (var pos = size - 7; result.length < numAlign; pos -= step) result.splice(1, 0, pos);
|
||||
return result;
|
||||
}
|
||||
function getBit(x, i) { return ((x >>> i) & 1) !== 0; }
|
||||
|
||||
// UTF-8 bytes for a string, dependency-free (TextEncoder when present).
|
||||
function utf8Bytes(str) {
|
||||
if (typeof TextEncoder !== 'undefined') return Array.from(new TextEncoder().encode(str));
|
||||
var out = [];
|
||||
for (var i = 0; i < str.length; i++) {
|
||||
var c = str.charCodeAt(i);
|
||||
if (c < 0x80) out.push(c);
|
||||
else if (c < 0x800) { out.push(0xC0 | (c >> 6), 0x80 | (c & 0x3F)); }
|
||||
else { out.push(0xE0 | (c >> 12), 0x80 | ((c >> 6) & 0x3F), 0x80 | (c & 0x3F)); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------- encode bytes -> { size, modules } ----------
|
||||
function encodeBytes(dataBytes, eclName) {
|
||||
var ecl = ECL_INDEX[eclName] != null ? ECL_INDEX[eclName] : 1;
|
||||
|
||||
// smallest version that fits
|
||||
var ver;
|
||||
for (ver = 1; ; ver++) {
|
||||
if (ver > 40) throw new RangeError('Data too long to fit in any QR version');
|
||||
var ccbits = ver <= 9 ? 8 : 16;
|
||||
var usedBits = 4 + ccbits + dataBytes.length * 8;
|
||||
if (usedBits <= numDataCodewords(ver, ecl) * 8) break;
|
||||
}
|
||||
// boost ECC level for free if it still fits at this version
|
||||
[1, 2, 3].forEach(function (newEcl) {
|
||||
var ccbits = ver <= 9 ? 8 : 16;
|
||||
var usedBits = 4 + ccbits + dataBytes.length * 8;
|
||||
if (newEcl > ecl && usedBits <= numDataCodewords(ver, newEcl) * 8) ecl = newEcl;
|
||||
});
|
||||
|
||||
// build bit buffer
|
||||
var bb = [];
|
||||
function appendBits(val, len) { for (var i = len - 1; i >= 0; i--) bb.push((val >>> i) & 1); }
|
||||
appendBits(0x4, 4); // byte mode indicator
|
||||
appendBits(dataBytes.length, ver <= 9 ? 8 : 16); // char count
|
||||
for (var i = 0; i < dataBytes.length; i++) appendBits(dataBytes[i], 8);
|
||||
|
||||
var capacityBits = numDataCodewords(ver, ecl) * 8;
|
||||
appendBits(0, Math.min(4, capacityBits - bb.length)); // terminator
|
||||
appendBits(0, (8 - bb.length % 8) % 8); // byte align
|
||||
for (var pad = 0xEC; bb.length < capacityBits; pad ^= 0xEC ^ 0x11) appendBits(pad, 8);
|
||||
|
||||
var dataCodewords = [];
|
||||
for (i = 0; i < bb.length; i += 8) {
|
||||
var b = 0;
|
||||
for (var j = 0; j < 8; j++) b = (b << 1) | bb[i + j];
|
||||
dataCodewords.push(b);
|
||||
}
|
||||
|
||||
var allCodewords = addEccAndInterleave(dataCodewords, ver, ecl);
|
||||
return buildMatrix(allCodewords, ver, ecl);
|
||||
}
|
||||
|
||||
function addEccAndInterleave(data, ver, ecl) {
|
||||
var numBlocks = ECC_BLOCKS[ecl][ver];
|
||||
var blockEccLen = ECC_CW[ecl][ver];
|
||||
var rawCodewords = Math.floor(numRawDataModules(ver) / 8);
|
||||
var numShortBlocks = numBlocks - rawCodewords % numBlocks;
|
||||
var shortBlockLen = Math.floor(rawCodewords / numBlocks);
|
||||
var blocks = [];
|
||||
var divisor = rsDivisor(blockEccLen);
|
||||
for (var i = 0, k = 0; i < numBlocks; i++) {
|
||||
var dat = data.slice(k, k + shortBlockLen - blockEccLen + (i < numShortBlocks ? 0 : 1));
|
||||
k += dat.length;
|
||||
var ecc = rsRemainder(dat, divisor);
|
||||
if (i < numShortBlocks) dat = dat.concat([0]);
|
||||
blocks.push(dat.concat(ecc));
|
||||
}
|
||||
var result = [];
|
||||
for (i = 0; i < blocks[0].length; i++) {
|
||||
for (var j = 0; j < blocks.length; j++) {
|
||||
if (i !== shortBlockLen - blockEccLen || j >= numShortBlocks) result.push(blocks[j][i]);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function buildMatrix(allCodewords, ver, ecl) {
|
||||
var size = ver * 4 + 17;
|
||||
var modules = [], isFunc = [];
|
||||
for (var i = 0; i < size; i++) { modules.push(new Array(size).fill(false)); isFunc.push(new Array(size).fill(false)); }
|
||||
function set(x, y, dark) { if (x >= 0 && x < size && y >= 0 && y < size) { modules[y][x] = dark; isFunc[y][x] = true; } }
|
||||
|
||||
// timing patterns
|
||||
for (i = 0; i < size; i++) { set(6, i, i % 2 === 0); set(i, 6, i % 2 === 0); }
|
||||
// finder patterns + separators
|
||||
[[3, 3], [size - 4, 3], [3, size - 4]].forEach(function (c) {
|
||||
for (var dy = -4; dy <= 4; dy++) for (var dx = -4; dx <= 4; dx++) {
|
||||
var dist = Math.max(Math.abs(dx), Math.abs(dy));
|
||||
set(c[0] + dx, c[1] + dy, dist !== 2 && dist !== 4);
|
||||
}
|
||||
});
|
||||
// alignment patterns
|
||||
var ap = alignmentPositions(ver), n = ap.length;
|
||||
for (i = 0; i < n; i++) for (var j = 0; j < n; j++) {
|
||||
if ((i === 0 && j === 0) || (i === 0 && j === n - 1) || (i === n - 1 && j === 0)) continue;
|
||||
for (var dy = -2; dy <= 2; dy++) for (var dx = -2; dx <= 2; dx++) {
|
||||
set(ap[j] + dx, ap[i] + dy, Math.max(Math.abs(dx), Math.abs(dy)) !== 1);
|
||||
}
|
||||
}
|
||||
|
||||
function drawFormat(mask) {
|
||||
var data = (ECL_FORMAT[ecl] << 3) | mask;
|
||||
var rem = data;
|
||||
for (var i = 0; i < 10; i++) rem = (rem << 1) ^ ((rem >>> 9) * 0x537);
|
||||
var bits = ((data << 10) | rem) ^ 0x5412;
|
||||
for (i = 0; i <= 5; i++) set(8, i, getBit(bits, i));
|
||||
set(8, 7, getBit(bits, 6)); set(8, 8, getBit(bits, 7)); set(7, 8, getBit(bits, 8));
|
||||
for (i = 9; i < 15; i++) set(14 - i, 8, getBit(bits, i));
|
||||
for (i = 0; i < 8; i++) set(size - 1 - i, 8, getBit(bits, i));
|
||||
for (i = 8; i < 15; i++) set(8, size - 15 + i, getBit(bits, i));
|
||||
set(8, size - 8, true); // always-dark module
|
||||
}
|
||||
function drawVersion() {
|
||||
if (ver < 7) return;
|
||||
var rem = ver;
|
||||
for (var i = 0; i < 12; i++) rem = (rem << 1) ^ ((rem >>> 11) * 0x1F25);
|
||||
var bits = (ver << 12) | rem;
|
||||
for (i = 0; i < 18; i++) {
|
||||
var bit = getBit(bits, i);
|
||||
var a = size - 11 + i % 3, b = Math.floor(i / 3);
|
||||
set(a, b, bit); set(b, a, bit);
|
||||
}
|
||||
}
|
||||
drawFormat(0); // reserve the format areas as function modules
|
||||
drawVersion();
|
||||
|
||||
// draw data + ecc codewords (zigzag, bottom-right -> up)
|
||||
var bitIdx = 0;
|
||||
for (var right = size - 1; right >= 1; right -= 2) {
|
||||
if (right === 6) right = 5;
|
||||
for (var vert = 0; vert < size; vert++) {
|
||||
for (var c2 = 0; c2 < 2; c2++) {
|
||||
var x = right - c2;
|
||||
var upward = ((right + 1) & 2) === 0;
|
||||
var y = upward ? size - 1 - vert : vert;
|
||||
if (!isFunc[y][x] && bitIdx < allCodewords.length * 8) {
|
||||
modules[y][x] = getBit(allCodewords[bitIdx >>> 3], 7 - (bitIdx & 7));
|
||||
bitIdx++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// choose the mask with the lowest penalty, then apply it for real
|
||||
function applyMask(mask) {
|
||||
for (var y = 0; y < size; y++) for (var x = 0; x < size; x++) {
|
||||
if (isFunc[y][x]) continue;
|
||||
var invert;
|
||||
switch (mask) {
|
||||
case 0: invert = (x + y) % 2 === 0; break;
|
||||
case 1: invert = y % 2 === 0; break;
|
||||
case 2: invert = x % 3 === 0; break;
|
||||
case 3: invert = (x + y) % 3 === 0; break;
|
||||
case 4: invert = (Math.floor(x / 3) + Math.floor(y / 2)) % 2 === 0; break;
|
||||
case 5: invert = (x * y) % 2 + (x * y) % 3 === 0; break;
|
||||
case 6: invert = ((x * y) % 2 + (x * y) % 3) % 2 === 0; break;
|
||||
case 7: invert = ((x + y) % 2 + (x * y) % 3) % 2 === 0; break;
|
||||
}
|
||||
if (invert) modules[y][x] = !modules[y][x];
|
||||
}
|
||||
}
|
||||
|
||||
var best = -1, minPenalty = Infinity;
|
||||
for (var mask = 0; mask < 8; mask++) {
|
||||
drawFormat(mask); applyMask(mask);
|
||||
var p = penalty(modules, size);
|
||||
if (p < minPenalty) { minPenalty = p; best = mask; }
|
||||
applyMask(mask); // undo (XOR is its own inverse)
|
||||
}
|
||||
drawFormat(best); applyMask(best);
|
||||
|
||||
return { size: size, modules: modules, version: ver, ecl: ecl };
|
||||
}
|
||||
|
||||
// ---------- mask penalty (Nayuki getPenaltyScore) ----------
|
||||
function penalty(modules, size) {
|
||||
var N1 = 3, N2 = 3, N3 = 40, N4 = 10, result = 0;
|
||||
|
||||
function countPatterns(rh) {
|
||||
var nn = rh[1];
|
||||
var core = nn > 0 && rh[2] === nn && rh[3] === nn * 3 && rh[4] === nn && rh[5] === nn;
|
||||
return (core && rh[0] >= nn * 4 && rh[6] >= nn ? 1 : 0) + (core && rh[6] >= nn * 4 && rh[0] >= nn ? 1 : 0);
|
||||
}
|
||||
function addHistory(run, rh) { if (rh[0] === 0) run += size; rh.pop(); rh.unshift(run); }
|
||||
function terminate(color, run, rh) {
|
||||
if (color) { addHistory(run, rh); run = 0; }
|
||||
run += size; addHistory(run, rh);
|
||||
return countPatterns(rh);
|
||||
}
|
||||
|
||||
// rows
|
||||
for (var y = 0; y < size; y++) {
|
||||
var color = false, run = 0, rh = [0, 0, 0, 0, 0, 0, 0];
|
||||
for (var x = 0; x < size; x++) {
|
||||
if (modules[y][x] === color) { run++; if (run === 5) result += N1; else if (run > 5) result++; }
|
||||
else { addHistory(run, rh); if (!color) result += countPatterns(rh) * N3; color = modules[y][x]; run = 1; }
|
||||
}
|
||||
result += terminate(color, run, rh) * N3;
|
||||
}
|
||||
// columns
|
||||
for (var x2 = 0; x2 < size; x2++) {
|
||||
var color2 = false, run2 = 0, rh2 = [0, 0, 0, 0, 0, 0, 0];
|
||||
for (var y2 = 0; y2 < size; y2++) {
|
||||
if (modules[y2][x2] === color2) { run2++; if (run2 === 5) result += N1; else if (run2 > 5) result++; }
|
||||
else { addHistory(run2, rh2); if (!color2) result += countPatterns(rh2) * N3; color2 = modules[y2][x2]; run2 = 1; }
|
||||
}
|
||||
result += terminate(color2, run2, rh2) * N3;
|
||||
}
|
||||
// 2x2 blocks
|
||||
for (var yy = 0; yy < size - 1; yy++) for (var xx = 0; xx < size - 1; xx++) {
|
||||
var c = modules[yy][xx];
|
||||
if (c === modules[yy][xx + 1] && c === modules[yy + 1][xx] && c === modules[yy + 1][xx + 1]) result += N2;
|
||||
}
|
||||
// dark proportion
|
||||
var dark = 0;
|
||||
for (var a = 0; a < size; a++) for (var b = 0; b < size; b++) if (modules[a][b]) dark++;
|
||||
var total = size * size;
|
||||
var k = Math.ceil(Math.abs(dark * 20 - total * 10) / total) - 1;
|
||||
result += k * N4;
|
||||
return result;
|
||||
}
|
||||
|
||||
var QR = { rsMul: rsMul, rsDivisor: rsDivisor, rsRemainder: rsRemainder, encodeBytes: encodeBytes, utf8Bytes: utf8Bytes, numDataCodewords: numDataCodewords };
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = QR;
|
||||
else global.QR = QR;
|
||||
|
||||
// ---------- browser rendering ----------
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
function draw() {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var data = q.get('data') || '';
|
||||
var label = (q.get('label') || '').trim();
|
||||
|
||||
var labelEl = document.getElementById('label');
|
||||
if (labelEl) labelEl.textContent = label;
|
||||
|
||||
var canvas = document.getElementById('qr');
|
||||
var placeholder = document.getElementById('placeholder');
|
||||
|
||||
if (!data) { show(placeholder); hide(canvas); return; }
|
||||
try {
|
||||
var qr = encodeBytes(utf8Bytes(data), 'M');
|
||||
paint(canvas, qr);
|
||||
show(canvas); hide(placeholder);
|
||||
} catch (e) {
|
||||
if (placeholder) placeholder.textContent = 'QR error: ' + (e && e.message ? e.message : e);
|
||||
show(placeholder); hide(canvas);
|
||||
}
|
||||
}
|
||||
function show(el) { if (el) el.style.display = ''; }
|
||||
function hide(el) { if (el) el.style.display = 'none'; }
|
||||
|
||||
function paint(canvas, qr) {
|
||||
if (!canvas) return;
|
||||
var quiet = 4;
|
||||
var dim = qr.size + quiet * 2;
|
||||
var scale = Math.max(2, Math.floor(560 / dim)); // crisp internal resolution
|
||||
canvas.width = dim * scale;
|
||||
canvas.height = dim * scale;
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.fillStyle = '#000000';
|
||||
for (var y = 0; y < qr.size; y++) for (var x = 0; x < qr.size; x++) {
|
||||
if (qr.modules[y][x]) ctx.fillRect((x + quiet) * scale, (y + quiet) * scale, scale, scale);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', draw);
|
||||
else draw();
|
||||
})(typeof globalThis !== 'undefined' ? globalThis : this);
|
||||
153
Examples/PIP-QR-Rotator/qr.js
Normal file
153
Examples/PIP-QR-Rotator/qr.js
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
'use strict';
|
||||
|
||||
// QR Rotator -> ScreenTinker PiP. Cycles through a list of {label, data} entries,
|
||||
// pushing each as a PiP web overlay that renders the QR code CLIENT-SIDE (the encoder
|
||||
// lives in qr-overlay.js — no network, no external libraries, CSP-safe). Every
|
||||
// `rotate_interval_sec` it shows the next entry; the player keeps a single overlay slot
|
||||
// (last-show-wins) so each push replaces the previous one. Cleared on exit.
|
||||
//
|
||||
// node qr.js [path/to/config.json]
|
||||
// node qr.js [config] --clear # remove the overlay and exit
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
//
|
||||
// Good for: guest Wi-Fi join, lunch menu, feedback survey, ticket/checkout links,
|
||||
// "scan to download the app", event schedule — anything a phone camera should grab.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// --- pure, testable helpers (no I/O) ---
|
||||
|
||||
// Keep only well-formed entries: `data` is required (the QR payload); `label` is
|
||||
// optional caption text. Returns { entries, errors } so the caller can warn and proceed.
|
||||
function validateEntries(raw) {
|
||||
const entries = [];
|
||||
const errors = [];
|
||||
if (!Array.isArray(raw)) return { entries, errors: ['"entries" must be an array'] };
|
||||
raw.forEach((e, i) => {
|
||||
if (!e || typeof e !== 'object') { errors.push(`entry ${i}: not an object`); return; }
|
||||
const data = typeof e.data === 'string' ? e.data.trim() : '';
|
||||
if (!data) { errors.push(`entry ${i}: missing "data"`); return; }
|
||||
entries.push({ label: typeof e.label === 'string' ? e.label : '', data });
|
||||
});
|
||||
return { entries, errors };
|
||||
}
|
||||
|
||||
// Build the overlay URL with the QR payload + caption in the query string.
|
||||
function overlayUri(overlayBase, entry) {
|
||||
const q = new URLSearchParams({ data: entry.data || '', label: entry.label || '' });
|
||||
return `${overlayBase}${overlayBase.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
// Advance the rotation index, wrapping around the list.
|
||||
function nextIndex(i, len) {
|
||||
if (!len || len < 1) return 0;
|
||||
return (i + 1) % len;
|
||||
}
|
||||
|
||||
module.exports = { validateEntries, overlayUri, nextIndex };
|
||||
|
||||
// --- CLI ---
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
function main() {
|
||||
const args = process.argv.slice(2);
|
||||
const clear = args.includes('--clear');
|
||||
const positional = args.filter(a => !a.startsWith('--'));
|
||||
const cfgPath = positional[0] || path.join(__dirname, 'config.json');
|
||||
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${cfgPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
const apiBase = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const apiToken = cfg.api_token;
|
||||
const overlayBase = cfg.overlay_base_url;
|
||||
const deviceId = cfg.device_id;
|
||||
|
||||
if (!apiBase || !apiToken || !deviceId) {
|
||||
console.error('config must set api_base, api_token, and device_id.');
|
||||
process.exit(1);
|
||||
}
|
||||
if (clear) return doClear(apiBase, apiToken, deviceId);
|
||||
|
||||
if (!overlayBase) { console.error('config must set overlay_base_url (where qr-overlay.html is served).'); process.exit(1); }
|
||||
|
||||
const { entries, errors } = validateEntries(cfg.entries);
|
||||
for (const err of errors) console.warn(`skipping ${err}`);
|
||||
if (entries.length === 0) { console.error('config.entries has no valid entries (each needs a "data" string).'); process.exit(1); }
|
||||
|
||||
const intervalSec = cfg.rotate_interval_sec || 15;
|
||||
const position = cfg.position || 'bottom-right';
|
||||
const width = cfg.width || 360;
|
||||
const height = cfg.height || 420;
|
||||
const opacity = cfg.opacity != null ? cfg.opacity : 1;
|
||||
const borderRadius = cfg.border_radius != null ? cfg.border_radius : 16;
|
||||
|
||||
console.log(`QR rotator starting — ${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}, every ${intervalSec}s, position ${position}`);
|
||||
entries.forEach((e, i) => console.log(` ${i + 1}. ${e.label || '(no label)'} -> ${e.data.slice(0, 60)}${e.data.length > 60 ? '…' : ''}`));
|
||||
|
||||
const opts = { apiBase, apiToken, deviceId, overlayBase, position, width, height, opacity, borderRadius };
|
||||
let idx = 0;
|
||||
let lastPip = null;
|
||||
|
||||
async function show() {
|
||||
const entry = entries[idx];
|
||||
try {
|
||||
lastPip = await pipShow(opts, entry);
|
||||
console.log(`[${new Date().toISOString()}] SHOW ${idx + 1}/${entries.length} "${entry.label || '(no label)'}" pip=${lastPip}`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
|
||||
}
|
||||
idx = nextIndex(idx, entries.length);
|
||||
}
|
||||
|
||||
show();
|
||||
const timer = entries.length > 1 ? setInterval(show, intervalSec * 1000) : null;
|
||||
|
||||
async function shutdown() {
|
||||
if (timer) clearInterval(timer);
|
||||
console.log('\nclearing overlay before exit...');
|
||||
try { await doClear(apiBase, apiToken, deviceId, true); } catch { /* best effort */ }
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
async function pipShow(opts, entry) {
|
||||
const body = {
|
||||
device_id: opts.deviceId,
|
||||
type: 'web',
|
||||
uri: overlayUri(opts.overlayBase, entry),
|
||||
position: opts.position,
|
||||
width: opts.width,
|
||||
height: opts.height,
|
||||
duration: 0, // persistent; we replace/clear it ourselves
|
||||
opacity: opts.opacity,
|
||||
border_radius: opts.borderRadius,
|
||||
close_button: false,
|
||||
title: (entry.label || '').slice(0, 200),
|
||||
};
|
||||
const res = await fetch(`${opts.apiBase}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${opts.apiToken}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||
return json.pip_id;
|
||||
}
|
||||
|
||||
async function doClear(apiBase, apiToken, deviceId, quiet) {
|
||||
const res = await fetch(`${apiBase}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${apiToken}` },
|
||||
body: JSON.stringify({ device_id: deviceId }),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(`(${res.status}) ${json.error || 'unknown error'}`);
|
||||
if (!quiet) console.log(`CLEAR sent to ${deviceId} (sent=${json.sent ?? '?'} offline=${json.offline ?? '?'})`);
|
||||
}
|
||||
72
Examples/PIP-QR-Rotator/test.js
Normal file
72
Examples/PIP-QR-Rotator/test.js
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
'use strict';
|
||||
|
||||
// Offline test. No network, no player. Covers:
|
||||
// - qr.js pure helpers (entry validation, overlay-uri build/round-trip, rotation wrap)
|
||||
// - the embedded QR encoder's Reed-Solomon core, checked against the published QR
|
||||
// generator polynomials (degree 7 and 10) — this catches GF(256) math errors without
|
||||
// needing a QR decoder — plus structural invariants of a generated matrix.
|
||||
const { validateEntries, overlayUri, nextIndex } = require('./qr');
|
||||
const QR = require('./qr-overlay');
|
||||
|
||||
let ok = true;
|
||||
function check(name, cond) { console.log(`${cond ? '•' : '✗'} ${name}`); if (!cond) ok = false; }
|
||||
|
||||
// ---- qr.js pure helpers ----
|
||||
const v = validateEntries([
|
||||
{ label: 'A', data: 'https://x.test/1' },
|
||||
{ label: 'B', data: ' ' }, // blank -> rejected
|
||||
{ data: 'WIFI:T:WPA;S:Net;P:pw;;' }, // no label -> ok, label defaults to ''
|
||||
{ label: 'C' }, // no data -> rejected
|
||||
]);
|
||||
check('validateEntries keeps the 2 valid entries', v.entries.length === 2);
|
||||
check('validateEntries reports the 2 bad entries', v.errors.length === 2);
|
||||
check('validateEntries defaults missing label to ""', v.entries[1].label === '');
|
||||
check('validateEntries non-array -> error', validateEntries('nope').errors.length === 1);
|
||||
|
||||
const entry = { label: 'Guest Wi-Fi & More', data: 'WIFI:T:WPA;S:Lobby Guest;P:p@ss=1;;' };
|
||||
const uri = overlayUri('https://s.example.com/qr-overlay.html', entry);
|
||||
const back = new URLSearchParams(uri.split('?')[1]);
|
||||
check('overlayUri round-trips data exactly', back.get('data') === entry.data);
|
||||
check('overlayUri round-trips label exactly', back.get('label') === entry.label);
|
||||
check('overlayUri encodes (no raw spaces/&/;)', !/[ &;]/.test(uri.split('?')[1].replace(/&data=|&label=/, '')));
|
||||
check('overlayUri joins with & when base already has ?',
|
||||
overlayUri('https://s/x?a=1', { data: 'd' }).includes('?a=1&'));
|
||||
|
||||
check('nextIndex wraps around', nextIndex(2, 3) === 0 && nextIndex(0, 3) === 1 && nextIndex(1, 3) === 2);
|
||||
check('nextIndex guards empty list', nextIndex(0, 0) === 0);
|
||||
|
||||
// ---- Reed-Solomon core vs published QR generator polynomials ----
|
||||
// Build GF(256) exp/log tables from the encoder's own multiply, then convert the computed
|
||||
// divisor coefficients back to alpha-exponent form to compare with the spec's values.
|
||||
const exp = new Array(256), log = new Array(256);
|
||||
exp[0] = 1;
|
||||
for (let i = 1; i < 256; i++) exp[i] = QR.rsMul(exp[i - 1], 2);
|
||||
for (let i = 0; i < 255; i++) log[exp[i]] = i;
|
||||
|
||||
function toAlpha(coeffs) { return coeffs.map((c) => log[c]); }
|
||||
|
||||
// Published non-leading generator-polynomial exponents (Thonky / ISO 18004 Annex A).
|
||||
const GEN7 = [87, 229, 146, 149, 238, 102, 21];
|
||||
const GEN10 = [251, 67, 46, 61, 118, 70, 64, 94, 32, 45];
|
||||
const d7 = toAlpha(QR.rsDivisor(7));
|
||||
const d10 = toAlpha(QR.rsDivisor(10));
|
||||
check('RS generator poly (deg 7) matches spec', JSON.stringify(d7) === JSON.stringify(GEN7));
|
||||
check('RS generator poly (deg 10) matches spec', JSON.stringify(d10) === JSON.stringify(GEN10));
|
||||
|
||||
// ---- encoder structural invariants ----
|
||||
const tiny = QR.encodeBytes(QR.utf8Bytes('hi'), 'M'); // tiny -> version 1
|
||||
check('tiny payload -> 21x21 (version 1)', tiny.size === 21 && tiny.modules.length === 21);
|
||||
// finder patterns: dark outer ring at the three corners, white separator beside them.
|
||||
check('top-left finder corner dark', tiny.modules[0][0] === true);
|
||||
check('top-left separator light', tiny.modules[0][7] === false);
|
||||
check('top-left finder centre dark', tiny.modules[3][3] === true);
|
||||
check('top-right finder present', tiny.modules[0][tiny.size - 1] === true);
|
||||
check('bottom-left finder present', tiny.modules[tiny.size - 1][0] === true);
|
||||
// timing pattern alternates along row/col 6
|
||||
check('timing pattern alternates', tiny.modules[6][8] !== tiny.modules[6][9]);
|
||||
|
||||
const url = QR.encodeBytes(QR.utf8Bytes('https://example.com/menu'), 'M');
|
||||
check('longer URL bumps the version (size > 21)', url.size > 21 && (url.size - 17) % 4 === 0);
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
3
Examples/PIP-Room-Status-Calendar/.gitignore
vendored
Normal file
3
Examples/PIP-Room-Status-Calendar/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
107
Examples/PIP-Room-Status-Calendar/README.md
Normal file
107
Examples/PIP-Room-Status-Calendar/README.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Room Status sign (calendar-driven Available / Busy)
|
||||
|
||||
Turns a ScreenTinker display into a meeting-room sign. It polls an **ICS calendar
|
||||
feed** and pushes a [PiP](../../docs) web overlay that shows **AVAILABLE** (green) or
|
||||
**BUSY** (red) plus the current/next meeting time. Re-pushed every poll so the state
|
||||
stays fresh; cleared when you stop the script.
|
||||
|
||||
No dependencies — Node 18+ only.
|
||||
|
||||
## How it works
|
||||
|
||||
```
|
||||
ICS feed ──poll──> room.js ──POST /api/pip (type=web)──> player renders room-overlay.html
|
||||
```
|
||||
|
||||
- `room.js` fetches the calendar, parses VEVENTs, and decides busy/free at *now*.
|
||||
- The overlay is `room-overlay.html` + `room-overlay.js`, served by the signage server
|
||||
and rendered by the player in an iframe. The script reads the status from the URL
|
||||
query string (the server CSP forbids inline scripts, so the logic lives in the
|
||||
external `.js`).
|
||||
|
||||
## Get an ICS URL
|
||||
|
||||
- **Google Calendar:** Calendar settings → *Integrate calendar* → **Secret address in
|
||||
iCal format**. (Treat it like a password.) For a room, use the room/resource calendar.
|
||||
- **Outlook / Microsoft 365:** Calendar → Share → **Publish**, then copy the **ICS** link.
|
||||
- Any CalDAV/ICS publisher works. The feed must be reachable by the machine running `room.js`.
|
||||
|
||||
## Serve the overlay assets
|
||||
|
||||
Copy `room-overlay.html` and `room-overlay.js` into the signage server's web root (the
|
||||
same directory that serves the SPA), so they're reachable at
|
||||
`https://<your-server>/room-overlay.html`. They must be **same-origin** with the player
|
||||
(the overlay runs in an iframe under the server's CSP).
|
||||
|
||||
## Configure
|
||||
|
||||
```bash
|
||||
cp config.example.json config.json
|
||||
# edit config.json: api_base, api_token (st_ token with the 'full' scope),
|
||||
# overlay_base_url, device_id (a device OR a group id), room_name, ics_url
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
```bash
|
||||
npm start # or: node room.js config.json
|
||||
```
|
||||
|
||||
Stop with Ctrl-C — it clears the overlay on the way out.
|
||||
|
||||
### Local quick-start (self-signed dev server)
|
||||
|
||||
For a local ScreenTinker instance on `https://localhost:3443` with a self-signed cert:
|
||||
|
||||
```json
|
||||
{
|
||||
"room_name": "Aspen Room",
|
||||
"api_base": "https://localhost:3443/",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://localhost:3443/room-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
"ics_url": "https://calendar.google.com/calendar/ical/.../basic.ics",
|
||||
"poll_interval_sec": 60
|
||||
}
|
||||
```
|
||||
|
||||
```bash
|
||||
NODE_TLS_REJECT_UNAUTHORIZED=0 node room.js config.json
|
||||
```
|
||||
|
||||
(`NODE_TLS_REJECT_UNAUTHORIZED=0` only to accept the dev cert — never in production.)
|
||||
Remember to copy `room-overlay.html` + `room-overlay.js` into the server's web root first.
|
||||
|
||||
## Offline demo / test
|
||||
|
||||
`test.js` runs the ICS parser and status logic against `fixture-room.ics` at a fixed
|
||||
clock — no server, no network:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
You can also drive the overlay against the fixture by setting `ics_file` (instead of
|
||||
`ics_url`) in `config.json`.
|
||||
|
||||
## Config reference
|
||||
|
||||
| key | meaning |
|
||||
| --- | --- |
|
||||
| `room_name` | label shown on the overlay |
|
||||
| `api_base` | ScreenTinker server base URL |
|
||||
| `api_token` | `st_` API token with the **full** scope |
|
||||
| `overlay_base_url` | URL where `room-overlay.html` is served (same-origin with the player) |
|
||||
| `device_id` | target device **or** group id |
|
||||
| `ics_url` | calendar feed URL (or use `ics_file` for a local file) |
|
||||
| `poll_interval_sec` | refresh cadence (default 120) |
|
||||
| `colors.available` / `colors.busy` | band colors, 6-hex no `#` |
|
||||
| `overlay.position` | `center` (default), `top-right`, `top-left`, `bottom-right`, `bottom-left` |
|
||||
| `overlay.width` / `overlay.height` / `overlay.border_radius` | overlay box geometry |
|
||||
|
||||
## Time-zone note
|
||||
|
||||
DTSTART/DTEND in UTC (`…Z`) are handled exactly. A *floating* time (no `Z`) is read as
|
||||
the **local time of the machine running `room.js`**, and `TZID` parameters are not
|
||||
resolved to their zone. For a single room whose host shares the room's timezone this is
|
||||
correct; for cross-timezone calendars, publish the feed in UTC.
|
||||
17
Examples/PIP-Room-Status-Calendar/config.example.json
Normal file
17
Examples/PIP-Room-Status-Calendar/config.example.json
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"room_name": "Aspen Room",
|
||||
"poll_interval_sec": 120,
|
||||
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/room-overlay.html",
|
||||
"device_id": "DEVICE_OR_GROUP_ID",
|
||||
|
||||
"ics_url": "https://calendar.google.com/calendar/ical/your-room%40group.calendar.google.com/private-xxxxxxxx/basic.ics",
|
||||
|
||||
"colors": { "available": "1f9d55", "busy": "CC0000" },
|
||||
"overlay": { "position": "center", "width": 900, "height": 360, "border_radius": 16 },
|
||||
|
||||
"_offline_demo": "to test against the bundled fixture instead of a live calendar, drop ics_url and add:",
|
||||
"ics_file": null
|
||||
}
|
||||
30
Examples/PIP-Room-Status-Calendar/fixture-room.ics
Normal file
30
Examples/PIP-Room-Status-Calendar/fixture-room.ics
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//ScreenTinker//Room Status Example//EN
|
||||
CALSCALE:GREGORIAN
|
||||
BEGIN:VEVENT
|
||||
UID:standup-0618@example.com
|
||||
DTSTART:20260618T093000Z
|
||||
DTEND:20260618T094500Z
|
||||
SUMMARY:Daily Standup
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:sprint-0618@example.com
|
||||
DTSTART:20260618T140000Z
|
||||
DTEND:20260618T150000Z
|
||||
SUMMARY:Sprint Planning
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:oneonone-0618@example.com
|
||||
DTSTART:20260618T160000Z
|
||||
DTEND:20260618T163000Z
|
||||
SUMMARY:1:1 with Da
|
||||
na
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:retro-0618@example.com
|
||||
DTSTART:20260618T170000Z
|
||||
DTEND:20260618T180000Z
|
||||
SUMMARY:Quarterly Retro\, room A
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
12
Examples/PIP-Room-Status-Calendar/package.json
Normal file
12
Examples/PIP-Room-Status-Calendar/package.json
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"name": "pip-room-status-calendar",
|
||||
"version": "0.1.0",
|
||||
"description": "Example: turn a ScreenTinker display into a meeting-room Available/Busy sign driven by an ICS calendar feed, via the PiP API.",
|
||||
"type": "commonjs",
|
||||
"main": "room.js",
|
||||
"scripts": {
|
||||
"start": "node room.js",
|
||||
"test": "node test.js"
|
||||
},
|
||||
"engines": { "node": ">=18" }
|
||||
}
|
||||
33
Examples/PIP-Room-Status-Calendar/room-overlay.html
Normal file
33
Examples/PIP-Room-Status-Calendar/room-overlay.html
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Room Status</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||
.band { padding: 18px 26px; display: flex; align-items: center; gap: 16px; font-weight: 800;
|
||||
letter-spacing: .05em; text-transform: uppercase; font-size: clamp(28px, 7vw, 64px); }
|
||||
.band .dot { width: 18px; height: 18px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||
box-shadow: 0 0 14px rgba(255,255,255,.7); }
|
||||
.body { padding: 20px 26px; display: flex; flex-direction: column; gap: 12px; flex: 1; }
|
||||
.room { font-size: clamp(18px, 3.4vw, 28px); font-weight: 600; color: #e8e8e8; }
|
||||
.detail { font-size: clamp(20px, 4.4vw, 34px); font-weight: 700; line-height: 1.15; }
|
||||
.sub { margin-top: auto; font-size: clamp(15px, 3vw, 22px); color: #b9b9b9; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="band" id="band"><span class="dot"></span><span id="state">—</span></div>
|
||||
<div class="body">
|
||||
<div class="room" id="room"></div>
|
||||
<div class="detail" id="detail"></div>
|
||||
<div class="sub" id="sub"></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="room-overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
13
Examples/PIP-Room-Status-Calendar/room-overlay.js
Normal file
13
Examples/PIP-Room-Status-Calendar/room-overlay.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// External overlay script — same-origin so the server CSP (scriptSrc 'self') permits it.
|
||||
// Reads the room status from the URL query string and paints the card.
|
||||
(function () {
|
||||
var q = new URLSearchParams(location.search);
|
||||
var get = function (k) { return (q.get(k) || '').trim(); };
|
||||
|
||||
var color = '#' + (get('color').replace(/[^0-9a-fA-F]/g, '') || '1f9d55');
|
||||
document.getElementById('band').style.background = color;
|
||||
document.getElementById('state').textContent = (get('state') || '—').toUpperCase();
|
||||
document.getElementById('room').textContent = get('room') || '';
|
||||
document.getElementById('detail').textContent = get('detail') || '';
|
||||
document.getElementById('sub').textContent = get('sub') || '';
|
||||
})();
|
||||
255
Examples/PIP-Room-Status-Calendar/room.js
Normal file
255
Examples/PIP-Room-Status-Calendar/room.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
'use strict';
|
||||
|
||||
// Meeting-room "Available / Busy" sign for ScreenTinker, driven by an ICS calendar
|
||||
// feed. Polls the calendar and pushes a PiP web overlay showing whether the room is
|
||||
// free right now (green) or in a meeting (red), plus the next/current meeting time.
|
||||
//
|
||||
// node room.js [path/to/config.json]
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
//
|
||||
// ICS time handling: DTSTART/DTEND ending in "Z" are UTC; a bare date-time
|
||||
// (YYYYMMDDTHHMMSS) is treated as the monitor host's LOCAL time; an all-day
|
||||
// VALUE=DATE (YYYYMMDD) spans local midnight..midnight. TZID parameters are NOT
|
||||
// resolved to their zone — a floating time is read as local. For a single room
|
||||
// display whose host shares the room's timezone this is correct; cross-timezone
|
||||
// calendars should publish UTC.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ICS parsing (minimal, dependency-free) — pure, exported, offline-testable
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// RFC 5545 line folding: a CRLF followed by a space or tab continues the prior
|
||||
// line. Unfold first, then split into logical lines.
|
||||
function unfold(ics) {
|
||||
return ics.replace(/\r\n/g, '\n').replace(/\r/g, '\n').replace(/\n[ \t]/g, '');
|
||||
}
|
||||
|
||||
// Parse an ICS date/-time value into epoch ms. Handles:
|
||||
// 20260618T143000Z -> UTC
|
||||
// 20260618T143000 -> local (floating)
|
||||
// 20260618 -> all-day, local midnight
|
||||
function parseIcsDate(val) {
|
||||
if (!val) return NaN;
|
||||
const v = val.trim();
|
||||
let m = v.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})(Z)?$/);
|
||||
if (m) {
|
||||
const [, y, mo, d, h, mi, s, z] = m;
|
||||
if (z) return Date.UTC(+y, +mo - 1, +d, +h, +mi, +s);
|
||||
return new Date(+y, +mo - 1, +d, +h, +mi, +s).getTime();
|
||||
}
|
||||
m = v.match(/^(\d{4})(\d{2})(\d{2})$/);
|
||||
if (m) {
|
||||
const [, y, mo, d] = m;
|
||||
return new Date(+y, +mo - 1, +d, 0, 0, 0).getTime(); // local midnight
|
||||
}
|
||||
const t = Date.parse(v);
|
||||
return Number.isFinite(t) ? t : NaN;
|
||||
}
|
||||
|
||||
// Split a "NAME;PARAM=x:VALUE" property line into { name, value }.
|
||||
function splitProp(line) {
|
||||
const idx = line.indexOf(':');
|
||||
if (idx < 0) return null;
|
||||
const head = line.slice(0, idx);
|
||||
const value = line.slice(idx + 1);
|
||||
const name = head.split(';')[0].toUpperCase();
|
||||
return { name, value };
|
||||
}
|
||||
|
||||
// RFC 5545 TEXT unescaping (\n \, \; \\).
|
||||
function unescapeText(s) {
|
||||
return String(s)
|
||||
.replace(/\\n/gi, ' ')
|
||||
.replace(/\\,/g, ',')
|
||||
.replace(/\\;/g, ';')
|
||||
.replace(/\\\\/g, '\\');
|
||||
}
|
||||
|
||||
// Extract VEVENTs as { summary, start, end } (start/end = epoch ms). Events
|
||||
// without a parseable start are skipped; a missing end defaults to start (a
|
||||
// zero-length event, which is never "current").
|
||||
function parseIcs(ics) {
|
||||
const lines = unfold(ics).split('\n');
|
||||
const events = [];
|
||||
let cur = null;
|
||||
for (const raw of lines) {
|
||||
const line = raw.trim();
|
||||
if (line === 'BEGIN:VEVENT') { cur = {}; continue; }
|
||||
if (line === 'END:VEVENT') {
|
||||
if (cur && Number.isFinite(cur.start)) {
|
||||
events.push({
|
||||
summary: cur.summary || '(busy)',
|
||||
start: cur.start,
|
||||
end: Number.isFinite(cur.end) ? cur.end : cur.start,
|
||||
});
|
||||
}
|
||||
cur = null;
|
||||
continue;
|
||||
}
|
||||
if (!cur) continue;
|
||||
const p = splitProp(line);
|
||||
if (!p) continue;
|
||||
if (p.name === 'DTSTART') cur.start = parseIcsDate(p.value);
|
||||
else if (p.name === 'DTEND') cur.end = parseIcsDate(p.value);
|
||||
else if (p.name === 'SUMMARY') cur.summary = unescapeText(p.value);
|
||||
}
|
||||
return events;
|
||||
}
|
||||
|
||||
// Given events and a `now` (epoch ms), decide if the room is busy. "current" is
|
||||
// the soonest-ending event covering now; "next" is the soonest event starting
|
||||
// strictly after now.
|
||||
function status(events, now) {
|
||||
const current = events
|
||||
.filter(e => e.start <= now && now < e.end)
|
||||
.sort((a, b) => a.end - b.end)[0] || null;
|
||||
const next = events
|
||||
.filter(e => e.start > now)
|
||||
.sort((a, b) => a.start - b.start)[0] || null;
|
||||
const trim = e => e && { summary: e.summary, start: e.start, end: e.end };
|
||||
return {
|
||||
state: current ? 'busy' : 'available',
|
||||
current: trim(current),
|
||||
next: trim(next),
|
||||
busyUntil: current ? current.end : null,
|
||||
freeUntil: !current && next ? next.start : null,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { parseIcs, parseIcsDate, status, unfold, unescapeText };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Runtime (only when executed directly) — config load, PiP push, poll loop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function runMain() {
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
const POLL_SEC = cfg.poll_interval_sec || 120;
|
||||
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const API_TOKEN = cfg.api_token;
|
||||
const OVERLAY_BASE = cfg.overlay_base_url;
|
||||
const DEVICE_ID = cfg.device_id;
|
||||
const ROOM_NAME = cfg.room_name || 'Meeting Room';
|
||||
const OVERLAY = cfg.overlay || {};
|
||||
const COLORS = Object.assign({ available: '1f9d55', busy: 'CC0000' }, cfg.colors || {});
|
||||
|
||||
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || !DEVICE_ID || (!cfg.ics_url && !cfg.ics_file)) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, device_id, and ics_url or ics_file.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const hhmm = ms => {
|
||||
const d = new Date(ms);
|
||||
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
};
|
||||
|
||||
// Map a status result to the overlay query fields.
|
||||
function viewOf(st) {
|
||||
if (st.state === 'busy') {
|
||||
return {
|
||||
state: 'BUSY', color: COLORS.busy,
|
||||
detail: st.current ? st.current.summary : 'In a meeting',
|
||||
sub: st.busyUntil ? `until ${hhmm(st.busyUntil)}` : '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
state: 'AVAILABLE', color: COLORS.available,
|
||||
detail: st.next ? `Next: ${st.next.summary}` : 'No more meetings today',
|
||||
sub: st.next ? `at ${hhmm(st.next.start)}` : '',
|
||||
};
|
||||
}
|
||||
|
||||
function overlayUri(st) {
|
||||
const v = viewOf(st);
|
||||
const q = new URLSearchParams({
|
||||
state: v.state, room: ROOM_NAME, detail: v.detail || '', sub: v.sub || '',
|
||||
color: (v.color || '1f9d55').replace(/[^0-9a-fA-F]/g, ''),
|
||||
});
|
||||
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
let activePip = null;
|
||||
|
||||
async function pipShow(st) {
|
||||
const body = {
|
||||
device_id: DEVICE_ID, type: 'web', uri: overlayUri(st),
|
||||
position: OVERLAY.position || 'center',
|
||||
width: OVERLAY.width || 900, height: OVERLAY.height || 360,
|
||||
duration: 0, // persistent; we refresh each poll and clear on exit
|
||||
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
|
||||
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
|
||||
close_button: false,
|
||||
title: ROOM_NAME,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
return json.pip_id;
|
||||
}
|
||||
|
||||
async function pipClear(pipId) {
|
||||
const res = await fetch(`${API_BASE}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({ device_id: DEVICE_ID, pip_id: pipId || undefined }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadIcs() {
|
||||
if (cfg.ics_file) return fs.readFileSync(cfg.ics_file, 'utf8');
|
||||
const res = await fetch(cfg.ics_url, { headers: { Accept: 'text/calendar' } });
|
||||
if (!res.ok) throw new Error(`ICS HTTP ${res.status}`);
|
||||
return res.text();
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
let events;
|
||||
try { events = parseIcs(await loadIcs()); }
|
||||
catch (e) { console.error(`[${new Date().toISOString()}] calendar load error: ${e.message}`); return; }
|
||||
const st = status(events, Date.now());
|
||||
const v = viewOf(st);
|
||||
try {
|
||||
// last-show-wins: re-pushing replaces the previous overlay with fresh state.
|
||||
const pipId = await pipShow(st);
|
||||
activePip = pipId;
|
||||
console.log(`[${new Date().toISOString()}] ${v.state} — ${v.detail} ${v.sub} (pip=${pipId})`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] show error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
(async () => {
|
||||
console.log(`Room status sign starting — room="${ROOM_NAME}"`);
|
||||
console.log(` source: ${cfg.ics_file ? `file ${cfg.ics_file}` : cfg.ics_url}`);
|
||||
console.log(` poll: every ${POLL_SEC}s`);
|
||||
await tick();
|
||||
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(timer);
|
||||
console.log('\nclearing overlay before exit...');
|
||||
try { if (activePip) await pipClear(activePip); } catch { /* best effort */ }
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
})();
|
||||
}
|
||||
|
||||
if (require.main === module) runMain();
|
||||
45
Examples/PIP-Room-Status-Calendar/test.js
Normal file
45
Examples/PIP-Room-Status-Calendar/test.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
'use strict';
|
||||
|
||||
// Offline test for the ICS parser + room status logic. No network, fixed clock.
|
||||
const fs = require('fs');
|
||||
const { parseIcs, status } = require('./room');
|
||||
|
||||
const events = parseIcs(fs.readFileSync('./fixture-room.ics', 'utf8'));
|
||||
|
||||
console.log(`Parsed ${events.length} event(s):\n`);
|
||||
for (const e of events) {
|
||||
console.log(`• ${e.summary} ${new Date(e.start).toISOString()} -> ${new Date(e.end).toISOString()}`);
|
||||
}
|
||||
console.log('');
|
||||
|
||||
// 14:30Z is inside Sprint Planning (14:00-15:00); next is the 1:1 at 16:00.
|
||||
const nowBusy = Date.UTC(2026, 5, 18, 14, 30, 0);
|
||||
const sBusy = status(events, nowBusy);
|
||||
|
||||
// 15:30Z is between meetings; room free, next is the 1:1 at 16:00.
|
||||
const nowFree = Date.UTC(2026, 5, 18, 15, 30, 0);
|
||||
const sFree = status(events, nowFree);
|
||||
|
||||
const fold = events.find(e => e.summary === '1:1 with Dana'); // proves line-unfolding
|
||||
const esc = events.find(e => e.summary === 'Quarterly Retro, room A'); // proves TEXT unescaping
|
||||
|
||||
const ok =
|
||||
events.length === 4 &&
|
||||
sBusy.state === 'busy' &&
|
||||
sBusy.current && sBusy.current.summary === 'Sprint Planning' &&
|
||||
sBusy.busyUntil === Date.UTC(2026, 5, 18, 15, 0, 0) &&
|
||||
sBusy.next && sBusy.next.summary === '1:1 with Dana' &&
|
||||
sFree.state === 'available' &&
|
||||
sFree.current === null &&
|
||||
sFree.next && sFree.next.summary === '1:1 with Dana' &&
|
||||
sFree.freeUntil === Date.UTC(2026, 5, 18, 16, 0, 0) &&
|
||||
!!fold && !!esc;
|
||||
|
||||
console.log('--- assertions ---');
|
||||
console.log('at 14:30Z =>', sBusy.state, '|', sBusy.current && sBusy.current.summary, '| next:', sBusy.next && sBusy.next.summary);
|
||||
console.log('at 15:30Z =>', sFree.state, '| next:', sFree.next && sFree.next.summary);
|
||||
console.log('folded summary parsed:', !!fold, '("1:1 with Dana")');
|
||||
console.log('escaped summary parsed:', !!esc, '("Quarterly Retro, room A")');
|
||||
|
||||
console.log('\nRESULT:', ok ? 'PASS ✅' : 'FAIL ❌');
|
||||
process.exit(ok ? 0 : 1);
|
||||
4
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/.gitignore
vendored
Normal file
4
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
config.json
|
||||
node_modules/
|
||||
package-lock.json
|
||||
demo-noaa.json
|
||||
36
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/alert-overlay.html
Normal file
36
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/alert-overlay.html
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Emergency Alert</title>
|
||||
<style>
|
||||
html, body { margin: 0; height: 100%; background: transparent; }
|
||||
body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; display: flex; }
|
||||
.card { flex: 1; display: flex; flex-direction: column; background: #1a1a1a; color: #fff;
|
||||
border-radius: 16px; overflow: hidden; box-shadow: 0 10px 40px rgba(0,0,0,.45); }
|
||||
.band { padding: 14px 22px; display: flex; align-items: center; gap: 14px; font-weight: 800;
|
||||
letter-spacing: .04em; text-transform: uppercase; font-size: clamp(18px, 4vw, 30px); }
|
||||
.band .pulse { width: 16px; height: 16px; border-radius: 50%; background: rgba(255,255,255,.95);
|
||||
animation: pulse 1.1s ease-in-out infinite; }
|
||||
@keyframes pulse { 0%,100% { transform: scale(.7); opacity:.6 } 50% { transform: scale(1.15); opacity:1 } }
|
||||
.body { padding: 18px 24px; display: flex; flex-direction: column; gap: 10px; flex: 1; }
|
||||
.headline { font-size: clamp(20px, 5vw, 38px); font-weight: 700; line-height: 1.15; }
|
||||
.meta { font-size: clamp(13px, 2.6vw, 18px); color: #cfcfcf; display: flex; flex-wrap: wrap; gap: 6px 18px; }
|
||||
.meta b { color: #fff; font-weight: 600; }
|
||||
.footer { margin-top: auto; font-size: clamp(12px, 2.2vw, 16px); color: #9a9a9a; }
|
||||
.agency { opacity: .8; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div class="band" id="band"><span class="pulse"></span><span id="level">ALERT</span></div>
|
||||
<div class="body">
|
||||
<div class="headline" id="headline"></div>
|
||||
<div class="meta" id="meta"></div>
|
||||
<div class="footer"><span class="agency" id="agency"></span> <span id="updated"></span></div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="overlay.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
183
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/cap-parse.js
Normal file
183
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/cap-parse.js
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
'use strict';
|
||||
|
||||
// CAP-AU parser for the NSW RFS "majorIncidentsCAP" feed (and other CAP-AU sources that
|
||||
// wrap their alerts the same way). Three jobs:
|
||||
// 1. Unwrap the EDXL-DE envelope and pull out each embedded CAP <alert>.
|
||||
// 2. Normalise the bits we actually gate/render on (AlertLevel lives in <parameter>,
|
||||
// NOT in CAP <severity> — RFS leaves severity "Unknown" for routine incidents).
|
||||
// 3. Geofence: is a given screen's lat/lon inside an alert's <area>? CAP coordinates
|
||||
// are "lat,lon" (note: the REVERSE of GeoJSON's lon,lat) — this module keeps the
|
||||
// flip in one place so callers never have to think about it.
|
||||
|
||||
const { XMLParser } = require('fast-xml-parser');
|
||||
|
||||
const parser = new XMLParser({
|
||||
ignoreAttributes: false,
|
||||
removeNSPrefix: true, // EDXLDistribution and alert sit in different namespaces
|
||||
parseTagValue: false, // keep everything as strings; we coerce deliberately
|
||||
trimValues: true,
|
||||
});
|
||||
|
||||
// Always work with arrays even when the XML has a single child.
|
||||
function arr(x) {
|
||||
if (x === undefined || x === null) return [];
|
||||
return Array.isArray(x) ? x : [x];
|
||||
}
|
||||
|
||||
// Pull the <parameter> name/value pairs into a flat map. This is where the useful,
|
||||
// already-structured fields live (AlertLevel, IncidentType, Status, ...), so we read
|
||||
// these instead of regexing the HTML-encoded <description> blob.
|
||||
function paramsToMap(info) {
|
||||
const out = {};
|
||||
for (const p of arr(info && info.parameter)) {
|
||||
if (p && p.valueName != null) out[String(p.valueName)] = p.value == null ? '' : String(p.value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Parse a CAP "<polygon>" string ("lat,lon lat,lon ...") into [{lat, lon}, ...].
|
||||
function parsePolygon(str) {
|
||||
if (!str) return null;
|
||||
const pts = String(str).trim().split(/\s+/).map((pair) => {
|
||||
const [lat, lon] = pair.split(',').map(Number);
|
||||
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null;
|
||||
}).filter(Boolean);
|
||||
return pts.length >= 3 ? pts : null;
|
||||
}
|
||||
|
||||
// Parse a CAP "<circle>" string ("lat,lon radiusKm"). RFS often emits radius 0 (a point),
|
||||
// which can never contain anything, so callers should treat a 0-radius circle as "no
|
||||
// usable circle" and rely on the polygon.
|
||||
function parseCircle(str) {
|
||||
if (!str) return null;
|
||||
const [center, radius] = String(str).trim().split(/\s+/);
|
||||
const [lat, lon] = (center || '').split(',').map(Number);
|
||||
const km = Number(radius);
|
||||
if (![lat, lon, km].every(Number.isFinite)) return null;
|
||||
return { lat, lon, km };
|
||||
}
|
||||
|
||||
// Ray-casting point-in-polygon. We map lon -> x and lat -> y so the algorithm is ordinary
|
||||
// planar; that mapping is the ONE place the CAP lat,lon order is reconciled.
|
||||
function pointInPolygon(pt, poly) {
|
||||
const x = pt.lon, y = pt.lat;
|
||||
let inside = false;
|
||||
for (let i = 0, j = poly.length - 1; i < poly.length; j = i++) {
|
||||
const xi = poly[i].lon, yi = poly[i].lat;
|
||||
const xj = poly[j].lon, yj = poly[j].lat;
|
||||
const intersect = (yi > y) !== (yj > y) &&
|
||||
x < ((xj - xi) * (y - yi)) / (yj - yi) + xi;
|
||||
if (intersect) inside = !inside;
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
function haversineKm(a, b) {
|
||||
const R = 6371;
|
||||
const toRad = (d) => (d * Math.PI) / 180;
|
||||
const dLat = toRad(b.lat - a.lat);
|
||||
const dLon = toRad(b.lon - a.lon);
|
||||
const lat1 = toRad(a.lat), lat2 = toRad(b.lat);
|
||||
const h = Math.sin(dLat / 2) ** 2 + Math.cos(lat1) * Math.cos(lat2) * Math.sin(dLon / 2) ** 2;
|
||||
return 2 * R * Math.asin(Math.sqrt(h));
|
||||
}
|
||||
|
||||
// Does {lat, lon} fall inside this alert's area? Polygon first; fall back to a non-zero
|
||||
// circle. Returns false when the alert has no usable geometry.
|
||||
function pointInAlertArea(point, alert) {
|
||||
if (alert.polygon && pointInPolygon(point, alert.polygon)) return true;
|
||||
if (alert.circle && alert.circle.km > 0 && haversineKm(point, alert.circle) <= alert.circle.km) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Flatten one embedded CAP <alert> into the shape the monitor works with.
|
||||
function normaliseAlert(a) {
|
||||
const info = Array.isArray(a.info) ? a.info[0] : a.info || {};
|
||||
const area = Array.isArray(info.area) ? info.area[0] : info.area || {};
|
||||
const params = paramsToMap(info);
|
||||
return {
|
||||
identifier: a.identifier != null ? String(a.identifier) : null,
|
||||
msgType: a.msgType || null, // Alert | Update | Cancel
|
||||
sent: a.sent || null,
|
||||
headline: info.headline || params.IncidentName || '(no headline)',
|
||||
event: info.event || null,
|
||||
category: info.category || null,
|
||||
responseType: info.responseType || null, // mostly "Monitor" in this feed
|
||||
severity: info.severity || null, // mostly "Unknown" — do NOT gate on this
|
||||
expires: info.expires || null,
|
||||
web: info.web || null,
|
||||
// RFS-specific, the field that actually carries urgency:
|
||||
alertLevel: params.AlertLevel || null, // Planned Burn | Advice | Watch and Act | Emergency Warning
|
||||
incidentType: params.IncidentType || null,
|
||||
status: params.Status || null,
|
||||
size: params.Fireground || params.Size || null,
|
||||
council: params.CouncilArea || params.Location || null,
|
||||
isFire: (params.IsFire || '').toLowerCase() === 'yes',
|
||||
polygon: parsePolygon(area.polygon),
|
||||
circle: parseCircle(area.circle),
|
||||
areaDesc: area.areaDesc || null,
|
||||
params,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse a full feed body (EDXL-DE wrapping embedded CAP alerts) into normalised alerts.
|
||||
function parseFeed(xml) {
|
||||
const root = parser.parse(xml);
|
||||
const dist = root.EDXLDistribution || root.Distribution || null;
|
||||
const alerts = [];
|
||||
if (dist) {
|
||||
for (const co of arr(dist.contentObject)) {
|
||||
const embedded = co && co.xmlContent && co.xmlContent.embeddedXMLContent;
|
||||
for (const e of arr(embedded)) {
|
||||
for (const al of arr(e && e.alert)) alerts.push(normaliseAlert(al));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: a bare CAP feed (no EDXL envelope).
|
||||
for (const al of arr(root.alert)) alerts.push(normaliseAlert(al));
|
||||
}
|
||||
return alerts;
|
||||
}
|
||||
|
||||
// Has this alert's <expires> passed? (Treats missing/unparseable expiry as "not expired".)
|
||||
function isExpired(alert, now = Date.now()) {
|
||||
if (!alert.expires) return false;
|
||||
const t = Date.parse(alert.expires);
|
||||
return Number.isFinite(t) && t <= now;
|
||||
}
|
||||
|
||||
// The gate: should this alert put something on a screen at `point`?
|
||||
// - msgType must be Alert/Update (Cancel clears, never shows)
|
||||
// - not expired
|
||||
// - AlertLevel is at or above the configured threshold
|
||||
// - the screen falls inside the alert area
|
||||
// Returns { show: bool, reason } so callers can log why something did/didn't fire.
|
||||
const DEFAULT_LEVELS = ['Watch and Act', 'Emergency Warning'];
|
||||
|
||||
function shouldShow(alert, point, opts = {}) {
|
||||
const levels = opts.alertLevels || DEFAULT_LEVELS;
|
||||
const now = opts.now || Date.now();
|
||||
if (alert.msgType === 'Cancel') return { show: false, reason: 'cancelled' };
|
||||
if (isExpired(alert, now)) return { show: false, reason: 'expired' };
|
||||
if (!alert.alertLevel || !levels.includes(alert.alertLevel)) {
|
||||
return { show: false, reason: `alertLevel "${alert.alertLevel}" below threshold` };
|
||||
}
|
||||
if (!alert.polygon && !(alert.circle && alert.circle.km > 0)) {
|
||||
return { show: false, reason: 'no usable geometry' };
|
||||
}
|
||||
if (!pointInAlertArea(point, alert)) return { show: false, reason: 'outside area' };
|
||||
return { show: true, reason: 'in-area, at/above threshold' };
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
parseFeed,
|
||||
normaliseAlert,
|
||||
parsePolygon,
|
||||
parseCircle,
|
||||
pointInPolygon,
|
||||
pointInAlertArea,
|
||||
haversineKm,
|
||||
isExpired,
|
||||
shouldShow,
|
||||
DEFAULT_LEVELS,
|
||||
};
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
{
|
||||
"source": "noaa",
|
||||
"poll_interval_sec": 60,
|
||||
|
||||
"api_base": "https://signage.example.com",
|
||||
"api_token": "st_REPLACE_WITH_A_FULL_SCOPE_TOKEN",
|
||||
"overlay_base_url": "https://signage.example.com/alert-overlay.html",
|
||||
|
||||
"min_severity": "Severe",
|
||||
"urgencies": null,
|
||||
"noaa_user_agent": "ScreenTinker-CAP-Alert-Monitor (you@example.com)",
|
||||
|
||||
"screens": [
|
||||
{ "name": "OKC lobby", "lat": 35.4676, "lon": -97.5164, "device_id": "DEVICE_OR_GROUP_ID" }
|
||||
],
|
||||
|
||||
"overlay": { "position": "center", "width": 900, "height": 320, "border_radius": 16 },
|
||||
|
||||
"_demo": "to watch show->expire-removal deterministically: run `node make-demo-alert.js 90`, then add the next line:",
|
||||
"test_feed_file": null
|
||||
}
|
||||
82
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-feed.xml
Normal file
82
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-feed.xml
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
<?xml version="1.0" encoding="utf-8" standalone="no"?><?xml-stylesheet href="lib/RFS_EDXL_simple.xsl" type="text/xsl"?>
|
||||
<EDXLDistribution xmlns="urn:oasis:names:tc:emergency:EDXL:DE:1.0">
|
||||
<distributionID>RFSUniqueID:2026-06-18T00:00:00Z</distributionID>
|
||||
<senderID>webmaster@rfs.nsw.gov.au</senderID>
|
||||
<dateTimeSent>2026-06-18T10:00:00+10:00</dateTimeSent>
|
||||
<distributionStatus>Actual</distributionStatus>
|
||||
<distributionType>Report</distributionType>
|
||||
<contentObject>
|
||||
<contentDescription>Information on Aberdeen HR</contentDescription>
|
||||
<xmlContent><embeddedXMLContent>
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>2026-06-17T14:46:00.0000000:662900</identifier>
|
||||
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-17T14:46:00+10:00</sent>
|
||||
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||
<info>
|
||||
<category>Fire</category><event>Fire</event><responseType>Monitor</responseType>
|
||||
<urgency>Unknown</urgency><severity>Unknown</severity><certainty>Observed</certainty>
|
||||
<expires>2026-06-30T21:25:21+10:00</expires>
|
||||
<headline>Aberdeen HR</headline>
|
||||
<parameter><valueName>AlertLevel</valueName><value>Planned Burn</value></parameter>
|
||||
<parameter><valueName>IncidentType</valueName><value>Hazard Reduction</value></parameter>
|
||||
<parameter><valueName>Status</valueName><value>Under control</value></parameter>
|
||||
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||
<area>
|
||||
<areaDesc>STANBOROUGH</areaDesc>
|
||||
<polygon>-29.974,151.103 -29.984,151.103 -29.984,151.108 -29.974,151.108 -29.974,151.103</polygon>
|
||||
<circle>-29.978,151.105 0</circle>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
</embeddedXMLContent></xmlContent>
|
||||
</contentObject>
|
||||
<contentObject>
|
||||
<contentDescription>Emergency Warning - Test Ridge</contentDescription>
|
||||
<xmlContent><embeddedXMLContent>
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>2026-06-18T09:30:00.0000000:670001</identifier>
|
||||
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:30:00+10:00</sent>
|
||||
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||
<info>
|
||||
<category>Fire</category><event>Bushfire</event><responseType>Evacuate</responseType>
|
||||
<urgency>Immediate</urgency><severity>Extreme</severity><certainty>Observed</certainty>
|
||||
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||
<headline>Test Ridge Road Fire</headline>
|
||||
<parameter><valueName>AlertLevel</valueName><value>Emergency Warning</value></parameter>
|
||||
<parameter><valueName>IncidentType</valueName><value>Bush Fire</value></parameter>
|
||||
<parameter><valueName>Status</valueName><value>Out of control</value></parameter>
|
||||
<parameter><valueName>CouncilArea</valueName><value>Testshire</value></parameter>
|
||||
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||
<area>
|
||||
<areaDesc>Test Ridge - 5km around the screen</areaDesc>
|
||||
<polygon>-33.90,151.10 -33.90,151.30 -33.80,151.30 -33.80,151.10 -33.90,151.10</polygon>
|
||||
<circle>-33.85,151.20 8</circle>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
</embeddedXMLContent></xmlContent>
|
||||
</contentObject>
|
||||
<contentObject>
|
||||
<contentDescription>Watch and Act - far away</contentDescription>
|
||||
<xmlContent><embeddedXMLContent>
|
||||
<alert xmlns="urn:oasis:names:tc:emergency:cap:1.2">
|
||||
<identifier>2026-06-18T09:45:00.0000000:670002</identifier>
|
||||
<sender>webmaster@rfs.nsw.gov.au</sender><sent>2026-06-18T09:45:00+10:00</sent>
|
||||
<status>Actual</status><msgType>Alert</msgType><scope>Public</scope>
|
||||
<info>
|
||||
<category>Fire</category><event>Bushfire</event><responseType>Prepare</responseType>
|
||||
<urgency>Expected</urgency><severity>Severe</severity><certainty>Likely</certainty>
|
||||
<expires>2026-06-30T21:00:00+10:00</expires>
|
||||
<headline>Distant Valley Fire</headline>
|
||||
<parameter><valueName>AlertLevel</valueName><value>Watch and Act</value></parameter>
|
||||
<parameter><valueName>IsFire</valueName><value>Yes</value></parameter>
|
||||
<area>
|
||||
<areaDesc>Distant Valley (far from screen)</areaDesc>
|
||||
<polygon>-31.00,150.00 -31.00,150.10 -30.90,150.10 -30.90,150.00 -31.00,150.00</polygon>
|
||||
<circle>-30.95,150.05 0</circle>
|
||||
</area>
|
||||
</info>
|
||||
</alert>
|
||||
</embeddedXMLContent></xmlContent>
|
||||
</contentObject>
|
||||
</EDXLDistribution>
|
||||
90
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-noaa.json
Normal file
90
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/fixture-noaa.json
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
{
|
||||
"type": "FeatureCollection",
|
||||
"title": "Fixture: NWS active alerts (offline test for noaa-parse)",
|
||||
"features": [
|
||||
{
|
||||
"id": "NWS-TEST-TORNADO-1",
|
||||
"type": "Feature",
|
||||
"geometry": null,
|
||||
"properties": {
|
||||
"id": "NWS-TEST-TORNADO-1",
|
||||
"event": "Tornado Warning",
|
||||
"severity": "Extreme",
|
||||
"urgency": "Immediate",
|
||||
"certainty": "Observed",
|
||||
"messageType": "Alert",
|
||||
"status": "Actual",
|
||||
"sent": "2026-06-18T10:00:00-05:00",
|
||||
"effective": "2026-06-18T10:00:00-05:00",
|
||||
"expires": "2026-06-18T10:01:00-05:00",
|
||||
"headline": "Tornado Warning issued June 18 at 10:00AM CDT",
|
||||
"areaDesc": "Test County, ST",
|
||||
"senderName": "NWS Test Office",
|
||||
"response": "Shelter"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "NWS-TEST-WINTER-3",
|
||||
"type": "Feature",
|
||||
"geometry": null,
|
||||
"properties": {
|
||||
"id": "NWS-TEST-WINTER-3",
|
||||
"event": "Winter Storm Warning",
|
||||
"severity": "Severe",
|
||||
"urgency": "Expected",
|
||||
"certainty": "Likely",
|
||||
"messageType": "Alert",
|
||||
"status": "Actual",
|
||||
"sent": "2026-06-18T09:30:00-05:00",
|
||||
"effective": "2026-06-18T09:30:00-05:00",
|
||||
"expires": "2026-06-18T20:00:00-05:00",
|
||||
"headline": "Winter Storm Warning in effect",
|
||||
"areaDesc": "Test County, ST",
|
||||
"senderName": "NWS Test Office",
|
||||
"response": "Prepare"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "NWS-TEST-FLOOD-2",
|
||||
"type": "Feature",
|
||||
"geometry": null,
|
||||
"properties": {
|
||||
"id": "NWS-TEST-FLOOD-2",
|
||||
"event": "Flood Advisory",
|
||||
"severity": "Minor",
|
||||
"urgency": "Expected",
|
||||
"certainty": "Likely",
|
||||
"messageType": "Alert",
|
||||
"status": "Actual",
|
||||
"sent": "2026-06-18T09:45:00-05:00",
|
||||
"effective": "2026-06-18T09:45:00-05:00",
|
||||
"expires": "2026-06-18T20:00:00-05:00",
|
||||
"headline": "Flood Advisory in effect",
|
||||
"areaDesc": "Test County, ST",
|
||||
"senderName": "NWS Test Office",
|
||||
"response": "Avoid"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "NWS-TEST-CANCEL-4",
|
||||
"type": "Feature",
|
||||
"geometry": null,
|
||||
"properties": {
|
||||
"id": "NWS-TEST-CANCEL-4",
|
||||
"event": "Severe Thunderstorm Warning",
|
||||
"severity": "Severe",
|
||||
"urgency": "Immediate",
|
||||
"certainty": "Observed",
|
||||
"messageType": "Cancel",
|
||||
"status": "Actual",
|
||||
"sent": "2026-06-18T09:55:00-05:00",
|
||||
"effective": "2026-06-18T09:55:00-05:00",
|
||||
"expires": "2026-06-18T20:00:00-05:00",
|
||||
"headline": "Severe Thunderstorm Warning cancelled",
|
||||
"areaDesc": "Test County, ST",
|
||||
"senderName": "NWS Test Office",
|
||||
"response": "AllClear"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
25
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/make-demo-alert.js
Normal file
25
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/make-demo-alert.js
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Usage: node make-demo-alert.js [seconds] [outfile]
|
||||
// Writes a NWS-shaped FeatureCollection with one Extreme alert expiring `seconds` from now
|
||||
// (default 90). Point the monitor's config.test_feed_file at the output to watch show->expire.
|
||||
const fs = require('fs');
|
||||
const secs = parseInt(process.argv[2] || '90', 10);
|
||||
const out = process.argv[3] || 'demo-noaa.json';
|
||||
const now = new Date();
|
||||
const expires = new Date(now.getTime() + secs * 1000);
|
||||
const fc = {
|
||||
type: 'FeatureCollection',
|
||||
features: [{
|
||||
id: 'https://api.weather.gov/alerts/DEMO-EXPIRY-1', type: 'Feature', geometry: null,
|
||||
properties: {
|
||||
id: 'DEMO-EXPIRY-1', areaDesc: 'Demo County',
|
||||
sent: now.toISOString(), effective: now.toISOString(), onset: now.toISOString(),
|
||||
expires: expires.toISOString(), ends: expires.toISOString(),
|
||||
status: 'Actual', messageType: 'Alert', category: 'Met',
|
||||
severity: 'Extreme', certainty: 'Observed', urgency: 'Immediate',
|
||||
event: 'Tornado Warning', senderName: 'NWS Demo Office',
|
||||
headline: `DEMO alert — auto-clears at ${expires.toLocaleTimeString()}`, response: 'Shelter',
|
||||
},
|
||||
}],
|
||||
};
|
||||
fs.writeFileSync(out, JSON.stringify(fc, null, 2));
|
||||
console.log(`wrote ${out}: DEMO Tornado Warning expiring in ${secs}s (at ${expires.toISOString()})`);
|
||||
247
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/monitor.js
Normal file
247
Examples/PIP-USD-Cap-Alert-Monitor-NOAA/monitor.js
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
'use strict';
|
||||
|
||||
// CAP -> ScreenTinker PiP monitor. Supports two sources via config.source:
|
||||
// "capau" (default) - NSW RFS EDXL/CAP-AU feed, client-side polygon geofence, gate on AlertLevel.
|
||||
// "noaa" - api.weather.gov, server-side ?point= geofence, gate on real CAP severity.
|
||||
//
|
||||
// For each configured screen it pushes a PiP web overlay when a qualifying alert covers
|
||||
// that screen, and clears it when the alert expires, is cancelled, or drops out. Overlays
|
||||
// also self-remove at the alert's `expires` time via the PiP `duration` field (the player
|
||||
// auto-clears), so they vanish on expiry even between polls.
|
||||
//
|
||||
// node monitor.js [path/to/config.json]
|
||||
//
|
||||
// Node 18+ (global fetch). Needs an st_ API token with the 'full' scope.
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const cap = require('./cap-parse');
|
||||
const noaa = require('./noaa-parse');
|
||||
|
||||
const configPath = process.argv[2] || path.join(__dirname, 'config.json');
|
||||
let cfg;
|
||||
try { cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); }
|
||||
catch (e) { console.error(`Could not read config at ${configPath}: ${e.message}`); process.exit(1); }
|
||||
|
||||
const SOURCE = (cfg.source || 'capau').toLowerCase();
|
||||
const POLL_SEC = cfg.poll_interval_sec || 120;
|
||||
const API_BASE = (cfg.api_base || '').replace(/\/$/, '');
|
||||
const API_TOKEN = cfg.api_token;
|
||||
const OVERLAY_BASE = cfg.overlay_base_url;
|
||||
const SCREENS = cfg.screens || [];
|
||||
const OVERLAY = cfg.overlay || {};
|
||||
const PIP_DUR_MAX = 86400; // PiP API cap (seconds)
|
||||
|
||||
// capau-only:
|
||||
const FEED_URL = cfg.feed_url || 'https://www.rfs.nsw.gov.au/feeds/majorIncidentsCAP.xml';
|
||||
const ALERT_LEVELS = cfg.alert_levels || cap.DEFAULT_LEVELS;
|
||||
const CAPAU_COLORS = Object.assign({ 'Emergency Warning': 'CC0000', 'Watch and Act': 'E8730C', 'Advice': 'F2C200' }, OVERLAY.colors || {});
|
||||
|
||||
if (!API_BASE || !API_TOKEN || !OVERLAY_BASE || SCREENS.length === 0) {
|
||||
console.error('config must set api_base, api_token, overlay_base_url, and at least one screen.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// active overlays: key `${device_id}|${identifier}` -> { pip_id, expiresAt }
|
||||
const active = new Map();
|
||||
const keyFor = (deviceId, identifier) => `${deviceId}|${identifier}`;
|
||||
|
||||
// Map a normalised alert (either source) to the overlay's display fields.
|
||||
function viewOf(alert) {
|
||||
if (alert.source === 'noaa') {
|
||||
return {
|
||||
level: alert.displayLevel, color: alert.color, headline: alert.headline,
|
||||
area: alert.areaDesc || '', status: alert.response || alert.urgency || '',
|
||||
updated: alert.sent || '', agency: alert.agency || 'US National Weather Service',
|
||||
};
|
||||
}
|
||||
return {
|
||||
level: alert.alertLevel || 'Alert',
|
||||
color: CAPAU_COLORS[alert.alertLevel] || 'CC0000',
|
||||
headline: alert.headline || '',
|
||||
area: alert.areaDesc || alert.council || '',
|
||||
status: alert.status || '',
|
||||
updated: alert.sent || '',
|
||||
agency: OVERLAY.agency || 'NSW Rural Fire Service',
|
||||
};
|
||||
}
|
||||
|
||||
function overlayUri(alert) {
|
||||
const v = viewOf(alert);
|
||||
const q = new URLSearchParams({
|
||||
level: v.level || '', headline: v.headline || '', area: v.area || '',
|
||||
status: v.status || '', updated: v.updated || '',
|
||||
color: (v.color || 'CC0000').replace(/[^0-9a-fA-F]/g, ''), agency: v.agency || '',
|
||||
});
|
||||
return `${OVERLAY_BASE}${OVERLAY_BASE.includes('?') ? '&' : '?'}${q.toString()}`;
|
||||
}
|
||||
|
||||
// Seconds until expiry, clamped to the PiP duration range. 0 => keep until we clear it.
|
||||
function durationForExpiry(alert, now = Date.now()) {
|
||||
if (!alert.expires) return 0;
|
||||
const t = Date.parse(alert.expires);
|
||||
if (!Number.isFinite(t)) return 0;
|
||||
const secs = Math.floor((t - now) / 1000);
|
||||
if (secs <= 0) return 0;
|
||||
return Math.min(secs, PIP_DUR_MAX);
|
||||
}
|
||||
|
||||
async function pipShow(deviceId, alert) {
|
||||
const body = {
|
||||
device_id: deviceId, type: 'web', uri: overlayUri(alert),
|
||||
position: OVERLAY.position || 'center',
|
||||
width: OVERLAY.width || 900, height: OVERLAY.height || 320,
|
||||
duration: durationForExpiry(alert),
|
||||
opacity: OVERLAY.opacity != null ? OVERLAY.opacity : 1,
|
||||
border_radius: OVERLAY.border_radius != null ? OVERLAY.border_radius : 16,
|
||||
close_button: false,
|
||||
title: viewOf(alert).level,
|
||||
};
|
||||
const res = await fetch(`${API_BASE}/api/pip`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const json = await res.json().catch(() => ({}));
|
||||
if (!res.ok || !json.pip_id) throw new Error(`pip show failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
return { pipId: json.pip_id, duration: body.duration };
|
||||
}
|
||||
|
||||
async function pipClear(deviceId, pipId) {
|
||||
const res = await fetch(`${API_BASE}/api/pip/clear`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${API_TOKEN}` },
|
||||
body: JSON.stringify({ device_id: deviceId, pip_id: pipId }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const json = await res.json().catch(() => ({}));
|
||||
throw new Error(`pip clear failed (${res.status}): ${json.error || 'unknown'}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Gate without geofence (for the test_feed_file override, where geometry/point isn't real).
|
||||
function passesNonGeo(alert, now) {
|
||||
if (alert.msgType === 'Cancel') return false;
|
||||
if (SOURCE === 'noaa') {
|
||||
if (alert.status && alert.status !== 'Actual') return false;
|
||||
if (noaa.isExpired(alert, now)) return false;
|
||||
return (noaa.SEV_RANK[alert.severity] || 0) >= (noaa.SEV_RANK[cfg.min_severity || 'Severe'] || 0);
|
||||
}
|
||||
if (cap.isExpired(alert, now)) return false;
|
||||
return !!alert.alertLevel && ALERT_LEVELS.includes(alert.alertLevel);
|
||||
}
|
||||
|
||||
async function collect(now) {
|
||||
const pairs = [];
|
||||
const polled = new Set();
|
||||
|
||||
// Test/demo override: read alerts from a local file instead of the network, geofence
|
||||
// bypassed (every alert applies to every screen). Lets you watch the show->expire->remove
|
||||
// lifecycle on a deterministic timer. Remove `test_feed_file` from config for real use.
|
||||
if (cfg.test_feed_file) {
|
||||
let alerts = [];
|
||||
try {
|
||||
const raw = fs.readFileSync(cfg.test_feed_file, 'utf8');
|
||||
alerts = SOURCE === 'noaa' ? noaa.normaliseFeatureCollection(raw) : cap.parseFeed(raw);
|
||||
} catch (e) { console.error(`test_feed_file read error: ${e.message}`); return { pairs, polled }; }
|
||||
for (const screen of SCREENS) {
|
||||
polled.add(screen.device_id);
|
||||
for (const a of alerts) {
|
||||
if (a.identifier && passesNonGeo(a, now)) pairs.push({ screen, alert: a });
|
||||
}
|
||||
}
|
||||
return { pairs, polled };
|
||||
}
|
||||
|
||||
if (SOURCE === 'noaa') {
|
||||
for (const screen of SCREENS) {
|
||||
let alerts;
|
||||
try { alerts = await noaa.fetchActiveForPoint(screen.lat, screen.lon, cfg.noaa_user_agent); }
|
||||
catch (e) { console.error(`[${new Date().toISOString()}] NWS fetch error for ${screen.name}: ${e.message}`); continue; }
|
||||
polled.add(screen.device_id);
|
||||
for (const a of alerts) {
|
||||
if (!a.identifier) continue;
|
||||
if (noaa.shouldShow(a, { minSeverity: cfg.min_severity, urgencies: cfg.urgencies, now }).show) {
|
||||
pairs.push({ screen, alert: a });
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let alerts;
|
||||
try {
|
||||
const res = await fetch(FEED_URL, { headers: { Accept: 'application/xml, text/xml' } });
|
||||
if (!res.ok) throw new Error(`feed HTTP ${res.status}`);
|
||||
alerts = cap.parseFeed(await res.text());
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] feed fetch/parse error: ${e.message}`);
|
||||
return { pairs: [], polled };
|
||||
}
|
||||
for (const screen of SCREENS) {
|
||||
polled.add(screen.device_id);
|
||||
const point = { lat: screen.lat, lon: screen.lon };
|
||||
for (const a of alerts) {
|
||||
if (!a.identifier) continue;
|
||||
if (cap.shouldShow(a, { alertLevels: ALERT_LEVELS, now }).show) pairs.push({ screen, alert: a });
|
||||
}
|
||||
}
|
||||
}
|
||||
return { pairs, polled };
|
||||
}
|
||||
|
||||
async function tick() {
|
||||
const now = Date.now();
|
||||
const { pairs, polled } = await collect(now);
|
||||
const stillQualifying = new Set();
|
||||
|
||||
for (const { screen, alert } of pairs) {
|
||||
const key = keyFor(screen.device_id, alert.identifier);
|
||||
stillQualifying.add(key);
|
||||
if (active.has(key)) continue;
|
||||
try {
|
||||
const { pipId, duration } = await pipShow(screen.device_id, alert);
|
||||
active.set(key, { pip_id: pipId, expiresAt: Date.parse(alert.expires) || null });
|
||||
const v = viewOf(alert);
|
||||
console.log(`[${new Date().toISOString()}] SHOW "${alert.headline}" (${v.level}) on ${screen.name} pip=${pipId} dur=${duration || '∞'}s`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] show error on ${screen.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
for (const [key, rec] of [...active.entries()]) {
|
||||
const [deviceId] = key.split('|');
|
||||
if (!polled.has(deviceId)) continue;
|
||||
if (stillQualifying.has(key)) continue;
|
||||
try {
|
||||
await pipClear(deviceId, rec.pip_id);
|
||||
active.delete(key);
|
||||
console.log(`[${new Date().toISOString()}] CLEAR pip=${rec.pip_id} on ${deviceId} (gone/expired/cancelled)`);
|
||||
} catch (e) {
|
||||
console.error(`[${new Date().toISOString()}] clear error: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`CAP PiP monitor starting — source=${SOURCE}`);
|
||||
console.log(` poll: every ${POLL_SEC}s`);
|
||||
if (SOURCE === 'noaa') console.log(` min severity: ${cfg.min_severity || 'Severe'}${cfg.urgencies ? `, urgency in [${cfg.urgencies.join(',')}]` : ''}`);
|
||||
else console.log(` feed: ${FEED_URL}\n levels: ${ALERT_LEVELS.join(', ')}`);
|
||||
console.log(` screens: ${SCREENS.map(s => `${s.name}(${s.lat},${s.lon})`).join(', ')}`);
|
||||
|
||||
await tick();
|
||||
const timer = setInterval(tick, POLL_SEC * 1000);
|
||||
|
||||
async function shutdown() {
|
||||
clearInterval(timer);
|
||||
console.log('\nclearing active overlays before exit...');
|
||||
for (const [key, rec] of active.entries()) {
|
||||
const [deviceId] = key.split('|');
|
||||
try { await pipClear(deviceId, rec.pip_id); } catch { /* best effort */ }
|
||||
}
|
||||
process.exit(0);
|
||||
}
|
||||
process.on('SIGINT', shutdown);
|
||||
process.on('SIGTERM', shutdown);
|
||||
}
|
||||
|
||||
main();
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue