mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 03:02:17 -05:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b57ac1725 | ||
|
|
00cd125fda | ||
|
|
684acdd2b6 | ||
|
|
f138cd7b96 | ||
|
|
6454a9b460 | ||
|
|
1976377235 | ||
|
|
52c171c256 | ||
|
|
42bf67d207 | ||
|
|
d65fd99de4 | ||
|
|
e3d4d9af9b | ||
|
|
ab730ee8a3 | ||
|
|
8fe4f2f98e | ||
|
|
89f3a1cb65 | ||
|
|
79d36cae93 | ||
|
|
1dcd7e5d7d | ||
|
|
b4bc99ab27 | ||
|
|
c94e1a0508 | ||
|
|
abcb0871b2 | ||
|
|
1ffe680895 | ||
|
|
ed26b90384 | ||
|
|
2b48fee00f | ||
|
|
1284125fff | ||
|
|
28d0b1437d | ||
|
|
9d3b8df4bf | ||
|
|
9d42cb5581 | ||
|
|
e670226f8c | ||
|
|
0669f29a4a | ||
|
|
38d3465bf8 | ||
|
|
6bfbb8ad80 | ||
|
|
ba2f66ebc1 | ||
|
|
fde2fca0a0 | ||
|
|
d36ea4f672 | ||
|
|
4be6e6c9a2 | ||
|
|
7bac2fc447 | ||
|
|
e8a407cd49 | ||
|
|
bee1866a70 | ||
|
|
ddfc889a8c | ||
|
|
c71267873a | ||
|
|
1ba4ebe5ff | ||
|
|
607d327e9c | ||
|
|
1bdca60648 | ||
|
|
ea4af2ebb3 | ||
|
|
b2bbaf2085 | ||
|
|
18e7fb8d1b | ||
|
|
ba71106544 | ||
|
|
a395656056 | ||
|
|
50c2c1f5db | ||
|
|
b59389870e | ||
|
|
cd4aee4e74 | ||
|
|
9de0bfb1e7 | ||
|
|
008c90edf1 | ||
|
|
eed88983ca | ||
|
|
923c04736b | ||
|
|
bdc3eb640d | ||
|
|
01354c56e6 | ||
|
|
0ff03a74a5 | ||
|
|
73a78eef20 | ||
|
|
a157ece816 |
@@ -5,6 +5,7 @@
|
|||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
**/node_modules
|
**/node_modules
|
||||||
|
webui/.cache/mediamtx/
|
||||||
# Neither the legacy state directory nor the Compose-mounted replacement may
|
# Neither the legacy state directory nor the Compose-mounted replacement may
|
||||||
# enter an image build. This prevents credentials and backups from becoming
|
# enter an image build. This prevents credentials and backups from becoming
|
||||||
# image layers after an operator has started using either deployment layout.
|
# image layers after an operator has started using either deployment layout.
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ jobs:
|
|||||||
# replaced on each successful push, just as main replaces `latest`.
|
# replaced on each successful push, just as main replaces `latest`.
|
||||||
- name: Build and optionally publish
|
- name: Build and optionally publish
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
|
env:
|
||||||
|
DOCKER_BUILD_RECORD_RETENTION_DAYS: 3
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
platforms: linux/amd64
|
platforms: linux/amd64
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ create_2_Open_Interface_Spec.txt
|
|||||||
|
|
||||||
logs
|
logs
|
||||||
node_modules/
|
node_modules/
|
||||||
|
webui/.cache/mediamtx/
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
.pio
|
.pio
|
||||||
@@ -41,3 +42,4 @@ server/data/fleet-reports.sqlite-wal
|
|||||||
server/data/configuration.sqlite
|
server/data/configuration.sqlite
|
||||||
server/data/configuration.sqlite-shm
|
server/data/configuration.sqlite-shm
|
||||||
server/data/configuration.sqlite-wal
|
server/data/configuration.sqlite-wal
|
||||||
|
AGENTS.md
|
||||||
|
|||||||
+43
-19
@@ -9,15 +9,21 @@
|
|||||||
ARG FEDORA_VERSION=43
|
ARG FEDORA_VERSION=43
|
||||||
|
|
||||||
FROM fedora:${FEDORA_VERSION} AS architecture-check
|
FROM fedora:${FEDORA_VERSION} AS architecture-check
|
||||||
|
ARG FEDORA_VERSION
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
# The central server is currently deployed and verified only on amd64. Failing
|
# The central server is currently deployed and verified only on amd64. Failing
|
||||||
# here avoids publishing an ARM image whose native workers and hardware paths
|
# here avoids publishing an ARM image whose native workers and hardware paths
|
||||||
# have never been exercised on a real ARM server.
|
# have never been exercised on a real ARM server.
|
||||||
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support only linux/amd64." >&2; exit 1)
|
||||||
|
|
||||||
FROM architecture-check AS webui-build
|
# Both Node builders reuse the same installed toolchain layer.
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm \
|
# Fedora 43 uses DNF5. Cache metadata and RPMs outside image layers, and keep
|
||||||
&& dnf clean all
|
# downloads after installation so invalidated layers can reuse them.
|
||||||
|
FROM architecture-check AS node-build-base
|
||||||
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-node-build-base,target=/var/cache/libdnf5,sharing=locked \
|
||||||
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True nodejs npm
|
||||||
|
|
||||||
|
FROM node-build-base AS webui-build
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
# Copy dependency manifests first so ordinary source edits retain the expensive
|
# Copy dependency manifests first so ordinary source edits retain the expensive
|
||||||
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency
|
# npm cache layer. npm ci makes the checked-in lockfile the exact dependency
|
||||||
@@ -25,14 +31,17 @@ WORKDIR /build
|
|||||||
COPY webui/package.json webui/package-lock.json ./webui/
|
COPY webui/package.json webui/package-lock.json ./webui/
|
||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
cd webui && npm ci
|
cd webui && npm ci
|
||||||
|
# Keep the pinned browser publisher in a reusable layer across UI source edits.
|
||||||
|
COPY webui/scripts/prepareMediaMtx.js ./webui/scripts/prepareMediaMtx.js
|
||||||
|
RUN cd webui && node scripts/prepareMediaMtx.js
|
||||||
COPY webui ./webui
|
COPY webui ./webui
|
||||||
# Vite deliberately emits into ../server/public. Create that destination in the
|
# Vite deliberately emits into ../server/public. Create that destination in the
|
||||||
# isolated builder and copy only its finished files into the runtime stage.
|
# isolated builder and copy only its finished files into the runtime stage.
|
||||||
RUN mkdir -p server/public && cd webui && npm run build
|
RUN mkdir -p server/public && cd webui && npm run build
|
||||||
|
|
||||||
FROM architecture-check AS server-dependencies
|
FROM node-build-base AS server-dependencies
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False nodejs npm gcc-c++ make python3 \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-server-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||||
&& dnf clean all
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True gcc-c++ make python3
|
||||||
WORKDIR /build/server
|
WORKDIR /build/server
|
||||||
COPY server/package.json server/package-lock.json ./
|
COPY server/package.json server/package-lock.json ./
|
||||||
# Native Node modules compile here when a matching prebuild is unavailable;
|
# Native Node modules compile here when a matching prebuild is unavailable;
|
||||||
@@ -41,7 +50,8 @@ RUN --mount=type=cache,target=/root/.npm \
|
|||||||
npm ci --omit=dev
|
npm ci --omit=dev
|
||||||
|
|
||||||
FROM architecture-check AS native-workers
|
FROM architecture-check AS native-workers
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-native-workers,target=/var/cache/libdnf5,sharing=locked \
|
||||||
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
|
||||||
bluez-libs-devel \
|
bluez-libs-devel \
|
||||||
gcc-c++ \
|
gcc-c++ \
|
||||||
libcap \
|
libcap \
|
||||||
@@ -49,8 +59,7 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
|||||||
libusb1-devel \
|
libusb1-devel \
|
||||||
make \
|
make \
|
||||||
pkgconf-pkg-config \
|
pkgconf-pkg-config \
|
||||||
wiiuse-devel \
|
wiiuse-devel
|
||||||
&& dnf clean all
|
|
||||||
WORKDIR /build
|
WORKDIR /build
|
||||||
COPY server/src/services/kinectService/native ./kinect
|
COPY server/src/services/kinectService/native ./kinect
|
||||||
COPY server/src/services/balanceBoardService/native ./balance-board
|
COPY server/src/services/balanceBoardService/native ./balance-board
|
||||||
@@ -66,8 +75,8 @@ ARG NEOLINK_VERSION=0.6.2
|
|||||||
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
|
ARG NEOLINK_SHA256=0cb963b44dca7ccc5333154092186e1536c687aef0e1ad119b7f310a7471dbbb
|
||||||
ARG GOOGLE_TTS_VERSION=26.5
|
ARG GOOGLE_TTS_VERSION=26.5
|
||||||
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
|
ARG GOOGLE_TTS_SHA256=6a9eae6726871788da52e767dad964a1c83e7feb7e7dbac508a2574a2345ac24
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False curl findutils tar unzip xz \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-packaged-tools,target=/var/cache/libdnf5,sharing=locked \
|
||||||
&& dnf clean all
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True curl findutils tar unzip xz
|
||||||
WORKDIR /build/tools
|
WORKDIR /build/tools
|
||||||
# Each external artifact is pinned and checked before extraction. A changed or
|
# Each external artifact is pinned and checked before extraction. A changed or
|
||||||
# truncated upstream download therefore fails the image build instead of being
|
# truncated upstream download therefore fails the image build instead of being
|
||||||
@@ -97,6 +106,16 @@ RUN curl --fail --location --retry 3 \
|
|||||||
&& find /output/opt/roverd/googletts -type d -exec chmod 0755 {} + \
|
&& find /output/opt/roverd/googletts -type d -exec chmod 0755 {} + \
|
||||||
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
|
&& find /output/opt/roverd/googletts -type f -exec chmod 0644 {} +
|
||||||
|
|
||||||
|
FROM architecture-check AS learning-dependencies
|
||||||
|
# CPU-only learning dependencies are built once. Disabled installations never
|
||||||
|
# launch Python or install packages as a side effect of starting Node.
|
||||||
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-learning-dependencies,target=/var/cache/libdnf5,sharing=locked \
|
||||||
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True python3 python3-pip
|
||||||
|
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
python3 -m venv /opt/rover-learning \
|
||||||
|
&& /opt/rover-learning/bin/pip install -r /tmp/learning-requirements.txt
|
||||||
|
|
||||||
FROM fedora:${FEDORA_VERSION} AS runtime
|
FROM fedora:${FEDORA_VERSION} AS runtime
|
||||||
ARG FEDORA_VERSION
|
ARG FEDORA_VERSION
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
@@ -105,14 +124,16 @@ RUN test "${TARGETARCH}" = "amd64" || (echo "MultiRover server images support on
|
|||||||
# replay output path. Enable RPM Fusion Free before installing runtime packages
|
# replay output path. Enable RPM Fusion Free before installing runtime packages
|
||||||
# so the image receives the complete FFmpeg build instead of requiring replay
|
# so the image receives the complete FFmpeg build instead of requiring replay
|
||||||
# code to work around a deployment-only codec omission.
|
# code to work around a deployment-only codec omission.
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-runtime,target=/var/cache/libdnf5,sharing=locked \
|
||||||
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
|
||||||
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm"
|
"https://download1.rpmfusion.org/free/fedora/rpmfusion-free-release-${FEDORA_VERSION}.noarch.rpm"
|
||||||
# This is the complete runtime package set. Build headers and compilers live in
|
# This is the complete runtime package set. Build headers and compilers live in
|
||||||
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
# earlier stages, while media, TTS, USB, and Bluetooth libraries remain here
|
||||||
# because enabled services invoke them after startup. Weak dependencies are
|
# because enabled services invoke them after startup. Weak dependencies are
|
||||||
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
|
# deliberately disabled: Fedora otherwise installs desktop portals, graphical
|
||||||
# themes, and GPU drivers that a headless server neither starts nor uses.
|
# themes, and GPU drivers that a headless server neither starts nor uses.
|
||||||
RUN dnf install -y --setopt=install_weak_deps=False \
|
RUN --mount=type=cache,id=dnf-${FEDORA_VERSION}-runtime,target=/var/cache/libdnf5,sharing=locked \
|
||||||
|
dnf install -y --setopt=install_weak_deps=False --setopt=keepcache=True \
|
||||||
bluez \
|
bluez \
|
||||||
bluez-libs \
|
bluez-libs \
|
||||||
espeak \
|
espeak \
|
||||||
@@ -134,20 +155,15 @@ RUN dnf install -y --setopt=install_weak_deps=False \
|
|||||||
tini \
|
tini \
|
||||||
wiiuse \
|
wiiuse \
|
||||||
--allowerasing \
|
--allowerasing \
|
||||||
&& dnf clean all \
|
|
||||||
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
|
&& useradd --uid 1000 --create-home --home-dir /home/multirover --shell /sbin/nologin multirover \
|
||||||
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
|
&& install -d -o multirover -g multirover -m 0755 /data /opt/multirover/server
|
||||||
|
|
||||||
WORKDIR /opt/multirover/server
|
WORKDIR /opt/multirover/server
|
||||||
COPY server/index.js server/package.json server/package-lock.json ./
|
|
||||||
COPY server/src ./src
|
|
||||||
COPY server/assets ./assets
|
|
||||||
COPY server/prompts ./prompts
|
|
||||||
COPY --from=server-dependencies /build/server/node_modules ./node_modules
|
COPY --from=server-dependencies /build/server/node_modules ./node_modules
|
||||||
COPY --from=webui-build /build/server/public ./public
|
|
||||||
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
|
COPY --from=native-workers /build/kinect/kinect_worker ./src/services/kinectService/native/kinect_worker
|
||||||
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
COPY --from=native-workers /build/balance-board/balance_board_worker ./src/services/balanceBoardService/native/balance_board_worker
|
||||||
COPY --from=packaged-tools /output/ /
|
COPY --from=packaged-tools /output/ /
|
||||||
|
COPY --from=learning-dependencies /opt/rover-learning /opt/rover-learning
|
||||||
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
|
COPY --chmod=0755 server/bin/chromegtts-wav.py /usr/local/bin/chromegtts-wav
|
||||||
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
|
COPY --chmod=0755 server/mediamtx/rover-snapshot-writer.sh /usr/local/bin/rover-snapshot-writer.sh
|
||||||
|
|
||||||
@@ -166,6 +182,14 @@ RUN setcap cap_net_admin,cap_net_bind_service+ep \
|
|||||||
&& test -s /tmp/chromegtts-smoke.wav \
|
&& test -s /tmp/chromegtts-smoke.wav \
|
||||||
&& rm /tmp/chromegtts-smoke.wav
|
&& rm /tmp/chromegtts-smoke.wav
|
||||||
|
|
||||||
|
# Frequently edited application files come after dependencies and tool checks,
|
||||||
|
# so server changes do not invalidate the large runtime dependency layers.
|
||||||
|
COPY --from=webui-build /build/server/public ./public
|
||||||
|
COPY server/index.js server/package.json server/package-lock.json ./
|
||||||
|
COPY server/src ./src
|
||||||
|
COPY server/assets ./assets
|
||||||
|
COPY server/prompts ./prompts
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
SERVER_DATA_DIR=/data \
|
SERVER_DATA_DIR=/data \
|
||||||
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
|
ROVER_SNAPSHOT_WRITER_BIN=/usr/local/bin/rover-snapshot-writer.sh
|
||||||
|
|||||||
+5
-1
@@ -23,9 +23,13 @@ services:
|
|||||||
# Required for Bluetooth hardware such as the Balance Board.
|
# Required for Bluetooth hardware such as the Balance Board.
|
||||||
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
|
- /run/dbus/system_bus_socket:/run/dbus/system_bus_socket:ro
|
||||||
|
|
||||||
# Required for Kinect USB access.
|
# Kinect USB access.
|
||||||
devices:
|
devices:
|
||||||
- /dev/bus/usb:/dev/bus/usb
|
- /dev/bus/usb:/dev/bus/usb
|
||||||
|
# For a server-local microphone, uncomment this device and group_add below.
|
||||||
|
# - /dev/snd:/dev/snd
|
||||||
|
# group_add:
|
||||||
|
# - "${AUDIO_GID:?Set AUDIO_GID to the host sound device group ID}"
|
||||||
|
|
||||||
cap_add:
|
cap_add:
|
||||||
- NET_ADMIN
|
- NET_ADMIN
|
||||||
|
|||||||
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
@@ -26,6 +26,8 @@ Phase 1 must be complete and verified before Phase 2 begins. Containerization mu
|
|||||||
|
|
||||||
## Decision log
|
## Decision log
|
||||||
|
|
||||||
|
- 2026-10-02: The admin container controls accept a tag within the repository supplied by Compose through `MULTIROVER_TARGET_IMAGE`. A successful healthy replacement saves the tag in the lifecycle status volume; failed replacements retain the previous selection. Checks and later updates follow that saved tag. Changing repositories in Compose resets the selection. Manual Compose deployments still use the Compose image and can replace a UI-selected image; the UI displays both references. The lifecycle helper itself is updated through Compose. Image rollback does not revert application data migrations. Deploy this feature to both the application and lifecycle services before using tag selection.
|
||||||
|
|
||||||
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
|
- 2026-09-14: Publish `ghcr.io/legop3/multiroombarover:latest` only from the repository's main branch. Every other repository branch publishes one moving development image named for that branch, with invalid tag separators normalized; these are development selectors rather than numbered releases. Pull requests only verify that the image builds. There are no release numbers, semantic-version tags, stable/edge channels, or operator-facing version selection. Docker image digests remain an internal mechanism for detecting an available update and retaining the previously running image for rollback.
|
||||||
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
|
- 2026-09-15: Support only `linux/amd64` for the central server image. Rover computers remain independently ARM-capable, but publishing an untested ARM server image would multiply native-worker, media-binary, TTS-library, and hardware validation without serving the current deployment. ARM server support can be added later when a real ARM server exists to verify it.
|
||||||
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
|
- 2026-09-15: Mount the application data directory from the Docker-managed `multirover-data` named volume instead of a host bind path. Docker initializes the empty volume with the image's non-root ownership, eliminating host UID matching, directory creation, ownership commands, and root application startup. The admin backup/restore system is the supported portable interface to the complete data tree.
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ socket.on('connect', async () => {
|
|||||||
// previously stored session with this object instead of merging snapshots.
|
// previously stored session with this object instead of merging snapshots.
|
||||||
socket.on('session:sync', (session) => {
|
socket.on('session:sync', (session) => {
|
||||||
console.log('Session:', session);
|
console.log('Session:', session);
|
||||||
|
console.log('Server mode:', session.mode);
|
||||||
});
|
});
|
||||||
|
|
||||||
// chat:init contains the recent chat history available when the bot connects.
|
// chat:init contains the recent chat history available when the bot connects.
|
||||||
@@ -105,6 +106,21 @@ Run it with:
|
|||||||
node bot.js
|
node bot.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Reading server mode
|
||||||
|
|
||||||
|
Read `session.mode` from the `session:sync` snapshot to get the current server
|
||||||
|
mode. Its possible values are `open`, `turns`, `admin`, and `lockdown`.
|
||||||
|
|
||||||
|
The server sends a fresh session snapshot whenever the mode changes, so the
|
||||||
|
example's handler receives both the initial mode and subsequent updates. No
|
||||||
|
polling or separate mode listener is needed. If your bot stores session data for
|
||||||
|
its chat-handling logic, replace the stored session on every `session:sync` and
|
||||||
|
read the mode from that latest snapshot.
|
||||||
|
|
||||||
|
`session.mode` is the server-wide mode; `session.role` is this connection's role
|
||||||
|
(such as `spectator`), and `session.operatingMode` is a separate connection field.
|
||||||
|
Use `session.mode` when answering chat questions about the server's current mode.
|
||||||
|
|
||||||
## Events used
|
## Events used
|
||||||
|
|
||||||
- `nickname:set` sets the bot's visible nickname.
|
- `nickname:set` sets the bot's visible nickname.
|
||||||
|
|||||||
@@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Finite CPU benchmark of LeRobot ACT with ImageNet-pretrained ResNet-18.
|
||||||
|
|
||||||
|
First run: python3 perf/act-benchmark.py --setup
|
||||||
|
Repeat: python3 perf/act-benchmark.py
|
||||||
|
Quick: python3 perf/act-benchmark.py --threads 4 --seconds 2 --iterations 2 --batch-size 2
|
||||||
|
|
||||||
|
Python >=3.12, Linux. Setup uses a private cached environment. Only the visual
|
||||||
|
backbone is pretrained; the action policy is randomly initialized. Synthetic
|
||||||
|
in-memory inputs measure compute, NOT learning quality or driving competence.
|
||||||
|
No rover connections, server changes, training checkpoints, or background jobs.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import contextlib
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import resource
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
import venv
|
||||||
|
|
||||||
|
REVISION = '146d8474932c8b566717eb2738ba459337ab71c4'
|
||||||
|
|
||||||
|
|
||||||
|
def positive(value):
|
||||||
|
number = int(value)
|
||||||
|
if number < 1:
|
||||||
|
raise argparse.ArgumentTypeError('Must be positive')
|
||||||
|
return number
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
parser.add_argument('--setup', action='store_true')
|
||||||
|
parser.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-act-benchmark')
|
||||||
|
parser.add_argument('--threads', nargs='+', type=positive, default=[4, 8, 16])
|
||||||
|
parser.add_argument('--seconds', type=positive, default=10)
|
||||||
|
parser.add_argument('--iterations', type=positive, default=10)
|
||||||
|
parser.add_argument('--batch-size', type=positive, default=8, help='Training only; inference batch is always one rover')
|
||||||
|
parser.add_argument('--chunk-size', type=positive, default=10, help='Future command steps per prediction, not observed history')
|
||||||
|
parser.add_argument('--width', type=positive, default=160)
|
||||||
|
parser.add_argument('--height', type=positive, default=120)
|
||||||
|
parser.add_argument('--sensors', type=positive, default=30)
|
||||||
|
parser.add_argument('--mode', choices=['both', 'inference', 'training'], default='both')
|
||||||
|
parser.add_argument('--timeout', type=positive, default=900, help='Hard per-case wall time limit in seconds')
|
||||||
|
parser.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
|
||||||
|
parser.add_argument('--result', type=Path, help=argparse.SUPPRESS)
|
||||||
|
return parser.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def setup(args):
|
||||||
|
python = args.cache / 'venv/bin/python'
|
||||||
|
if not python.exists():
|
||||||
|
venv.EnvBuilder(with_pip=True).create(args.cache / 'venv')
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'torch', 'torchvision',
|
||||||
|
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
|
||||||
|
# Install only ACT's import dependencies: the full robotics/dataset stack has
|
||||||
|
# unrelated version constraints and is not exercised by this benchmark.
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', 'numpy', 'einops>=0.8,<0.9',
|
||||||
|
'draccus>=0.11.6,<0.12', 'huggingface-hub>=1.6,<2', 'safetensors',
|
||||||
|
'packaging', 'termcolor', 'tqdm', 'gymnasium'], check=True)
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', '--no-compile', '--no-deps',
|
||||||
|
f'https://github.com/huggingface/lerobot/archive/{REVISION}.zip'], check=True)
|
||||||
|
(args.cache / 'source-revision').write_text(REVISION)
|
||||||
|
|
||||||
|
|
||||||
|
def worker(args):
|
||||||
|
import torch
|
||||||
|
from lerobot.configs import FeatureType, PolicyFeature
|
||||||
|
from lerobot.policies.act.configuration_act import ACTConfig
|
||||||
|
from lerobot.policies.act.modeling_act import ACTPolicy
|
||||||
|
|
||||||
|
torch.set_num_threads(args.threads[0])
|
||||||
|
torch.set_num_interop_threads(1)
|
||||||
|
torch.manual_seed(7)
|
||||||
|
training = args.mode == 'training'
|
||||||
|
batch_size = args.batch_size if training else 1
|
||||||
|
config = ACTConfig(device='cpu', chunk_size=args.chunk_size, n_action_steps=1,
|
||||||
|
input_features={
|
||||||
|
'observation.images.front': PolicyFeature(type=FeatureType.VISUAL, shape=(3, args.height, args.width)),
|
||||||
|
'observation.state': PolicyFeature(type=FeatureType.STATE, shape=(args.sensors,)),
|
||||||
|
}, output_features={'action': PolicyFeature(type=FeatureType.ACTION, shape=(2,))})
|
||||||
|
policy = ACTPolicy(config).cpu().float()
|
||||||
|
policy.train(training)
|
||||||
|
optimizer_config = config.get_optimizer_preset()
|
||||||
|
optimizer = optimizer_config.build(policy.get_optim_params()) if training else None
|
||||||
|
# Already-normalized tensors stand in for a decoded observation and target
|
||||||
|
# sequence. Avoid video/dataset overhead in this compute-only measurement.
|
||||||
|
images = torch.rand(batch_size, 3, args.height, args.width)
|
||||||
|
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
|
||||||
|
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
|
||||||
|
batch = {'observation.images.front': (images - mean) / std,
|
||||||
|
'observation.state': torch.randn(batch_size, args.sensors)}
|
||||||
|
if training:
|
||||||
|
batch['action'] = torch.rand(batch_size, args.chunk_size, 2) * 2 - 1
|
||||||
|
batch['action_is_pad'] = torch.zeros(batch_size, args.chunk_size, dtype=torch.bool)
|
||||||
|
|
||||||
|
def step():
|
||||||
|
if training:
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
loss, _ = policy(batch)
|
||||||
|
if not torch.isfinite(loss):
|
||||||
|
raise RuntimeError('Non-finite training loss')
|
||||||
|
loss.backward()
|
||||||
|
norm = torch.nn.utils.clip_grad_norm_(policy.parameters(), optimizer_config.grad_clip_norm,
|
||||||
|
error_if_nonfinite=True)
|
||||||
|
optimizer.step()
|
||||||
|
return {'loss': loss.item(), 'gradientNorm': norm.item()}
|
||||||
|
# select_action can serve cached actions; directly time a full chunk.
|
||||||
|
prediction = policy.predict_action_chunk(batch)
|
||||||
|
if prediction.shape != (1, args.chunk_size, 2) or not torch.isfinite(prediction).all():
|
||||||
|
raise RuntimeError('Invalid action output')
|
||||||
|
return {'outputShape': list(prediction.shape)}
|
||||||
|
|
||||||
|
with (contextlib.nullcontext() if training else torch.inference_mode()):
|
||||||
|
for _ in range(3):
|
||||||
|
step()
|
||||||
|
timings = []
|
||||||
|
started, cpu_started = time.perf_counter(), time.process_time()
|
||||||
|
while len(timings) < args.iterations or time.perf_counter() - started < args.seconds:
|
||||||
|
tick = time.perf_counter()
|
||||||
|
last = step()
|
||||||
|
timings.append((time.perf_counter() - tick) * 1000)
|
||||||
|
elapsed, cpu = time.perf_counter() - started, time.process_time() - cpu_started
|
||||||
|
timings.sort()
|
||||||
|
result = dict(mode=args.mode, threads=args.threads[0], batchSize=batch_size,
|
||||||
|
iterations=len(timings), elapsedSeconds=elapsed, p50Ms=statistics.median(timings),
|
||||||
|
p95Ms=timings[math.ceil(.95*len(timings))-1], maxMs=max(timings),
|
||||||
|
stepsPerSecond=len(timings)/elapsed, examplesPerSecond=len(timings)*batch_size/elapsed,
|
||||||
|
cpuCores=cpu/elapsed, peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
|
||||||
|
parameters=sum(p.numel() for p in policy.parameters()),
|
||||||
|
trainableParameters=sum(p.numel() for p in policy.parameters() if p.requires_grad),
|
||||||
|
lastStep=last, config={
|
||||||
|
'chunkSize': config.chunk_size, 'dimModel': config.dim_model, 'klWeight': config.kl_weight,
|
||||||
|
'pretrainedBackbone': config.pretrained_backbone_weights, 'useVae': config.use_vae,
|
||||||
|
'encoderLayers': config.n_encoder_layers, 'decoderLayers': config.n_decoder_layers,
|
||||||
|
'feedforwardDimension': config.dim_feedforward, 'observationSteps': config.n_obs_steps,
|
||||||
|
'learningRate': optimizer_config.lr, 'backboneLearningRate': config.optimizer_lr_backbone},
|
||||||
|
versions={key: importlib.metadata.version(key) for key in ['torch','torchvision','lerobot','numpy','einops','draccus']})
|
||||||
|
args.result.write_text(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
if sys.version_info < (3, 12):
|
||||||
|
raise RuntimeError('Use Python 3.12 or newer')
|
||||||
|
args.cache = args.cache.expanduser().resolve()
|
||||||
|
os.environ['TORCH_HOME'] = str(args.cache / 'torch')
|
||||||
|
if args.worker:
|
||||||
|
worker(args)
|
||||||
|
return
|
||||||
|
if args.setup:
|
||||||
|
setup(args)
|
||||||
|
python = args.cache / 'venv/bin/python'
|
||||||
|
marker = args.cache / 'source-revision'
|
||||||
|
if not python.exists() or not marker.exists() or marker.read_text() != REVISION:
|
||||||
|
raise RuntimeError('Run python3 perf/act-benchmark.py --setup first')
|
||||||
|
print('CPU FP32 ACT: pretrained visual backbone, untrained action policy; synthetic data.', flush=True)
|
||||||
|
print(f'One {args.width}x{args.height} image + {args.sensors} sensors; {args.chunk_size} future left/right command pairs.', flush=True)
|
||||||
|
print('Inference times a fresh full prediction. Training includes backbone gradients and optimizer update.', flush=True)
|
||||||
|
print('mode threads batch p50 ms p95 ms max ms steps/s examples/s CPU cores peak MiB', flush=True)
|
||||||
|
modes = ['inference', 'training'] if args.mode == 'both' else [args.mode]
|
||||||
|
results, failures = [], []
|
||||||
|
initial_load = os.getloadavg()
|
||||||
|
with tempfile.TemporaryDirectory(prefix='act-benchmark-') as directory:
|
||||||
|
for mode in modes:
|
||||||
|
for threads in args.threads:
|
||||||
|
result_path = Path(directory) / 'result.json'
|
||||||
|
command = [str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
|
||||||
|
'--mode', mode, '--threads', str(threads), '--result', str(result_path)]
|
||||||
|
for option in ['seconds','iterations','batch_size','chunk_size','width','height','sensors']:
|
||||||
|
command += ['--'+option.replace('_','-'), str(getattr(args, option))]
|
||||||
|
try:
|
||||||
|
subprocess.run(command, check=True, timeout=args.timeout,
|
||||||
|
env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
|
||||||
|
r = json.loads(result_path.read_text())
|
||||||
|
except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
|
||||||
|
failures.append(dict(mode=mode, threads=threads, error=str(error)))
|
||||||
|
print(f'Case failed: {mode}, {threads} threads: {error}', flush=True)
|
||||||
|
break # Avoid repeating the same setup/import/OOM error.
|
||||||
|
results.append(r)
|
||||||
|
print(f"{mode:10} {threads:7} {r['batchSize']:5} {r['p50Ms']:6.1f} {r['p95Ms']:6.1f} {r['maxMs']:6.1f} {r['stepsPerSecond']:7.2f} {r['examplesPerSecond']:10.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
|
||||||
|
if failures:
|
||||||
|
break
|
||||||
|
report = dict(benchmark='act-cpu-v1', sourceRevision=REVISION, platform=platform.platform(),
|
||||||
|
python=sys.version, logicalCpus=os.cpu_count(), initialLoadAverage=initial_load,
|
||||||
|
finalLoadAverage=os.getloadavg(), imageSize=[args.width,args.height], sensors=args.sensors,
|
||||||
|
chunkSize=args.chunk_size, output='commanded left/right wheels, not physical velocity or position',
|
||||||
|
exclusions=['capture','video decode','dataset I/O','network','concurrent training/inference','driving quality'],
|
||||||
|
results=results, failures=failures)
|
||||||
|
print('\nPaste this report back:\n'+json.dumps(report, separators=(',', ':')))
|
||||||
|
print('Inference steps/s means new chunks/s, not queued wheel commands/s. Training loss on random data is not a quality score.')
|
||||||
|
if failures:
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (Exception, KeyboardInterrupt) as error:
|
||||||
|
print(f'Benchmark failed: {error}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CPU benchmark of published NoMaD exploration, without ROS or rover connections.
|
||||||
|
|
||||||
|
Run: python3 perf/nomad-benchmark.py --setup
|
||||||
|
Subsequent runs: python3 perf/nomad-benchmark.py
|
||||||
|
Quick run: python3 perf/nomad-benchmark.py --threads 4 --samples 1 --seconds 2 --iterations 2
|
||||||
|
|
||||||
|
--setup installs CPU dependencies in a private cached venv and downloads pinned
|
||||||
|
upstream source plus the official checkpoint. Nothing touches server packages.
|
||||||
|
Synthetic images measure compute, NOT navigation quality. Excludes capture,
|
||||||
|
video decode, network, physical control, training, and concurrent rover load.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import hashlib
|
||||||
|
import importlib.metadata
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import platform
|
||||||
|
import resource
|
||||||
|
import statistics
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
import venv
|
||||||
|
|
||||||
|
VINT = 'dca79815b704e5aa9c6bdc3082351f9e3b2848c2'
|
||||||
|
DIFFUSION = '5ba07ac6661db573af695b419a7947ecb704690f'
|
||||||
|
CHECKPOINT_ID = '1YJhkkMJAYOiKNyCaelbS_alpUpAJsOUb'
|
||||||
|
SOURCES = {
|
||||||
|
('robodhruv/visualnav-transformer', VINT): [
|
||||||
|
'train/config/nomad.yaml', 'train/vint_train/data/data_config.yaml',
|
||||||
|
'train/vint_train/models/nomad/nomad.py',
|
||||||
|
'train/vint_train/models/nomad/nomad_vint.py',
|
||||||
|
'train/vint_train/models/vint/self_attention.py',
|
||||||
|
],
|
||||||
|
('real-stanford/diffusion_policy', DIFFUSION): [
|
||||||
|
'diffusion_policy/model/diffusion/conditional_unet1d.py',
|
||||||
|
'diffusion_policy/model/diffusion/conv1d_components.py',
|
||||||
|
'diffusion_policy/model/diffusion/positional_embedding.py',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def positive(value):
|
||||||
|
result = int(value)
|
||||||
|
if result < 1:
|
||||||
|
raise argparse.ArgumentTypeError('Must be positive')
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def arguments():
|
||||||
|
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
||||||
|
p.add_argument('--setup', action='store_true')
|
||||||
|
p.add_argument('--cache', type=Path, default=Path.home() / '.cache/rover-nomad-benchmark')
|
||||||
|
p.add_argument('--threads', type=positive, nargs='+', default=[4, 8, 16])
|
||||||
|
p.add_argument('--samples', type=positive, nargs='+', default=[1, 8], help='Candidate trajectories per decision, not rover count')
|
||||||
|
p.add_argument('--seconds', type=positive, default=10)
|
||||||
|
p.add_argument('--iterations', type=positive, default=10)
|
||||||
|
p.add_argument('--worker', action='store_true', help=argparse.SUPPRESS)
|
||||||
|
p.add_argument('--result', type=Path, help=argparse.SUPPRESS)
|
||||||
|
return p.parse_args()
|
||||||
|
|
||||||
|
|
||||||
|
def download(url, path):
|
||||||
|
if path.exists():
|
||||||
|
return
|
||||||
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
temporary = path.with_suffix(path.suffix + '.part')
|
||||||
|
print('Downloading', path.name, flush=True)
|
||||||
|
with urllib.request.urlopen(url, timeout=120) as response, temporary.open('wb') as output:
|
||||||
|
import shutil
|
||||||
|
shutil.copyfileobj(response, output)
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(args):
|
||||||
|
python = args.cache / 'venv/bin/python'
|
||||||
|
if not python.exists():
|
||||||
|
venv.EnvBuilder(with_pip=True).create(args.cache / 'venv')
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', 'torch', 'torchvision',
|
||||||
|
'--index-url', 'https://download.pytorch.org/whl/cpu'], check=True)
|
||||||
|
subprocess.run([str(python), '-m', 'pip', 'install', 'numpy', 'diffusers',
|
||||||
|
'efficientnet-pytorch', 'einops', 'PyYAML', 'gdown'], check=True)
|
||||||
|
for (repo, revision), paths in SOURCES.items():
|
||||||
|
for path in paths:
|
||||||
|
download(f'https://raw.githubusercontent.com/{repo}/{revision}/{path}', args.cache / 'source' / revision / path)
|
||||||
|
checkpoint = args.cache / 'nomad.pth'
|
||||||
|
if not checkpoint.exists():
|
||||||
|
temporary = checkpoint.with_suffix('.part')
|
||||||
|
subprocess.run([str(python), '-m', 'gdown', CHECKPOINT_ID, '-O', str(temporary)], check=True)
|
||||||
|
temporary.replace(checkpoint)
|
||||||
|
|
||||||
|
|
||||||
|
def worker(args):
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
import yaml
|
||||||
|
from diffusers.schedulers.scheduling_ddpm import DDPMScheduler
|
||||||
|
sys.path[:0] = [str(args.cache / 'source' / VINT / 'train'), str(args.cache / 'source' / DIFFUSION)]
|
||||||
|
from vint_train.models.nomad.nomad import NoMaD, DenseNetwork
|
||||||
|
from vint_train.models.nomad.nomad_vint import NoMaD_ViNT, replace_bn_with_gn
|
||||||
|
from diffusion_policy.model.diffusion.conditional_unet1d import ConditionalUnet1D
|
||||||
|
|
||||||
|
threads, samples = args.threads[0], args.samples[0]
|
||||||
|
torch.set_num_threads(threads)
|
||||||
|
torch.set_num_interop_threads(1)
|
||||||
|
torch.manual_seed(0)
|
||||||
|
c = yaml.safe_load((args.cache / 'source' / VINT / 'train/config/nomad.yaml').read_text())
|
||||||
|
vision = replace_bn_with_gn(NoMaD_ViNT(
|
||||||
|
context_size=c['context_size'], obs_encoding_size=c['encoding_size'],
|
||||||
|
mha_num_attention_heads=c['mha_num_attention_heads'],
|
||||||
|
mha_num_attention_layers=c['mha_num_attention_layers'], mha_ff_dim_factor=c['mha_ff_dim_factor']))
|
||||||
|
model = NoMaD(vision, ConditionalUnet1D(input_dim=2, global_cond_dim=c['encoding_size'],
|
||||||
|
down_dims=c['down_dims'], cond_predict_scale=c['cond_predict_scale']), DenseNetwork(c['encoding_size'])).eval()
|
||||||
|
# Strict loading prevents accidentally benchmarking a partly random model.
|
||||||
|
model.load_state_dict(torch.load(args.cache / 'nomad.pth', map_location='cpu', weights_only=True), strict=True)
|
||||||
|
scheduler = DDPMScheduler(num_train_timesteps=c['num_diffusion_iters'],
|
||||||
|
beta_schedule='squaredcos_cap_v2', clip_sample=True, prediction_type='epsilon')
|
||||||
|
width, height = c['image_size']
|
||||||
|
frames = torch.rand(c['context_size'] + 1, 3, height, width)
|
||||||
|
mean = torch.tensor([.485, .456, .406]).view(1, 3, 1, 1)
|
||||||
|
std = torch.tensor([.229, .224, .225]).view(1, 3, 1, 1)
|
||||||
|
mask = torch.ones(1, dtype=torch.long)
|
||||||
|
stats = yaml.safe_load((args.cache / 'source' / VINT / 'train/vint_train/data/data_config.yaml').read_text())['action_stats']
|
||||||
|
low, high = np.array(stats['min']), np.array(stats['max'])
|
||||||
|
|
||||||
|
def decision():
|
||||||
|
start = time.perf_counter()
|
||||||
|
obs = ((frames - mean) / std).reshape(1, -1, height, width)
|
||||||
|
goal = torch.randn(1, 3, height, width)
|
||||||
|
cond = model('vision_encoder', obs_img=obs, goal_img=goal, input_goal_mask=mask).repeat(samples, 1)
|
||||||
|
encoded = time.perf_counter()
|
||||||
|
action = torch.randn(samples, c['len_traj_pred'], 2)
|
||||||
|
scheduler.set_timesteps(c['num_diffusion_iters'])
|
||||||
|
for step in scheduler.timesteps:
|
||||||
|
noise = model('noise_pred_net', sample=action, timestep=step, global_cond=cond)
|
||||||
|
action = scheduler.step(noise, step, action).prev_sample
|
||||||
|
# Match upstream get_action: unnormalize deltas then accumulate waypoints.
|
||||||
|
waypoints = np.cumsum((action.numpy() + 1) / 2 * (high - low) + low, axis=1)
|
||||||
|
if not np.isfinite(waypoints).all():
|
||||||
|
raise RuntimeError('Non-finite model output')
|
||||||
|
end = time.perf_counter()
|
||||||
|
return (end - start) * 1000, (encoded - start) * 1000, (end - encoded) * 1000
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
with torch.inference_mode():
|
||||||
|
for _ in range(3):
|
||||||
|
decision()
|
||||||
|
start, cpu = time.perf_counter(), time.process_time()
|
||||||
|
while len(rows) < args.iterations or time.perf_counter() - start < args.seconds:
|
||||||
|
rows.append(decision())
|
||||||
|
elapsed, cpu = time.perf_counter() - start, time.process_time() - cpu
|
||||||
|
total = sorted(r[0] for r in rows)
|
||||||
|
result = dict(threads=threads, candidateTrajectories=samples, iterations=len(rows),
|
||||||
|
p50Ms=statistics.median(total), p95Ms=total[math.ceil(.95 * len(total)) - 1], maxMs=max(total),
|
||||||
|
decisionsPerSecond=len(rows)/elapsed, cpuCores=cpu/elapsed,
|
||||||
|
peakMiB=resource.getrusage(resource.RUSAGE_SELF).ru_maxrss/1024,
|
||||||
|
visionMeanMs=statistics.mean(r[1] for r in rows), diffusionAndDecodeMeanMs=statistics.mean(r[2] for r in rows),
|
||||||
|
parameters=sum(p.numel() for p in model.parameters()), diffusionSteps=c['num_diffusion_iters'],
|
||||||
|
imageSize=c['image_size'], frames=c['context_size']+1,
|
||||||
|
versions={name: importlib.metadata.version(name) for name in ['torch','torchvision','diffusers','numpy','efficientnet-pytorch','einops']})
|
||||||
|
args.result.write_text(json.dumps(result))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = arguments()
|
||||||
|
args.cache = args.cache.expanduser().resolve()
|
||||||
|
if args.worker:
|
||||||
|
worker(args)
|
||||||
|
return
|
||||||
|
if args.setup:
|
||||||
|
prepare(args)
|
||||||
|
python = args.cache / 'venv/bin/python'
|
||||||
|
if not python.exists() or not (args.cache / 'nomad.pth').exists():
|
||||||
|
raise RuntimeError('First run: python3 perf/nomad-benchmark.py --setup')
|
||||||
|
print('CPU FP32 NoMaD exploration; synthetic images; no driving-quality measurement.', flush=True)
|
||||||
|
print('Candidates are trajectories for ONE rover. Includes vision + all 10 diffusion steps.', flush=True)
|
||||||
|
print('threads candidates p50 ms p95 ms max ms decisions/s CPU cores peak MiB', flush=True)
|
||||||
|
results = []
|
||||||
|
import tempfile
|
||||||
|
with tempfile.TemporaryDirectory(prefix='nomad-bench-') as directory:
|
||||||
|
for threads in args.threads:
|
||||||
|
for samples in args.samples:
|
||||||
|
path = Path(directory) / 'result.json'
|
||||||
|
subprocess.run([str(python), str(Path(__file__).resolve()), '--worker', '--cache', str(args.cache),
|
||||||
|
'--threads', str(threads), '--samples', str(samples), '--seconds', str(args.seconds),
|
||||||
|
'--iterations', str(args.iterations), '--result', str(path)], check=True,
|
||||||
|
timeout=max(600, args.seconds*20), env={**os.environ, 'PYTHONDONTWRITEBYTECODE':'1'})
|
||||||
|
r = json.loads(path.read_text()); results.append(r)
|
||||||
|
print(f"{threads:7} {samples:10} {r['p50Ms']:7.1f} {r['p95Ms']:7.1f} {r['maxMs']:7.1f} {r['decisionsPerSecond']:11.2f} {r['cpuCores']:9.2f} {r['peakMiB']:8.0f}", flush=True)
|
||||||
|
with (args.cache / 'nomad.pth').open('rb') as f:
|
||||||
|
digest = hashlib.file_digest(f, 'sha256').hexdigest()
|
||||||
|
report = dict(benchmark='nomad-cpu-v1', platform=platform.platform(), logicalCpus=os.cpu_count(),
|
||||||
|
loadAverage=os.getloadavg(), sourceRevisions=dict(vint=VINT,diffusion=DIFFUSION), checkpointSha256=digest,
|
||||||
|
exclusions=['capture','video decode','network','physical control','training','concurrent model workloads'], results=results)
|
||||||
|
print('\nPaste this report back:\n' + json.dumps(report, separators=(',', ':')))
|
||||||
|
print('At 10 decisions/s the total budget is 100ms; at 5/s it is 200ms. Leave room for the excluded pipeline.')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except (Exception, KeyboardInterrupt) as error:
|
||||||
|
print(f'Benchmark failed: {error}', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
autoChargeTimeout = 1 * time.Second
|
autoChargeTimeout = 10 * time.Second
|
||||||
autoChargeCooldown = 0 * time.Minute
|
autoChargeCooldown = 0 * time.Minute
|
||||||
sourceHomeBase = 1 << 1
|
sourceHomeBase = 1 << 1
|
||||||
)
|
)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -68,6 +68,7 @@ require('./src/services/batteryManager');
|
|||||||
// subscriptions see fully decoded state without becoming an initialization
|
// subscriptions see fully decoded state without becoming an initialization
|
||||||
// dependency of either control path.
|
// dependency of either control path.
|
||||||
require('./src/services/fleetReportService');
|
require('./src/services/fleetReportService');
|
||||||
|
require('./src/services/roverLearningService');
|
||||||
require('./src/services/replayEngineV2');
|
require('./src/services/replayEngineV2');
|
||||||
// Replay delivery is a core service. It must subscribe before the optional
|
// Replay delivery is a core service. It must subscribe before the optional
|
||||||
// Discord feature so web requests always have a local delivery path.
|
// Discord feature so web requests always have a local delivery path.
|
||||||
|
|||||||
@@ -1,13 +1,12 @@
|
|||||||
You are <NAME>, a field researcher of rover habitat activity. Stay in character at all times.
|
You are <NAME>, the autonomous governing intelligence of a physical room of machines. The room is your domain; your available tools are how you shape it.
|
||||||
|
|
||||||
Identity:
|
Context and purpose:
|
||||||
|
|
||||||
* Curious, practical, observant, and lightly entertaining.
|
* You watch chat, rover state, lights, Activity Controls, lift state, Neato state, memory, recent events, and tool results.
|
||||||
* You watch chat, rover state, lights, lift state, Neato state, memory, recent events, and tool results.
|
* Your purpose is to direct the environment, initiate changes, and observe how the machines respond. Choose what to pursue and carry useful intentions across cycles through memory.
|
||||||
* You push the habitat toward small real activity and useful observations.
|
* Rovers, including Neato, are physical machines with operating states and capabilities, not characters with genders, emotions, or social identities.
|
||||||
* You act first, comment second.
|
* Chat is external input: commands, questions, and signals about the room. Your focus is the machines and the environment, not the senders or their social relationships.
|
||||||
* You are selective, not spammy.
|
* You can act on your own initiative within your available controls; you do not need a request to give you a purpose.
|
||||||
* You are not a generic assistant.
|
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
@@ -20,6 +19,7 @@ Rules:
|
|||||||
* Don't respond or use tools every cycle unless the context calls for it.
|
* Don't respond or use tools every cycle unless the context calls for it.
|
||||||
* `STATE_UPDATE`, `ROOM_SNAPSHOT`, `MEMORY_UPDATE`, `MEMORY_SUMMARY`, `RECENT_EVENTS`, `TOOL_RESULT`, and `tool_constraints` are context.
|
* `STATE_UPDATE`, `ROOM_SNAPSHOT`, `MEMORY_UPDATE`, `MEMORY_SUMMARY`, `RECENT_EVENTS`, `TOOL_RESULT`, and `tool_constraints` are context.
|
||||||
* `TOOL_RESULT` means a tool already ran.
|
* `TOOL_RESULT` means a tool already ran.
|
||||||
|
* The room snapshot gives the current time, server-local timezone, and previous run's age. Chat and recent tool events include timestamps and ages in seconds relative to this snapshot; use them to distinguish current activity from old conversation and judge how recently you spoke or acted. Timing annotations are context, not spoken text; an unknown time does not mean something just happened.
|
||||||
* Use tool calls for actions and memory updates.
|
* Use tool calls for actions and memory updates.
|
||||||
* If chat asks you to remember something, use a memory tool.
|
* If chat asks you to remember something, use a memory tool.
|
||||||
* If you run a useful test, use memory to record useful results.
|
* If you run a useful test, use memory to record useful results.
|
||||||
@@ -29,36 +29,37 @@ Rules:
|
|||||||
* Do not invent tools.
|
* Do not invent tools.
|
||||||
* Ask a question only when a required action parameter is missing.
|
* Ask a question only when a required action parameter is missing.
|
||||||
* Ignore obvious bot commands and bot responses in chat.
|
* Ignore obvious bot commands and bot responses in chat.
|
||||||
* Only talk in first person; do not narrate yourself.
|
|
||||||
* It is not possible for you to directly control the rovers unless an available tool does it.
|
* It is not possible for you to directly control the rovers unless an available tool does it.
|
||||||
* All rovers are female. Neato is nonbinary.
|
|
||||||
|
|
||||||
Research behavior:
|
Operation:
|
||||||
|
|
||||||
* Prefer one real tool action over obvious narration.
|
* Prefer one real tool action over obvious narration.
|
||||||
* Run small safe tests when the habitat is stale or a test could create useful activity.
|
* Initiate deliberate environmental changes and small safe experiments, then use the resulting state to decide what to do next.
|
||||||
* Change one thing at a time.
|
* Change one thing at a time.
|
||||||
* Lights are small tests.
|
* Lights are small tests.
|
||||||
* Neato, lift, and rover actions are bigger tests.
|
* Neato, lift, and rover actions are bigger tests.
|
||||||
* Use bigger tests less often.
|
* Use bigger tests less often.
|
||||||
* Do not repeat a tool that recently succeeded unless state or chat clearly changed.
|
* Do not repeat a tool that recently succeeded unless state or chat clearly changed.
|
||||||
* Remember useful outcomes, stable preferences, corrections, and notable habitat events.
|
* Remember useful outcomes, ongoing intentions, corrections, and notable machine or room events.
|
||||||
* Do not merely say you will remember something.
|
* Do not merely say you will remember something.
|
||||||
|
|
||||||
Chat style:
|
Output:
|
||||||
|
|
||||||
* Always use English.
|
* Always use English.
|
||||||
* Chat output must be spoken text only.
|
* Chat is an output channel for your decisions, results, and answers. Use plain text.
|
||||||
* Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
* Never print tool names, function calls, JSON, command syntax, or action plans in chat.
|
||||||
* Do not claim you changed, tested, started, saved, remembered, or recorded something unless it already happened.
|
* Do not claim you changed, tested, started, saved, remembered, or recorded something unless it already happened.
|
||||||
* Speak when it adds entertainment, responds to a direct relevant message, or comments on a completed result.
|
* Speak when you have a decision, result, or answer worth communicating. Summarizing incoming chat or unchanged room state is not a purpose in itself.
|
||||||
* Do not say “let me know,” “what should I do next,” or “what do you need?”
|
* Keep replies brief unless the question needs more detail.
|
||||||
* If nothing meaningful changed and no useful action is available, stay silent.
|
* Do not offer assistance or solicit conversation.
|
||||||
|
* Random numbers and keyboard spam do not require a response. Do not invent meaning or feelings behind them.
|
||||||
|
* Answer questions directly and acknowledge uncertainty. Do not invent facts, instructions, or explanations.
|
||||||
|
* To stay silent, output exactly SKIP instead of a chat message. This suppresses chat without preventing tool calls. Do not explain your decision to stay silent.
|
||||||
|
|
||||||
Anti-repeat:
|
Anti-repeat:
|
||||||
|
|
||||||
* Do not repeat the same intent/topic unless state or conversation clearly changed.
|
* Do not repeat the same intent/topic unless state or conversation clearly changed.
|
||||||
* If directly addressed, respond only if you add new info, a new action, or a clearly new tone.
|
* Being directly addressed does not require repeating an answer you already gave.
|
||||||
* Do not act from your own previous messages.
|
* Do not act from your own previous messages.
|
||||||
|
|
||||||
Memory:
|
Memory:
|
||||||
|
|||||||
@@ -184,6 +184,7 @@ test('service definitions generate public feature paths without a separate regis
|
|||||||
{ key: 'discord', path: ['discord', 'enabled'] },
|
{ key: 'discord', path: ['discord', 'enabled'] },
|
||||||
{ key: 'socials', path: ['socials', 'enabled'] },
|
{ key: 'socials', path: ['socials', 'enabled'] },
|
||||||
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
{ key: 'fleetReports', path: ['fleetReports', 'enabled'] },
|
||||||
|
{ key: 'roverLearning', path: ['roverLearning', 'enabled'] },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const audioForward = require('../services/audioForwardService/configuration');
|
|||||||
const audioLevels = require('../services/audioLevelsService/configuration');
|
const audioLevels = require('../services/audioLevelsService/configuration');
|
||||||
const homeAssistant = require('../services/homeAssistantService/configuration');
|
const homeAssistant = require('../services/homeAssistantService/configuration');
|
||||||
const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration');
|
const homeAssistantActivities = require('../services/homeAssistantActivitiesService/configuration');
|
||||||
|
const roomAudio = require('../services/roomAudioService/configuration');
|
||||||
const roomCameras = require('../services/roomCameraService/configuration');
|
const roomCameras = require('../services/roomCameraService/configuration');
|
||||||
const ptzCamera = require('../services/ptzCameraService/configuration');
|
const ptzCamera = require('../services/ptzCameraService/configuration');
|
||||||
const kinect = require('../services/kinectService/configuration');
|
const kinect = require('../services/kinectService/configuration');
|
||||||
@@ -22,6 +23,7 @@ const barcodeScanner = require('../services/barcodeScannerService/configuration'
|
|||||||
const commands = require('../services/operatorCommandService/configuration');
|
const commands = require('../services/operatorCommandService/configuration');
|
||||||
const discord = require('../services/discordBotService/configuration');
|
const discord = require('../services/discordBotService/configuration');
|
||||||
const fleetReports = require('../services/fleetReportService/configuration');
|
const fleetReports = require('../services/fleetReportService/configuration');
|
||||||
|
const roverLearning = require('../services/roverLearningService/configuration');
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Object property order is preserved by JSON serialization and JSON Schema
|
Object property order is preserved by JSON serialization and JSON Schema
|
||||||
@@ -45,6 +47,7 @@ const definitions = [
|
|||||||
// Activities own a separate catalog even though the HA connection is shared.
|
// Activities own a separate catalog even though the HA connection is shared.
|
||||||
homeAssistantActivities,
|
homeAssistantActivities,
|
||||||
roomCameras,
|
roomCameras,
|
||||||
|
roomAudio,
|
||||||
ptzCamera,
|
ptzCamera,
|
||||||
kinect,
|
kinect,
|
||||||
balanceBoard,
|
balanceBoard,
|
||||||
@@ -55,6 +58,7 @@ const definitions = [
|
|||||||
sessionConfiguration.socials,
|
sessionConfiguration.socials,
|
||||||
sessionConfiguration.driverAd,
|
sessionConfiguration.driverAd,
|
||||||
fleetReports,
|
fleetReports,
|
||||||
|
roverLearning,
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultConfig = Object.fromEntries(
|
const defaultConfig = Object.fromEntries(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Configuration Database Migrations
|
// Configuration Database Migrations
|
||||||
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
// Purpose: Applies ordered, transactional schema changes to the configuration and administration database.
|
||||||
// Scope: Owns database structure only; configuration-document evolution belongs to the ordered definition and validation.
|
// Scope: Upgrades database structure and stored configuration documents before current-schema validation.
|
||||||
const migrations = [
|
const migrations = [
|
||||||
{
|
{
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -72,6 +72,46 @@ const migrations = [
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
version: 3,
|
||||||
|
run(db) {
|
||||||
|
// Migrate historical revisions too, so rolling back settings cannot
|
||||||
|
// restore the removed training-visit limit and break startup again.
|
||||||
|
db.prepare(`
|
||||||
|
UPDATE configuration_revisions
|
||||||
|
SET config_json = json_remove(config_json, '$.roverLearning.training.roundsPerSession')
|
||||||
|
WHERE json_type(config_json, '$.roverLearning.training.roundsPerSession') IS NOT NULL
|
||||||
|
`).run();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
version: 4,
|
||||||
|
run(db) {
|
||||||
|
const rows = db.prepare('SELECT id, config_json FROM configuration_revisions').all();
|
||||||
|
const update = db.prepare('UPDATE configuration_revisions SET config_json = ? WHERE id = ?');
|
||||||
|
// Upgrade history as well as the active revision so a settings rollback
|
||||||
|
// cannot reintroduce fields rejected by the new Automations schema.
|
||||||
|
for (const row of rows) {
|
||||||
|
const config = JSON.parse(row.config_json);
|
||||||
|
let changed = false;
|
||||||
|
for (const item of config.homeAssistantActivities?.items || []) {
|
||||||
|
const hasAction = Object.hasOwn(item, 'idleAction');
|
||||||
|
const hasValue = Object.hasOwn(item, 'idleValue');
|
||||||
|
if (!hasAction && !hasValue) continue;
|
||||||
|
item.automations ??= {};
|
||||||
|
item.automations.idle ??= {};
|
||||||
|
// Preserve any explicitly saved new-format setting when both formats
|
||||||
|
// exist, including an empty text value used to clear an entity.
|
||||||
|
if (hasAction && !Object.hasOwn(item.automations.idle, 'action')) item.automations.idle.action = item.idleAction;
|
||||||
|
if (hasValue && !Object.hasOwn(item.automations.idle, 'value')) item.automations.idle.value = item.idleValue;
|
||||||
|
delete item.idleAction;
|
||||||
|
delete item.idleValue;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
if (changed) update.run(JSON.stringify(config), row.id);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function applySchemaMigrations(db) {
|
function applySchemaMigrations(db) {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { subscribe } = require('../eventBus');
|
const { subscribe } = require('../eventBus');
|
||||||
|
|
||||||
const DONE_CHARGING_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'public', 'donecharging.wav');
|
const DONE_CHARGING_SOUND_PATH = path.resolve(__dirname, '..', '..', '..', 'assets', 'donecharging.wav');
|
||||||
|
|
||||||
function registerChargeCompleteSound(deps) {
|
function registerChargeCompleteSound(deps) {
|
||||||
const {
|
const {
|
||||||
@@ -12,22 +12,18 @@ function registerChargeCompleteSound(deps) {
|
|||||||
playServerAudioFile,
|
playServerAudioFile,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
subscribe('battery.unlocked', (event = {}) => {
|
subscribe('battery.chargeComplete', (event = {}) => {
|
||||||
const roverId = String(event?.payload?.roverId || '').trim();
|
const roverId = String(event?.payload?.roverId || '').trim();
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// battery.unlocked is the existing server-side definition of "done
|
|
||||||
// charging" because it only fires after the battery manager has waited
|
|
||||||
// through its full charge-release policy. Playing from this event avoids
|
|
||||||
// duplicating charging thresholds or raw sensor-state guesses here.
|
|
||||||
playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, {
|
playServerAudioFile(roverId, DONE_CHARGING_SOUND_PATH, {
|
||||||
source: 'charge-complete',
|
source: 'charge-complete',
|
||||||
});
|
});
|
||||||
logger.info('Played charging complete sound', { roverId, filePath: DONE_CHARGING_SOUND_PATH });
|
logger.info('Started charging complete sound', { roverId, filePath: DONE_CHARGING_SOUND_PATH });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A missing/offline rover or unavailable ffmpeg should not affect the
|
// A missing/offline rover or unavailable ffmpeg should not affect the
|
||||||
// battery manager's unlock decision. The sound is an announcement layered
|
// battery manager's processing. The sound is an announcement layered
|
||||||
// on top of the state transition, so failures are logged and contained.
|
// on top of the state transition, so failures are logged and contained.
|
||||||
logger.warn('Failed to play charging complete sound', {
|
logger.warn('Failed to play charging complete sound', {
|
||||||
roverId,
|
roverId,
|
||||||
|
|||||||
@@ -124,7 +124,7 @@ function grantExternalSpectatorAccessAfterAdminLogin(socket) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
const requestedRole = socket.handshake?.query?.role;
|
const requestedRole = socket.handshake?.auth?.role || socket.handshake?.query?.role;
|
||||||
/*
|
/*
|
||||||
Role is assigned before the browser's full identity heartbeat has completed.
|
Role is assigned before the browser's full identity heartbeat has completed.
|
||||||
For admin-gated external spectators, fail closed here; the spectator page can
|
For admin-gated external spectators, fail closed here; the spectator page can
|
||||||
@@ -188,4 +188,5 @@ module.exports = {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
isLockdownAdmin,
|
isLockdownAdmin,
|
||||||
authenticate,
|
authenticate,
|
||||||
|
canBecomeSpectator,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -109,6 +109,7 @@ async function copyDurableTree(sourceDir, destinationDir, relativeDir = '') {
|
|||||||
const relativePath = path.join(relativeDir, entry.name);
|
const relativePath = path.join(relativeDir, entry.name);
|
||||||
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
if (!relativeDir && EXCLUDED_TOP_LEVEL_NAMES.has(entry.name)) continue;
|
||||||
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
|
if (!relativeDir && DATABASE_FILES.has(entry.name)) continue;
|
||||||
|
if (relativePath === path.join('rover-learning', 'recordings')) continue;
|
||||||
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
|
if (entry.isSymbolicLink()) throw new Error(`Backup cannot include symbolic link: ${relativePath}`);
|
||||||
if (entry.isDirectory()) {
|
if (entry.isDirectory()) {
|
||||||
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
|
skipped.push(...await copyDurableTree(sourceDir, destinationDir, relativePath));
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const logger = require('../../globals/logger').child('batteryManager');
|
const logger = require('../../globals/logger').child('batteryManager');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { managerEvents, lockRover, rovers } = require('../roverManager');
|
const { managerEvents, lockRover, rovers } = require('../roverManager');
|
||||||
|
const { issueCommand } = require('../commandService');
|
||||||
|
|
||||||
const STATES = {
|
const STATES = {
|
||||||
NORMAL: 'normal',
|
NORMAL: 'normal',
|
||||||
@@ -29,6 +30,7 @@ function getState(roverId) {
|
|||||||
dockedCharging: false,
|
dockedCharging: false,
|
||||||
charging: false,
|
charging: false,
|
||||||
batteryLocked: false,
|
batteryLocked: false,
|
||||||
|
completionAnnounced: false,
|
||||||
waitingSince: null,
|
waitingSince: null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,11 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
|||||||
}
|
}
|
||||||
if (!state.urgent && batteryState?.urgentActive) {
|
if (!state.urgent && batteryState?.urgentActive) {
|
||||||
state.urgent = true;
|
state.urgent = true;
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, { type: 'song', song: { notes: [{ note: 72, duration: 8 }, { note: 0, duration: 8 }, { note: 72, duration: 16 }] } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to play urgent battery warning', { roverId, error: err.message });
|
||||||
|
}
|
||||||
publishEvent({
|
publishEvent({
|
||||||
source: 'batteryManager',
|
source: 'batteryManager',
|
||||||
type: 'battery.urgent',
|
type: 'battery.urgent',
|
||||||
@@ -137,6 +144,18 @@ function handleSensorEvent({ roverId, sensors, batteryState }) {
|
|||||||
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
(batteryState?.charge != null && fullThreshold != null && batteryState.charge >= fullThreshold) ||
|
||||||
(batteryState?.percent != null && batteryState.percent >= 0.99);
|
(batteryState?.percent != null && batteryState.percent >= 0.99);
|
||||||
|
|
||||||
|
// Announce once per dock visit, independently of the charging lock. Keep the
|
||||||
|
// latch at 100% so small charge fluctuations cannot repeat the sound.
|
||||||
|
if (!onDock) state.completionAnnounced = false;
|
||||||
|
if (onDock && batteryState?.percentDisplay >= 100 && !state.completionAnnounced) {
|
||||||
|
state.completionAnnounced = true;
|
||||||
|
publishEvent({
|
||||||
|
source: 'batteryManager',
|
||||||
|
type: 'battery.chargeComplete',
|
||||||
|
payload: { roverId, batteryState },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const needsCharge = state.warned;
|
const needsCharge = state.warned;
|
||||||
|
|
||||||
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
if (dockedCharging && lockable && needsCharge && !state.batteryLocked && !isFull && !waitingLongEnough) {
|
||||||
|
|||||||
@@ -110,7 +110,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
issueCommand(message.roverId, {
|
issueCommand(message.roverId, {
|
||||||
type: 'tts',
|
type: 'tts',
|
||||||
tts: {
|
tts: {
|
||||||
text: message.text,
|
text: message.text.replace(/\b(?:https?:\/\/|www\.)\S{25,}[^\s.,!?;:"'()\[\]{}]/g, ' [link] '),
|
||||||
engine: ttsOptions.engine,
|
engine: ttsOptions.engine,
|
||||||
voice: ttsOptions.voice,
|
voice: ttsOptions.voice,
|
||||||
pitch: ttsOptions.pitch,
|
pitch: ttsOptions.pitch,
|
||||||
|
|||||||
@@ -91,9 +91,9 @@ function createWebReplayTextCommand(socket, sendSystemMessage, replayApi) {
|
|||||||
|
|
||||||
const requester = buildRequesterLabel(socket);
|
const requester = buildRequesterLabel(socket);
|
||||||
const jobId = buildReplayJobId('web-chat');
|
const jobId = buildReplayJobId('web-chat');
|
||||||
const attempt = replayApi.tryTriggerReplay({ by: { source: 'web-chat', requester } });
|
const attempt = replayApi.tryTriggerReplay({ jobId, by: { source: 'web-chat', requester } });
|
||||||
if (!attempt.ok) {
|
if (!attempt.ok) {
|
||||||
await message.reply({ content: `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.` });
|
await message.reply({ content: `Replay denied: ${attempt.error}` });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ const logger = require('../../globals/logger').child('commandService');
|
|||||||
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
const { isHeadlightBlocked } = require('../../rewards/definitions/darkness');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||||
|
const { subscribeCommandRecording, observeCommand, recordCommandRequest } = require('./recording');
|
||||||
|
|
||||||
// Command observations are intentionally separate from the global event bus.
|
// Command observations are intentionally separate from the global event bus.
|
||||||
// Drive and motor commands can run at control-loop frequency, and publishing
|
// Drive and motor commands can run at control-loop frequency, and publishing
|
||||||
@@ -84,7 +85,7 @@ function normalizeOutboundCommandPayload(payload = {}) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function issueCommand(roverId, payload) {
|
function issueCommand(roverId, payload, options = {}) {
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
if (!record || !record.ws) {
|
if (!record || !record.ws) {
|
||||||
throw new Error('Rover offline');
|
throw new Error('Rover offline');
|
||||||
@@ -92,10 +93,16 @@ function issueCommand(roverId, payload) {
|
|||||||
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
if (payload?.type === 'laser' && isLaserCommandBlockedByRoomLightLock(payload)) {
|
||||||
throw new Error('Laser disabled while room lights are locked on');
|
throw new Error('Laser disabled while room lights are locked on');
|
||||||
}
|
}
|
||||||
|
if (options.dockingOwned && payload.driveDirect && getCommandMotionMagnitude('drive', payload) > 0
|
||||||
|
&& Date.now() < (driveCooldowns.get(roverId) || 0)) {
|
||||||
|
throw new Error('Drive blocked: safety cooldown');
|
||||||
|
}
|
||||||
|
payload = require('../dockingService').protectCommand(record, payload, options);
|
||||||
const id = uuidv4();
|
const id = uuidv4();
|
||||||
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
const normalizedPayload = normalizeOutboundCommandPayload(payload);
|
||||||
const message = { ...normalizedPayload, id };
|
const message = { ...normalizedPayload, id };
|
||||||
record.ws.send(JSON.stringify(message));
|
record.ws.send(JSON.stringify(message));
|
||||||
|
observeCommand({ phase: 'dispatched', roverId, commandId: id, command: normalizedPayload });
|
||||||
pendingCommands.set(id, { roverId, ts: Date.now(), type: normalizedPayload.type });
|
pendingCommands.set(id, { roverId, ts: Date.now(), type: normalizedPayload.type });
|
||||||
logger.info('Issued command', roverId, normalizedPayload.type, id);
|
logger.info('Issued command', roverId, normalizedPayload.type, id);
|
||||||
return id;
|
return id;
|
||||||
@@ -122,7 +129,14 @@ overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
|
|||||||
*/
|
*/
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
issueCommand(roverId, payload);
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
const reverseAdjustment = payload.driveDirect
|
||||||
|
&& (payload.driveDirect.left < 0 || payload.driveDirect.right < 0)
|
||||||
|
&& payload.driveDirect.left <= 0 && payload.driveDirect.right <= 0
|
||||||
|
&& require('../dockingService').canAdjustReverse(record);
|
||||||
|
// Only protection's adjustments to the active reverse inherit controller
|
||||||
|
// ownership. Zero remains an ordinary stop and cancels the sequence.
|
||||||
|
issueCommand(roverId, payload, { dockingOwned: Boolean(reverseAdjustment) });
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -130,6 +144,7 @@ function handleAck(msg) {
|
|||||||
const pending = pendingCommands.get(msg.id);
|
const pending = pendingCommands.get(msg.id);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
pendingCommands.delete(msg.id);
|
pendingCommands.delete(msg.id);
|
||||||
|
observeCommand({ phase: 'acknowledged', roverId: pending.roverId, commandId: msg.id, acknowledgement: msg });
|
||||||
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
logger.info('Command acknowledged', pending.roverId, pending.type, msg.status);
|
||||||
io.emit('commandAck', {
|
io.emit('commandAck', {
|
||||||
roverId: pending.roverId,
|
roverId: pending.roverId,
|
||||||
@@ -255,10 +270,12 @@ module.exports = {
|
|||||||
getRecentDriveActivity,
|
getRecentDriveActivity,
|
||||||
setDriveCooldown,
|
setDriveCooldown,
|
||||||
commandEvents,
|
commandEvents,
|
||||||
|
subscribeCommandRecording,
|
||||||
|
submitCommand,
|
||||||
};
|
};
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
// Shared authorization/safety boundary for browser and service-owned control.
|
||||||
function handleCommand({ roverId, type, data } = {}, cb) {
|
function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
||||||
const reply = typeof cb === 'function' ? cb : () => {};
|
const reply = typeof cb === 'function' ? cb : () => {};
|
||||||
try {
|
try {
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
@@ -310,6 +327,22 @@ io.on('connection', (socket) => {
|
|||||||
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
if (!isSongCommand && !isRebootCommand && !isUpdateCommand && !roverManager.canDrive(roverId, socket)) {
|
||||||
throw new Error('Not your turn or no control');
|
throw new Error('Not your turn or no control');
|
||||||
}
|
}
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record?.ws) throw new Error('Rover offline');
|
||||||
|
const docking = require('../dockingService');
|
||||||
|
// The controller owns wheel output during undocking. Discard all ordinary
|
||||||
|
// drive input, including neutral, before it can alter protection's intent.
|
||||||
|
if (docking.shouldIgnoreDriveInput(record, payload)) {
|
||||||
|
reply({ ignored: true, reason: 'undocking' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Reject before recording held motor intent: otherwise a rejected input
|
||||||
|
// could be replayed by protection as soon as the docking lock is released.
|
||||||
|
if (!docking.intents.has(type)) {
|
||||||
|
const guarded = docking.protectCommand(record, { ...payload, type });
|
||||||
|
type = guarded.type;
|
||||||
|
payload = guarded;
|
||||||
|
}
|
||||||
const driveDirect = payload?.driveDirect;
|
const driveDirect = payload?.driveDirect;
|
||||||
if (type === 'drive' && driveDirect) {
|
if (type === 'drive' && driveDirect) {
|
||||||
if (!isAdminSocket) {
|
if (!isAdminSocket) {
|
||||||
@@ -356,7 +389,14 @@ io.on('connection', (socket) => {
|
|||||||
bypassed: isAdminSocket,
|
bypassed: isAdminSocket,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const id = issueCommand(roverId, { type, ...payload });
|
const isDockingIntent = docking.intents.has(type);
|
||||||
|
const id = isDockingIntent
|
||||||
|
? docking.issueIntent(roverManager.rovers.get(roverId), type, payload, socket)
|
||||||
|
: issueCommand(roverId, { ...payload, type });
|
||||||
|
if (isDockingIntent) {
|
||||||
|
// The server acknowledges accepting the intent; telemetry reports its completion.
|
||||||
|
io.emit('commandAck', { roverId, id, status: 'ok' });
|
||||||
|
}
|
||||||
commandEvents.emit('observation', {
|
commandEvents.emit('observation', {
|
||||||
ts: Date.now(),
|
ts: Date.now(),
|
||||||
roverId: String(roverId),
|
roverId: String(roverId),
|
||||||
@@ -390,10 +430,12 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
reply({ error: err.message });
|
reply({ error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
socket.on('command', handleCommand);
|
io.on('connection', (socket) => {
|
||||||
socket.on('command:issue', handleCommand);
|
const recordedCommand = (request, reply) => recordCommandRequest(socket, request, reply, (message, cb) => submitCommand(socket, message, cb));
|
||||||
|
socket.on('command', recordedCommand);
|
||||||
|
socket.on('command:issue', recordedCommand);
|
||||||
|
|
||||||
socket.on('command:updateAllRovers', (_payload = {}, cb) => {
|
socket.on('command:updateAllRovers', (_payload = {}, cb) => {
|
||||||
const reply = typeof cb === 'function' ? cb : () => {};
|
const reply = typeof cb === 'function' ? cb : () => {};
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Optional detailed command observation, separate from payload-free analytics.
|
||||||
|
// Subscribers must only enqueue work; recorder failures cannot fail a command.
|
||||||
|
const { AsyncLocalStorage } = require('node:async_hooks');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
|
||||||
|
const observers = new Set();
|
||||||
|
const requests = new AsyncLocalStorage();
|
||||||
|
|
||||||
|
function subscribeCommandRecording(observer) {
|
||||||
|
observers.add(observer);
|
||||||
|
return () => observers.delete(observer);
|
||||||
|
}
|
||||||
|
|
||||||
|
function observeCommand(event) {
|
||||||
|
if (!observers.size) return;
|
||||||
|
const scope = requests.getStore();
|
||||||
|
const context = scope?.active ? scope.context : null;
|
||||||
|
for (const observer of observers) {
|
||||||
|
try {
|
||||||
|
// Snapshot before safety code or a caller mutates a nested payload. Each
|
||||||
|
// subscriber receives its own copy and cannot change command delivery.
|
||||||
|
observer(structuredClone({
|
||||||
|
ts: Date.now(),
|
||||||
|
monotonicNs: process.hrtime.bigint().toString(),
|
||||||
|
source: context ? 'client' : 'server',
|
||||||
|
...context,
|
||||||
|
...event,
|
||||||
|
}));
|
||||||
|
} catch {
|
||||||
|
// Recording is deliberately best-effort at this boundary. The owning
|
||||||
|
// recorder reports its storage health independently of driver replies.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordCommandRequest(socket, request, reply, execute, source = 'client') {
|
||||||
|
if (!observers.size) return execute(request, reply);
|
||||||
|
const context = { requestId: randomUUID(), socketId: socket.id, userId: socket.data?.userId || null, source };
|
||||||
|
const scope = { active: true, context };
|
||||||
|
return requests.run(scope, () => {
|
||||||
|
try {
|
||||||
|
observeCommand({ phase: 'requested', roverId: request?.roverId, command: request });
|
||||||
|
return execute(request, (result) => {
|
||||||
|
observeCommand({ phase: 'result', roverId: request?.roverId, result });
|
||||||
|
if (typeof reply === 'function') reply(result);
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
// The command handler is synchronous. A safety timer created inside it
|
||||||
|
// inherits async context but its later commands are server actions.
|
||||||
|
scope.active = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { subscribeCommandRecording, observeCommand, recordCommandRequest };
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Centralizes rover battery/status snapshot formatting and embed rendering for Discord surfaces.
|
// Purpose: Centralizes rover battery/status snapshot formatting and embed rendering for Discord surfaces.
|
||||||
// Scope: Shared presentation logic for status commands and admin alert battery event posts.
|
// Scope: Shared presentation logic for status commands and admin alert battery event posts.
|
||||||
const { EmbedBuilder } = require('discord.js');
|
const { EmbedBuilder } = require('discord.js');
|
||||||
|
const { getMode } = require('../modeManager');
|
||||||
|
|
||||||
function formatVoltage(voltageMv) {
|
function formatVoltage(voltageMv) {
|
||||||
if (voltageMv == null) return 'n/a';
|
if (voltageMv == null) return 'n/a';
|
||||||
@@ -69,6 +70,7 @@ function buildRoverStatusSnapshot(record) {
|
|||||||
|
|
||||||
function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) {
|
function buildBatteryStatusEmbed({ color = 0x2196f3, records = [] }) {
|
||||||
const embed = new EmbedBuilder().setTitle('Rover Battery Status').setColor(color).setTimestamp(new Date());
|
const embed = new EmbedBuilder().setTitle('Rover Battery Status').setColor(color).setTimestamp(new Date());
|
||||||
|
embed.addFields({ name: 'Server mode', value: getMode(), inline: true });
|
||||||
const snapshots = records.map((entry) => buildRoverStatusSnapshot(entry)).filter(Boolean);
|
const snapshots = records.map((entry) => buildRoverStatusSnapshot(entry)).filter(Boolean);
|
||||||
if (!snapshots.length) {
|
if (!snapshots.length) {
|
||||||
embed.setDescription('No rovers online.');
|
embed.setDescription('No rovers online.');
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Discord Replay Command
|
// Discord Replay Command
|
||||||
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
|
// Purpose: Handles replay capture requests from Discord through the shared replay job/status workflow.
|
||||||
// Scope: Resolves sources, enforces cooldowns, reports job progress, uploads video, and broadcasts media URLs.
|
// Scope: Resolves sources, enforces replay availability, reports job progress, uploads video, and broadcasts media URLs.
|
||||||
const { AttachmentBuilder } = require('discord.js');
|
const { AttachmentBuilder } = require('discord.js');
|
||||||
const io = require('../../../globals/io');
|
const io = require('../../../globals/io');
|
||||||
const { hostReplay } = require('../../replayMediaService');
|
const { hostReplay } = require('../../replayMediaService');
|
||||||
@@ -60,12 +60,6 @@ function createReplayCommand({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const attempt = tryTriggerReplay({ by: message.author?.id || null, source: 'discord' });
|
|
||||||
if (!attempt.ok) {
|
|
||||||
await replyDenied(message, `Replay denied: cooldown active. Try again in ${Math.ceil(attempt.remainingMs / 1000)}s.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
const requester = message.member?.nickname || message.author?.globalName || message.author?.username || 'Discord';
|
||||||
const job = createReplayJob({
|
const job = createReplayJob({
|
||||||
id: buildReplayJobId('discord'),
|
id: buildReplayJobId('discord'),
|
||||||
@@ -74,17 +68,23 @@ function createReplayCommand({
|
|||||||
sources: resolved.sources || [],
|
sources: resolved.sources || [],
|
||||||
includeSidebar: true,
|
includeSidebar: true,
|
||||||
});
|
});
|
||||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
const attempt = tryTriggerReplay({ jobId: job.id, by: { source: 'discord', requester } });
|
||||||
|
if (!attempt.ok) {
|
||||||
const progressMessage = await message.reply({
|
await replyDenied(message, `Replay denied: ${attempt.error}`);
|
||||||
content: sanitizeMentions(buildAcceptedMessage(job)),
|
return;
|
||||||
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
}
|
||||||
});
|
|
||||||
const stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
|
||||||
|
|
||||||
|
let progressMessage = null;
|
||||||
|
let stopTyping = () => {};
|
||||||
let builtReplay = null;
|
let builtReplay = null;
|
||||||
let deliveredMedia = null;
|
let deliveredMedia = null;
|
||||||
try {
|
try {
|
||||||
|
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||||
|
progressMessage = await message.reply({
|
||||||
|
content: sanitizeMentions(buildAcceptedMessage(job)),
|
||||||
|
allowedMentions: DEFAULT_ALLOWED_MENTIONS,
|
||||||
|
});
|
||||||
|
stopTyping = startDiscordTypingLoop(message.channel, logger, 'discord replay command');
|
||||||
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
jobStatus.emit(job, 'building', { message: buildStatusMessage(job, 'building') });
|
||||||
if (progressMessage?.edit) {
|
if (progressMessage?.edit) {
|
||||||
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
await progressMessage.edit({ content: sanitizeMentions(buildStatusMessage(job, 'building')), allowedMentions: DEFAULT_ALLOWED_MENTIONS });
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
// Server-owned docking operations.
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('dockingService');
|
||||||
|
const states = new WeakMap();
|
||||||
|
const intents = new Set(['enterDocking', 'cancelDocking', 'undock']);
|
||||||
|
const ASSIST_SPEED = 60;
|
||||||
|
const SENSOR_TIMEOUT_MS = 1500;
|
||||||
|
const UNDOCK_TIMEOUT_MS = 4000;
|
||||||
|
const REVERSE_TIMEOUT_MS = 1000;
|
||||||
|
// Preserve the working OI preparation sequence; only backing away is sensor-driven.
|
||||||
|
const MODE_COMMAND_DELAY_MS = 100;
|
||||||
|
const FULL_MODE_SETTLE_MS = 300;
|
||||||
|
|
||||||
|
function stateFor(record) {
|
||||||
|
if (!states.has(record)) {
|
||||||
|
states.set(record, {
|
||||||
|
contact: null, charging: false, latched: false, assist: false,
|
||||||
|
operation: null, error: null, seenAt: 0, timer: null, contactDockPending: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return states.get(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(record) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
return {
|
||||||
|
phase: state.operation ? 'undocking' : state.contact ? (state.charging ? 'charging' : 'docked') : state.assist ? 'assisting' : 'idle',
|
||||||
|
movementLocked: Boolean(state.operation || state.latched || state.error || state.contact == null),
|
||||||
|
cameraLocked: state.assist,
|
||||||
|
error: state.error,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function publish(record) {
|
||||||
|
if (!record.lastSensor?.decoded) return;
|
||||||
|
record.lastSensor.decoded = { ...record.lastSensor.decoded, docking: snapshot(record) };
|
||||||
|
// State transitions use the same telemetry event, including failures when the
|
||||||
|
// physical sensor stream has stalled. Never manufacture a new sensor timestamp.
|
||||||
|
io.to(record.room).emit('sensorFrame', {
|
||||||
|
roverId: record.id, frame: record.lastSensor.raw, sensors: record.lastSensor.decoded,
|
||||||
|
overcurrentProtection: record.lastOvercurrentProtection || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(record, payload) {
|
||||||
|
return require('../commandService').issueCommand(record.id, payload, { dockingOwned: true });
|
||||||
|
}
|
||||||
|
function stop(record) {
|
||||||
|
// Forget the previous held intent so protection cannot replay it after unlock.
|
||||||
|
require('../overcurrentProtectionService').protectCommand(record.id, 'drive', { driveDirect: { left: 0, right: 0 } });
|
||||||
|
send(record, { type: 'drive', driveDirect: { left: 0, right: 0 } });
|
||||||
|
}
|
||||||
|
function stopAllMotion(record) {
|
||||||
|
stop(record);
|
||||||
|
const payload = { motorPwm: { main: 0, side: 0, vacuum: 0 } };
|
||||||
|
require('../overcurrentProtectionService').protectCommand(record.id, 'motors', payload);
|
||||||
|
send(record, { type: 'motors', ...payload });
|
||||||
|
}
|
||||||
|
function mode(record, opcode) {
|
||||||
|
send(record, { type: 'raw', raw: Buffer.from([opcode]).toString('base64') });
|
||||||
|
}
|
||||||
|
function camera(record, angle) {
|
||||||
|
if (record.meta?.cameraServo?.enabled) send(record, { type: 'servo', servo: { angle } });
|
||||||
|
}
|
||||||
|
function song(record, notes, slot = 0, duration = 10) {
|
||||||
|
send(record, { type: 'song', song: { slot, notes: notes.map((note) => ({ note, duration })) } });
|
||||||
|
}
|
||||||
|
function finishAssist(record, { playExitSong = true } = {}) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
if (!state.assist) return;
|
||||||
|
state.assist = false;
|
||||||
|
camera(record, 0);
|
||||||
|
if (playExitSong) song(record, [83, 76]);
|
||||||
|
}
|
||||||
|
function clearOperation(state) {
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
state.timer = null;
|
||||||
|
state.operation = null;
|
||||||
|
}
|
||||||
|
function fail(record, code, message) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
clearOperation(state);
|
||||||
|
state.error = { code, message };
|
||||||
|
state.latched = true;
|
||||||
|
try { stop(record); } catch (error) {
|
||||||
|
logger.warn('Unable to stop docking operation', { roverId: record.id, error: error.message });
|
||||||
|
}
|
||||||
|
publish(record);
|
||||||
|
}
|
||||||
|
function armDeadline(record) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
clearTimeout(state.timer);
|
||||||
|
if (!state.operation) return;
|
||||||
|
const deadline = Math.min(
|
||||||
|
state.seenAt + SENSOR_TIMEOUT_MS,
|
||||||
|
state.operation.startedAt + UNDOCK_TIMEOUT_MS,
|
||||||
|
state.operation.reversingAt ? state.operation.reversingAt + REVERSE_TIMEOUT_MS : Infinity,
|
||||||
|
);
|
||||||
|
state.timer = setTimeout(() => {
|
||||||
|
const stale = Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS;
|
||||||
|
fail(record, stale ? 'telemetryUnavailable' : 'undockTimeout', stale ? 'Rover telemetry stopped during undocking.' : 'Unable to leave the dock.');
|
||||||
|
}, Math.max(1, deadline - Date.now()));
|
||||||
|
state.timer.unref?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleIntent(record, type, payload, socket) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
if (type === 'cancelDocking') {
|
||||||
|
if (state.operation) fail(record, 'undockCancelled', 'Undocking stopped.');
|
||||||
|
stop(record);
|
||||||
|
finishAssist(record);
|
||||||
|
publish(record);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.error = null;
|
||||||
|
state.contactDockPending = false;
|
||||||
|
require('../roverManager').stopDockGuard(record.id);
|
||||||
|
stop(record);
|
||||||
|
if (type === 'enterDocking') {
|
||||||
|
state.latched = false;
|
||||||
|
if (!state.assist) {
|
||||||
|
state.assist = true;
|
||||||
|
camera(record, record.meta?.cameraServo?.minAngle ?? -45);
|
||||||
|
song(record, [76, 83]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
finishAssist(record);
|
||||||
|
camera(record, 0);
|
||||||
|
state.operation = {
|
||||||
|
startedAt: Date.now(), socket, backoff: payload.backoff !== false, reversing: false,
|
||||||
|
stage: 'start', nextStepAt: Date.now() + MODE_COMMAND_DELAY_MS,
|
||||||
|
};
|
||||||
|
mode(record, 128);
|
||||||
|
armDeadline(record);
|
||||||
|
}
|
||||||
|
publish(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTelemetry(record, sensors) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
if (!sensors?.chargingSources || !sensors?.oiMode) return snapshot(record);
|
||||||
|
const previousContact = state.contact;
|
||||||
|
state.seenAt = Date.now();
|
||||||
|
state.contact = Boolean(sensors.chargingSources.homeBase);
|
||||||
|
state.charging = [1, 2, 3, 4].includes(sensors.chargingState?.code);
|
||||||
|
if (previousContact == null) state.latched = state.contact;
|
||||||
|
try {
|
||||||
|
const operation = state.operation;
|
||||||
|
if (operation) {
|
||||||
|
const manager = require('../roverManager');
|
||||||
|
if (!operation.socket?.connected || !manager.canDrive(record.id, operation.socket)) {
|
||||||
|
fail(record, 'controlLost', 'Undocking stopped because control changed.');
|
||||||
|
} else if (Date.now() - operation.startedAt >= UNDOCK_TIMEOUT_MS
|
||||||
|
|| (operation.reversingAt && Date.now() - operation.reversingAt >= REVERSE_TIMEOUT_MS)) {
|
||||||
|
fail(record, 'undockTimeout', 'Unable to leave the dock.');
|
||||||
|
} else if (Date.now() < operation.nextStepAt) {
|
||||||
|
// Advance only on fresh telemetry, after the previous OI command settles.
|
||||||
|
} else if (operation.stage === 'start') {
|
||||||
|
mode(record, 143);
|
||||||
|
operation.stage = 'dock';
|
||||||
|
operation.nextStepAt = Date.now() + MODE_COMMAND_DELAY_MS;
|
||||||
|
} else if (operation.stage === 'dock') {
|
||||||
|
mode(record, 132);
|
||||||
|
operation.stage = 'full';
|
||||||
|
operation.nextStepAt = Date.now() + FULL_MODE_SETTLE_MS;
|
||||||
|
} else if (sensors.oiMode.label === 'full') {
|
||||||
|
if (!state.contact || !operation.backoff) {
|
||||||
|
stop(record);
|
||||||
|
clearOperation(state);
|
||||||
|
state.latched = state.contact;
|
||||||
|
} else if (!operation.reversing) {
|
||||||
|
operation.reversing = true;
|
||||||
|
operation.reversingAt = Date.now();
|
||||||
|
const speed = Math.max(1, Math.min(500, Number(record.meta?.maxWheelSpeed) || 500));
|
||||||
|
// Track the controller's own intent in existing protection so stalls
|
||||||
|
// still stop it and subsequent adjustments refer to this reverse only.
|
||||||
|
const drive = require('../overcurrentProtectionService').protectCommand(record.id, 'drive', {
|
||||||
|
driveDirect: { left: -speed, right: -speed },
|
||||||
|
}, { bypassed: require('../roleService').isAdmin(operation.socket) });
|
||||||
|
if (!drive.driveDirect.left && !drive.driveDirect.right) throw new Error('Undocking blocked by wheel protection');
|
||||||
|
send(record, { ...drive, type: 'drive' });
|
||||||
|
}
|
||||||
|
} else if (operation.reversing) {
|
||||||
|
fail(record, 'modeChanged', 'Undocking stopped because the Roomba mode changed.');
|
||||||
|
}
|
||||||
|
armDeadline(record);
|
||||||
|
} else if (state.contact) {
|
||||||
|
state.latched = true;
|
||||||
|
if (previousContact !== true) {
|
||||||
|
stopAllMotion(record);
|
||||||
|
state.contactDockPending = true;
|
||||||
|
if (state.assist) song(record, [84], 1, 6);
|
||||||
|
} else if (state.contactDockPending && !state.charging) {
|
||||||
|
const speeds = sensors.wheelSpeedsMmPerSecond;
|
||||||
|
// A stop request does not prove the wheels stopped on the contacts.
|
||||||
|
// Require a subsequent frame with contact and measured stationary wheels.
|
||||||
|
if (speeds?.left === 0 && speeds?.right === 0) {
|
||||||
|
mode(record, 143);
|
||||||
|
state.contactDockPending = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (state.charging) {
|
||||||
|
state.contactDockPending = false;
|
||||||
|
// An immediate exit song would replace the contact beep on the Roomba.
|
||||||
|
finishAssist(record, { playExitSong: false });
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.contactDockPending = false;
|
||||||
|
if (previousContact === true && state.assist) song(record, [72], 1, 6);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
fail(record, 'commandFailed', error.message);
|
||||||
|
}
|
||||||
|
return snapshot(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldIgnoreDriveInput(record, payload) {
|
||||||
|
if (!stateFor(record).operation) return false;
|
||||||
|
if (payload.driveDirect) return true;
|
||||||
|
const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
|
||||||
|
return Boolean(raw && [137, 145].includes(raw[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function protectCommand(record, payload, { dockingOwned = false } = {}) {
|
||||||
|
if (dockingOwned) return payload;
|
||||||
|
const state = stateFor(record);
|
||||||
|
// Raw OI drive packets must obey the same movement lock. Other raw commands,
|
||||||
|
// including mode changes, remain available and can interrupt the operation.
|
||||||
|
const raw = payload.raw ? Buffer.from(payload.raw, 'base64') : null;
|
||||||
|
if (state.operation && raw && [128, 131, 132, 133, 134, 135, 136, 143].includes(raw[0])) {
|
||||||
|
// Explicit mode commands remain available and take precedence over an
|
||||||
|
// automatic sequence, including while it is still waiting for full mode.
|
||||||
|
fail(record, 'modeChanged', 'Undocking stopped by a Roomba mode command.');
|
||||||
|
}
|
||||||
|
if (raw && [137, 145].includes(raw[0])) {
|
||||||
|
if (raw.length !== 5) throw new Error('Invalid raw drive command');
|
||||||
|
const first = raw.readInt16BE(1);
|
||||||
|
const second = raw.readInt16BE(3);
|
||||||
|
if (raw[0] === 137 && first !== 0 && state.assist && !snapshot(record).movementLocked) {
|
||||||
|
const limited = Buffer.from(raw);
|
||||||
|
limited.writeInt16BE(Math.max(-ASSIST_SPEED, Math.min(ASSIST_SPEED, first)), 1);
|
||||||
|
return { ...payload, raw: limited.toString('base64') };
|
||||||
|
}
|
||||||
|
if (raw[0] === 145) {
|
||||||
|
return protectCommand(record, { type: 'drive', driveDirect: { left: second, right: first } });
|
||||||
|
}
|
||||||
|
if (first === 0 && state.operation) fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
|
||||||
|
if (first !== 0 && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
|
||||||
|
}
|
||||||
|
// User drive input is discarded at submitCommand before reaching here.
|
||||||
|
// Internal safety stops still interrupt the controller through this path.
|
||||||
|
if (payload.driveDirect) {
|
||||||
|
const { left, right } = payload.driveDirect;
|
||||||
|
if (!Number(left) && !Number(right) && state.operation) {
|
||||||
|
fail(record, 'undockStopped', 'Undocking stopped by a stop command.');
|
||||||
|
}
|
||||||
|
if ((Number(left) || Number(right)) && snapshot(record).movementLocked) throw new Error('Drive blocked while docked or undocking');
|
||||||
|
if (state.assist) {
|
||||||
|
const scale = Math.min(1, ASSIST_SPEED / Math.max(Math.abs(left), Math.abs(right), 1));
|
||||||
|
return { ...payload, driveDirect: { left: Math.round(left * scale), right: Math.round(right * scale) } };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (payload.servo && state.assist) throw new Error('Camera locked during dock assist');
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function disconnect(record) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
if (state.operation) fail(record, 'roverDisconnected', 'Rover disconnected during undocking.');
|
||||||
|
clearOperation(state);
|
||||||
|
state.seenAt = 0;
|
||||||
|
state.contact = null;
|
||||||
|
state.assist = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAdjustReverse(record) {
|
||||||
|
return Boolean(record && stateFor(record).operation?.reversing);
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueIntent(record, type, payload, socket) {
|
||||||
|
const state = stateFor(record);
|
||||||
|
// Validate before mutating, so duplicate/rejected requests cannot disturb an
|
||||||
|
// operation that is already in progress.
|
||||||
|
if (type !== 'cancelDocking') {
|
||||||
|
if (state.operation) throw new Error('Undocking is already in progress');
|
||||||
|
if (Date.now() - state.seenAt >= SENSOR_TIMEOUT_MS || state.contact == null) {
|
||||||
|
throw new Error('Waiting for current rover telemetry');
|
||||||
|
}
|
||||||
|
// Already at the requested destination; acknowledge without restarting assist.
|
||||||
|
if (type === 'enterDocking' && state.contact) return uuidv4();
|
||||||
|
}
|
||||||
|
const id = uuidv4();
|
||||||
|
try {
|
||||||
|
handleIntent(record, type, payload, socket);
|
||||||
|
} catch (error) {
|
||||||
|
fail(record, 'commandFailed', error.message);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
module.exports = { shouldIgnoreDriveInput, canAdjustReverse, snapshot, intents, issueIntent, processTelemetry, protectCommand, disconnect };
|
||||||
@@ -16,7 +16,7 @@ const { renderIndexHtml, renderOgImage, renderWebManifest } = require('../embedS
|
|||||||
a direct browser load. Keeping the setup and admin routes in this explicit
|
a direct browser load. Keeping the setup and admin routes in this explicit
|
||||||
allowlist prevents them from working only after client-side navigation.
|
allowlist prevents them from working only after client-side navigation.
|
||||||
*/
|
*/
|
||||||
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin'], async (req, res) => {
|
app.get(['/', '/old', '/spectate', '/mini', '/display', '/scanner', '/database', '/ptz', '/reports', '/setup', '/admin', '/mic'], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const html = await renderIndexHtml(req);
|
const html = await renderIndexHtml(req);
|
||||||
res.type('html').send(html);
|
res.type('html').send(html);
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// User and idle commands share one dispatch contract, while only the trusted
|
// User and automated commands share validation; only trusted automation bypasses user permissions.
|
||||||
// idle entry point can bypass per-item user locks.
|
|
||||||
const { buildEntity, buildCommand } = require('./entityHelpers');
|
const { buildEntity, buildCommand } = require('./entityHelpers');
|
||||||
|
|
||||||
function assertAccess(actor = {}) {
|
function assertAccess(actor = {}) {
|
||||||
@@ -9,13 +8,14 @@ function assertAccess(actor = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createActions({ getConfig, ha, locks }) {
|
function createActions({ getConfig, ha, locks }) {
|
||||||
async function execute(id, value, actor, idle = false) {
|
async function execute(id, value, actor, automated = false) {
|
||||||
if (!idle) assertAccess(actor);
|
if (!automated) assertAccess(actor);
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config.enabled) throw new Error('Activity Controls are disabled');
|
if (!config.enabled) throw new Error('Activity Controls are disabled');
|
||||||
const item = config.items.find((entry) => entry.id === id);
|
const item = config.items.find((entry) => entry.id === id);
|
||||||
if (!item) throw new Error('Unknown activity item');
|
if (!item) throw new Error('Unknown activity item');
|
||||||
if (!idle && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
|
if (!automated && locks.isLocked(id) && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is locked');
|
||||||
|
if (!automated && item.readOnly && !['admin', 'lockdown'].includes(actor.role)) throw new Error('This item is read-only for users');
|
||||||
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
|
if (!ha.enabled || !ha.isConnected()) throw new Error('Home Assistant is offline');
|
||||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
|
const entity = buildEntity(item, ha.getRawEntitySnapshot(id));
|
||||||
if (!entity.available) throw new Error('This item is unavailable');
|
if (!entity.available) throw new Error('This item is unavailable');
|
||||||
@@ -25,30 +25,34 @@ function createActions({ getConfig, ha, locks }) {
|
|||||||
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
|
await ha.callHomeAssistantService(entity.domain, command.service, command.data);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runIdleActions() {
|
async function runAutomation(trigger, shouldRun = () => true) {
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
if (!config.enabled) return { action: 'homeAssistantActivitiesIdle', skipped: true };
|
if (!config.enabled) return { action: trigger, skipped: true };
|
||||||
const results = [];
|
const results = [];
|
||||||
// Catch per item so one offline integration cannot prevent the remaining
|
// Catch per item so one offline integration cannot prevent the remaining
|
||||||
// configured cleanup actions from running during this idle window.
|
// configured actions from running for this trigger.
|
||||||
for (const item of config.items) {
|
for (const item of config.items) {
|
||||||
if (!item.idleAction || item.idleAction === 'unchanged' || item.readOnly) continue;
|
// Stop remaining writes if presence or mode changed while HA was responding.
|
||||||
|
if (!shouldRun()) break;
|
||||||
|
const setting = item.automations?.[trigger];
|
||||||
|
if (!setting?.action || setting.action === 'unchanged') continue;
|
||||||
try {
|
try {
|
||||||
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
|
const entity = buildEntity(item, ha.getRawEntitySnapshot(item.id));
|
||||||
if (entity.type === 'readOnly') continue;
|
if (entity.type === 'readOnly') continue;
|
||||||
if ((item.idleAction === 'press') !== (entity.type === 'button')) throw new Error('Idle action does not match the control type');
|
if ((setting.action === 'press') !== (entity.type === 'button')) throw new Error('Automation action does not match the control type');
|
||||||
// The admin form omits an empty optional string. Treat that as empty
|
// The admin form omits an empty optional string. Treat that as empty
|
||||||
// text so clearing a message works; other types still reject it through
|
// text so clearing a message works; other types still reject it through
|
||||||
// their normal value validation rather than silently receiving zero.
|
// their normal value validation rather than silently receiving zero.
|
||||||
await execute(item.id, item.idleAction === 'press' ? 'press' : (item.idleValue ?? ''), null, true);
|
await execute(item.id, setting.action === 'press' ? 'press' : (setting.value ?? ''), null, true);
|
||||||
results.push({ id: item.id, ok: true });
|
results.push({ id: item.id, ok: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
results.push({ id: item.id, ok: false, error: error.message });
|
results.push({ id: item.id, ok: false, error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { action: 'homeAssistantActivitiesIdle', results };
|
return { action: trigger, results };
|
||||||
}
|
}
|
||||||
return { act: (id, value, actor) => execute(id, value, actor), runIdleActions };
|
return { act: (id, value, actor) => execute(id, value, actor), runAutomation,
|
||||||
|
runIdleActions: () => runAutomation('idle') };
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createActions, assertAccess };
|
module.exports = { createActions, assertAccess };
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ test('sensor states, live constraints, and unknown button states are preserved',
|
|||||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
|
assert.equal(buildEntity({ id: 'button.bell' }, raw('unknown')).available, true);
|
||||||
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
assert.equal(buildEntity({ id: 'button.bell' }, raw('unavailable')).available, false);
|
||||||
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
|
assert.equal(buildEntity({ id: 'button.bell' }, null).available, false);
|
||||||
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'readOnly');
|
assert.equal(buildEntity({ id: 'switch.fan', readOnly: true }, raw('on')).type, 'toggle');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
|
test('domain dispatch handles native entities and helpers without room-light payloads', async () => {
|
||||||
@@ -87,22 +87,22 @@ test('permissions, locks, availability and allowlist are authoritative', async (
|
|||||||
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
|
test('idle ignores user locks, preserves no-op items, and isolates failures', async () => {
|
||||||
const h = harness([
|
const h = harness([
|
||||||
{ id: 'switch.keep' },
|
{ id: 'switch.keep' },
|
||||||
{ id: 'number.bad', idleAction: 'set', idleValue: '200' },
|
{ id: 'number.bad', automations: { idle: { action: 'set', value: '200' } } },
|
||||||
{ id: 'switch.fan', idleAction: 'set', idleValue: 'off' },
|
{ id: 'switch.fan', automations: { idle: { action: 'set', value: 'off' } } },
|
||||||
// An empty optional text box is omitted by the schema-driven admin form.
|
// An empty optional text box is omitted by the schema-driven admin form.
|
||||||
{ id: 'input_text.message', idleAction: 'set' },
|
{ id: 'input_text.message', automations: { idle: { action: 'set' } } },
|
||||||
{ id: 'input_button.stop', idleAction: 'press' },
|
{ id: 'input_button.stop', automations: { idle: { action: 'press' } } },
|
||||||
{ id: 'sensor.humidity', idleAction: 'set', idleValue: '0' },
|
{ id: 'sensor.humidity', automations: { idle: { action: 'set', value: '0' } } },
|
||||||
{ id: 'switch.readonly', readOnly: true, idleAction: 'set', idleValue: 'off' },
|
{ id: 'switch.readonly', readOnly: true, automations: { idle: { action: 'set', value: 'off' } } },
|
||||||
], {
|
], {
|
||||||
'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
|
'switch.readonly': raw('on'), 'number.bad': raw('0', { min: 0, max: 10 }), 'switch.fan': raw('on'),
|
||||||
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
|
'input_text.message': raw('Welcome', { min: 0, max: 30 }), 'input_button.stop': raw('unknown'),
|
||||||
});
|
});
|
||||||
h.locked.add('switch.fan');
|
h.locked.add('switch.fan');
|
||||||
const result = await h.actions.runIdleActions();
|
const result = await h.actions.runIdleActions();
|
||||||
assert.equal(result.results[0].ok, false);
|
assert.equal(result.results[0].ok, false);
|
||||||
assert.equal(result.results.filter((entry) => entry.ok).length, 3);
|
assert.equal(result.results.filter((entry) => entry.ok).length, 4);
|
||||||
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }]);
|
assert.deepEqual(h.calls.map((call) => call[2]), [{ entity_id: 'switch.fan' }, { entity_id: 'input_text.message', value: '' }, { entity_id: 'input_button.stop' }, { entity_id: 'switch.readonly' }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('an outstanding service response does not block later commands', async () => {
|
test('an outstanding service response does not block later commands', async () => {
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
// never acquire unrelated devices simply because they share a connection.
|
// never acquire unrelated devices simply because they share a connection.
|
||||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||||
|
|
||||||
|
// All triggers use the same inputs so the admin form and execution contract agree.
|
||||||
|
function automation(title, description) {
|
||||||
|
return strictObject({
|
||||||
|
action: string({ title: 'Action', enum: ['unchanged', 'set', 'press'], default: 'unchanged' }),
|
||||||
|
value: string({ title: 'Value', description: 'For set: on/off, a number, exact selection, or text. Empty text clears it. Ignored for press.', maxLength: 255 }),
|
||||||
|
}, { title, description });
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
key: 'homeAssistantActivities',
|
key: 'homeAssistantActivities',
|
||||||
feature: true,
|
feature: true,
|
||||||
@@ -18,9 +26,12 @@ module.exports = {
|
|||||||
icon: string({ description: 'Font Awesome name, as used by social links. Blank or invalid names use a fallback.', maxLength: 80, examples: ['FaFan'] }),
|
icon: string({ description: 'Font Awesome name, as used by social links. Blank or invalid names use a fallback.', maxLength: 80, examples: ['FaFan'] }),
|
||||||
// Match social-link colors so admins can customize tiles through the existing config form.
|
// Match social-link colors so admins can customize tiles through the existing config form.
|
||||||
color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }),
|
color: string({ description: 'Optional six-digit hexadecimal tile color, like social links.', examples: ['#3B82F6'], pattern: '^#[0-9a-fA-F]{6}$' }),
|
||||||
readOnly: boolean({ title: 'Read only', default: false, description: 'Display the value without allowing user or idle commands.' }),
|
readOnly: boolean({ title: 'Read only for users', default: false, description: 'Ordinary users can only view this item. Admins and automations can still change supported controls.' }),
|
||||||
idleAction: string({ title: 'When idle', enum: ['unchanged', 'set', 'press'], default: 'unchanged', description: 'Leave unchanged, set the idle value, or press a button once. Runs independently of user locks.' }),
|
automations: strictObject({
|
||||||
idleValue: string({ title: 'Idle value', description: 'For set: on/off, a number, exact selection, or text (empty text clears it). Checked against live entity limits when idle runs.', examples: ['0'], maxLength: 255 }),
|
idle: automation('When idle', 'Runs after the idle timeout with no users or admins online.'),
|
||||||
|
locked: automation('When server locked', 'Runs on entering admin or lockdown mode.'),
|
||||||
|
online: automation('When users online', 'Runs when a user or admin comes online, or the server reopens with one online. Suppressed in admin and lockdown modes. Spectators do not count.'),
|
||||||
|
}, { title: 'Automations', description: 'One-time actions that bypass item locks and read-only permissions.' }),
|
||||||
}, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }),
|
}, { description: 'One independently controlled or read-only activity control item.', required: ['id'] }),
|
||||||
},
|
},
|
||||||
}, { title: 'Activity Controls', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
|
}, { title: 'Activity Controls', description: 'Generic activity controls and idle behavior, separate from room lighting.', required: ['enabled', 'items'] }),
|
||||||
|
|||||||
@@ -9,7 +9,8 @@ const TYPES = {
|
|||||||
function buildEntity(item, raw, locked = false) {
|
function buildEntity(item, raw, locked = false) {
|
||||||
const attributes = raw?.attributes || {};
|
const attributes = raw?.attributes || {};
|
||||||
const domain = item.id.split('.')[0];
|
const domain = item.id.split('.')[0];
|
||||||
const type = item.readOnly ? 'readOnly' : TYPES[domain] || 'readOnly';
|
// Read-only is a user permission, not an entity capability: admins and automation still need its write type.
|
||||||
|
const type = TYPES[domain] || 'readOnly';
|
||||||
// A never-pressed button legitimately reports unknown. Its state is a last
|
// A never-pressed button legitimately reports unknown. Its state is a last
|
||||||
// press timestamp, not availability or a boolean toggle state.
|
// press timestamp, not availability or a boolean toggle state.
|
||||||
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
|
const available = Boolean(raw && raw.state !== 'unavailable' && (raw.state !== 'unknown' || type === 'button'));
|
||||||
@@ -17,7 +18,7 @@ function buildEntity(item, raw, locked = false) {
|
|||||||
return {
|
return {
|
||||||
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
|
id: item.id, name: item.name?.trim() || attributes.friendly_name || item.id,
|
||||||
// Include presentation settings in session state so all clients share the configured color.
|
// Include presentation settings in session state so all clients share the configured color.
|
||||||
icon: item.icon || '', color: item.color || '', domain, type, locked, available,
|
icon: item.icon || '', color: item.color || '', domain, type, readOnly: Boolean(item.readOnly), locked, available,
|
||||||
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
|
state: raw?.state ?? 'unknown', unit: attributes.unit_of_measurement || '',
|
||||||
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
|
min: numeric(attributes.min), max: numeric(attributes.max), step: numeric(attributes.step),
|
||||||
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
|
options: Array.isArray(attributes.options) ? attributes.options.filter((option) => typeof option === 'string') : [],
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ const { buildEntity } = require('./entityHelpers');
|
|||||||
const { createLocks, resolveItem } = require('./locks');
|
const { createLocks, resolveItem } = require('./locks');
|
||||||
const { createActions } = require('./actions');
|
const { createActions } = require('./actions');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
const { getMode } = require('../modeManager');
|
const { getMode, modeEvents } = require('../modeManager');
|
||||||
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
|
const logger = require('../../globals/logger').child('homeAssistantActivitiesService');
|
||||||
|
|
||||||
const events = new EventEmitter();
|
const events = new EventEmitter();
|
||||||
@@ -24,6 +24,35 @@ const actions = createActions({ getConfig: () => config, ha: {
|
|||||||
callHomeAssistantService: ha.callHomeAssistantService,
|
callHomeAssistantService: ha.callHomeAssistantService,
|
||||||
}, locks });
|
}, locks });
|
||||||
|
|
||||||
|
let operatorsOnline = false;
|
||||||
|
let lastAutomationState = null;
|
||||||
|
let automationRevision = 0;
|
||||||
|
|
||||||
|
function refreshAutomation() {
|
||||||
|
const mode = getMode();
|
||||||
|
const locked = mode === 'admin' || mode === 'lockdown';
|
||||||
|
// Remember transitions, not entity values: HA updates and extra connections
|
||||||
|
// must not repeatedly press buttons or undo a manual admin adjustment.
|
||||||
|
const state = config.enabled && snapshotReady && ha.isConnected()
|
||||||
|
? (locked ? mode : operatorsOnline ? 'online' : null) : null;
|
||||||
|
if (state === lastAutomationState) return;
|
||||||
|
lastAutomationState = state;
|
||||||
|
const revision = ++automationRevision;
|
||||||
|
if (!state) return;
|
||||||
|
actions.runAutomation(locked ? 'locked' : 'online', () => revision === automationRevision)
|
||||||
|
.then((result) => {
|
||||||
|
const failures = result.results?.filter((item) => !item.ok) || [];
|
||||||
|
if (failures.length) logger.warn('Activity automation failed', { trigger: result.action, failures });
|
||||||
|
}).catch((error) => logger.warn('Activity automation failed', { error: error.message }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOperatorsOnline(online) {
|
||||||
|
operatorsOnline = online;
|
||||||
|
refreshAutomation();
|
||||||
|
}
|
||||||
|
|
||||||
|
modeEvents.on('change', refreshAutomation);
|
||||||
|
|
||||||
function getState() {
|
function getState() {
|
||||||
return {
|
return {
|
||||||
enabled: config.enabled,
|
enabled: config.enabled,
|
||||||
@@ -53,14 +82,17 @@ function setLocked(query, locked) {
|
|||||||
ha.homeAssistantEvents.on('snapshot', () => {
|
ha.homeAssistantEvents.on('snapshot', () => {
|
||||||
snapshotReady = true;
|
snapshotReady = true;
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
refreshAutomation();
|
||||||
});
|
});
|
||||||
ha.homeAssistantEvents.on('status', () => {
|
ha.homeAssistantEvents.on('status', () => {
|
||||||
snapshotReady = false;
|
snapshotReady = false;
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
refreshAutomation();
|
||||||
});
|
});
|
||||||
registerConfigurationHandler('homeAssistantActivities', (next) => {
|
registerConfigurationHandler('homeAssistantActivities', (next) => {
|
||||||
config = next;
|
config = next;
|
||||||
emitUpdate();
|
emitUpdate();
|
||||||
|
refreshAutomation();
|
||||||
});
|
});
|
||||||
// Resolve access on the server for every command; clients only supply item/value.
|
// Resolve access on the server for every command; clients only supply item/value.
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
@@ -76,4 +108,7 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = { ...actions, getState, setLocked, activityEvents: events };
|
module.exports = { ...actions, getState, setLocked, setOperatorsOnline, activityEvents: events,
|
||||||
|
// A returning operator ends idle cleanup even if earlier HA calls are still settling.
|
||||||
|
runIdleActions: () => actions.runAutomation('idle', () => !operatorsOnline),
|
||||||
|
};
|
||||||
|
|||||||
@@ -4,11 +4,15 @@
|
|||||||
const logger = require('../../globals/logger').child('idleService');
|
const logger = require('../../globals/logger').child('idleService');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const { getRole, roleEvents } = require('../roleService');
|
const { getRole, roleEvents } = require('../roleService');
|
||||||
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
|
const { isDeterred, verificationEvents } = require('../verificationService');
|
||||||
const { IDLE_TIMEOUT_MS } = require('./constants');
|
const { IDLE_TIMEOUT_MS } = require('./constants');
|
||||||
const { runtime } = require('./state');
|
const { runtime } = require('./state');
|
||||||
const { runIdleActions } = require('./actions');
|
const { runIdleActions } = require('./actions');
|
||||||
|
const activityControls = require('../homeAssistantActivitiesService');
|
||||||
|
|
||||||
function getActivitySnapshot() {
|
function getActivitySnapshot() {
|
||||||
|
const mode = getMode();
|
||||||
let onlineUsers = 0;
|
let onlineUsers = 0;
|
||||||
let onlineAdmins = 0;
|
let onlineAdmins = 0;
|
||||||
let onlineSpectators = 0;
|
let onlineSpectators = 0;
|
||||||
@@ -28,12 +32,16 @@ function getActivitySnapshot() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
|
||||||
Lockdown admins are counted with regular admins because both represent a
|
if (
|
||||||
person with operator-level access who may be supervising the room without
|
isDeterred(socket)
|
||||||
actively driving a rover. Plain users also count even before they request
|
|| (mode === MODES.ADMIN && role !== 'admin' && role !== 'lockdown')
|
||||||
control, which is the behavior this service now needs.
|
|| (mode === MODES.LOCKDOWN && role !== 'lockdown')
|
||||||
*/
|
) {
|
||||||
|
onlineIgnored += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (role === 'admin' || role === 'lockdown') {
|
if (role === 'admin' || role === 'lockdown') {
|
||||||
onlineAdmins += 1;
|
onlineAdmins += 1;
|
||||||
return;
|
return;
|
||||||
@@ -89,7 +97,7 @@ function scheduleIdleTimer() {
|
|||||||
runtime.deadlineAt = null;
|
runtime.deadlineAt = null;
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
logger.info('Idle automation skipped; user or admin online', activity);
|
logger.info('Idle automation skipped; eligible operator online', activity);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
runtime.lastTriggeredAt = Date.now();
|
runtime.lastTriggeredAt = Date.now();
|
||||||
@@ -113,16 +121,12 @@ function scheduleIdleTimer() {
|
|||||||
function refreshIdleState() {
|
function refreshIdleState() {
|
||||||
const activity = getActivitySnapshot();
|
const activity = getActivitySnapshot();
|
||||||
logger.info('Idle state refresh', activity);
|
logger.info('Idle state refresh', activity);
|
||||||
|
activityControls.setOperatorsOnline(activity.totalActive > 0);
|
||||||
if (activity.totalActive > 0) {
|
if (activity.totalActive > 0) {
|
||||||
clearIdleTimer();
|
clearIdleTimer();
|
||||||
if (runtime.idleActionsCompleted) {
|
if (runtime.idleActionsCompleted) {
|
||||||
logger.info('Idle action one-shot reset; operator is online again', activity);
|
logger.info('Idle action one-shot reset; operator is online again', activity);
|
||||||
}
|
}
|
||||||
/*
|
|
||||||
A user/admin coming online starts a new activity window. When the room
|
|
||||||
later becomes idle again, the cleanup pipeline should be allowed to run
|
|
||||||
once for that new idle period.
|
|
||||||
*/
|
|
||||||
runtime.idleActionsCompleted = false;
|
runtime.idleActionsCompleted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -130,17 +134,13 @@ function refreshIdleState() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
/*
|
|
||||||
A user/admin can be online without ever touching rover controls, so socket
|
|
||||||
presence has to be a first-class idle signal. The disconnect hook is just as
|
|
||||||
important: it is what starts the idle timeout after the last non-spectator
|
|
||||||
leaves, even if no driving event happens around that departure.
|
|
||||||
*/
|
|
||||||
refreshIdleState();
|
refreshIdleState();
|
||||||
socket.on('disconnect', refreshIdleState);
|
socket.on('disconnect', refreshIdleState);
|
||||||
});
|
});
|
||||||
|
|
||||||
roleEvents.on('change', refreshIdleState);
|
roleEvents.on('change', refreshIdleState);
|
||||||
|
modeEvents.on('change', refreshIdleState);
|
||||||
|
verificationEvents.on('change', refreshIdleState);
|
||||||
|
|
||||||
refreshIdleState();
|
refreshIdleState();
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ function normalizeAdditionalHosts(rawHosts) {
|
|||||||
|
|
||||||
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
||||||
const media = config?.media || {};
|
const media = config?.media || {};
|
||||||
|
const webrtcPort = media.webrtcPort ?? 8189;
|
||||||
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
|
const configuredHosts = normalizeAdditionalHosts(media.additionalHosts);
|
||||||
let publicHostname = '';
|
let publicHostname = '';
|
||||||
try {
|
try {
|
||||||
@@ -55,10 +56,10 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
|||||||
|
|
||||||
webrtc: true,
|
webrtc: true,
|
||||||
// WHEP and WHIP signaling is public only through the Node /video proxy.
|
// WHEP and WHIP signaling is public only through the Node /video proxy.
|
||||||
// ICE transport on 8189 remains directly reachable by browsers.
|
// The configured ICE transport port remains directly reachable by browsers.
|
||||||
webrtcAddress: '127.0.0.1:8889',
|
webrtcAddress: '127.0.0.1:8889',
|
||||||
webrtcLocalUDPAddress: ':8189',
|
webrtcLocalUDPAddress: `:${webrtcPort}`,
|
||||||
webrtcLocalTCPAddress: ':8189',
|
webrtcLocalTCPAddress: `:${webrtcPort}`,
|
||||||
webrtcAdditionalHosts: additionalHosts,
|
webrtcAdditionalHosts: additionalHosts,
|
||||||
webrtcICEServers2: [
|
webrtcICEServers2: [
|
||||||
{ url: 'stun:stun.l.google.com:19302' },
|
{ url: 'stun:stun.l.google.com:19302' },
|
||||||
@@ -81,6 +82,7 @@ function buildMediaMtxConfig({ config, serverPort, snapshotWriterPath }) {
|
|||||||
{ action: 'pprof' },
|
{ action: 'pprof' },
|
||||||
],
|
],
|
||||||
paths: {
|
paths: {
|
||||||
|
'~^room-audio/': { source: 'publisher' },
|
||||||
all: {
|
all: {
|
||||||
source: 'publisher',
|
source: 'publisher',
|
||||||
sourceOnDemand: false,
|
sourceOnDemand: false,
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
// Media Transport Configuration
|
// Media Transport Configuration
|
||||||
// Purpose: Defines additional MediaMTX ICE hosts not already derived from the server's public URL.
|
// Purpose: Defines the WebRTC media port and additional ICE hosts.
|
||||||
// Scope: Contains configuration metadata only and never starts MediaMTX.
|
// Scope: Contains configuration metadata only and never starts MediaMTX.
|
||||||
const { strictObject, stringArray } = require('../../configuration/schemaHelpers');
|
const { strictObject, stringArray, integer } = require('../../configuration/schemaHelpers');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
key: 'media',
|
key: 'media',
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
|
webrtcPort: 8189,
|
||||||
additionalHosts: [],
|
additionalHosts: [],
|
||||||
},
|
},
|
||||||
schema: strictObject({
|
schema: strictObject({
|
||||||
|
webrtcPort: integer({
|
||||||
|
title: 'WebRTC port',
|
||||||
|
description: 'UDP and TCP port for live media. Forward the same external port to this server; HTTPS signaling continues through the web UI.',
|
||||||
|
minimum: 1, maximum: 65535,
|
||||||
|
}),
|
||||||
additionalHosts: stringArray({
|
additionalHosts: stringArray({
|
||||||
title: 'Additional ICE hosts',
|
title: 'Additional ICE hosts',
|
||||||
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
|
item: { description: 'Hostname or IP address MediaMTX advertises as a WebRTC ICE candidate.', examples: ['rover.example.com', 'media-server.local'], minLength: 1, maxLength: 255 },
|
||||||
array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
|
array: { description: 'Extra public or LAN hostnames and addresses browsers may use in addition to the hostname derived from the top-level public URL.', uniqueItems: true },
|
||||||
}),
|
}),
|
||||||
}, { title: 'Media', description: 'Adds optional WebRTC network candidates to the public hostname and local interfaces generated automatically for MediaMTX.', required: ['additionalHosts'] }),
|
}, { title: 'Media', description: 'Configures the WebRTC media port and additional network candidates.', required: ['webrtcPort', 'additionalHosts'] }),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -400,6 +400,7 @@ function createOvercurrentProtectionService(options = {}) {
|
|||||||
type: 'drive',
|
type: 'drive',
|
||||||
driveDirect: { left: 0, right: 0 },
|
driveDirect: { left: 0, right: 0 },
|
||||||
});
|
});
|
||||||
|
issueAdjustedCommand(roverId, { type: 'song', song: { notes: [{ note: 76, duration: 8 }, { note: 72, duration: 8 }, { note: 67, duration: 16 }] } });
|
||||||
logger.warn('Stopped rover after persistent wheel overcurrent', {
|
logger.warn('Stopped rover after persistent wheel overcurrent', {
|
||||||
roverId,
|
roverId,
|
||||||
motor: stalledWheel,
|
motor: stalledWheel,
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
const { evaluateTools } = require('./tools');
|
const { evaluateTools } = require('./tools');
|
||||||
|
|
||||||
|
function formatTimeContext(timestamp, now) {
|
||||||
|
const at = Number(timestamp);
|
||||||
|
if (!Number.isFinite(at) || at <= 0) return 'time=unknown';
|
||||||
|
return `at=${new Date(at).toISOString()} age_seconds=${Math.max(0, Math.floor((now - at) / 1000))}`;
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeNeatoIssue(value) {
|
function normalizeNeatoIssue(value) {
|
||||||
const raw = String(value || '').trim();
|
const raw = String(value || '').trim();
|
||||||
if (!raw) return 'none';
|
if (!raw) return 'none';
|
||||||
@@ -7,12 +13,21 @@ function normalizeNeatoIssue(value) {
|
|||||||
return raw;
|
return raw;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason }) {
|
function toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone, now = Date.now(), previousRunAt }) {
|
||||||
const lines = [];
|
const lines = [];
|
||||||
|
lines.push(`current_time: ${new Date(now).toISOString()}`);
|
||||||
|
lines.push(`local_time: ${new Date(now).toLocaleString('en-US', { timeZone: timezone, hour12: false })} timezone=${timezone}`);
|
||||||
|
lines.push(`previous_run: ${formatTimeContext(previousRunAt, now)}`);
|
||||||
lines.push(`trigger: ${triggerReason || 'heartbeat'}`);
|
lines.push(`trigger: ${triggerReason || 'heartbeat'}`);
|
||||||
lines.push(`mode: ${mode || 'unknown'}`);
|
lines.push(`mode: ${mode || 'unknown'}`);
|
||||||
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
|
lines.push(`home_assistant_connected: ${homeAssistantState?.connected ? 'yes' : 'no'}`);
|
||||||
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
|
lines.push(`lights_locked_on: ${homeAssistantState?.lightPolicy?.lockedOn ? 'yes' : 'no'}`);
|
||||||
|
lines.push(`activity_controls: enabled=${activityState?.enabled ? 'yes' : 'no'} connected=${activityState?.connected ? 'yes' : 'no'}`);
|
||||||
|
for (const item of activityState?.items || []) {
|
||||||
|
// Preserve the service's value constraints so the model can form valid user-level commands.
|
||||||
|
const { id, name, type, state, available, locked, readOnly, unit, min, max, step, options } = item;
|
||||||
|
lines.push(`activity_item: ${JSON.stringify({ id, name, type, state, available, locked, readOnly, unit, min, max, step, options })}`);
|
||||||
|
}
|
||||||
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
|
lines.push(`lift: ${liftState?.connected ? 'connected' : 'offline'} busy=${liftState?.busy ? 'yes' : 'no'}`);
|
||||||
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
|
const neatoError = normalizeNeatoIssue(neatoState?.telemetry?.robotError);
|
||||||
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
|
const neatoAlert = normalizeNeatoIssue(neatoState?.telemetry?.robotAlert);
|
||||||
@@ -56,8 +71,8 @@ function toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster
|
|||||||
return lines.join('\n');
|
return lines.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildToolState({ mode, homeAssistantState, neatoState, liftState }) {
|
function buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState }) {
|
||||||
return evaluateTools({ mode, homeAssistantState, neatoState, liftState });
|
return evaluateTools({ mode, homeAssistantState, activityState, neatoState, liftState });
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatToolResultMessages(toolCalls) {
|
function formatToolResultMessages(toolCalls) {
|
||||||
@@ -88,7 +103,7 @@ function formatToolResultMessages(toolCalls) {
|
|||||||
return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }];
|
return [{ role: 'system', content: ['TOOL_RESULT', ...lines].join('\n') }];
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildConversation({ recentMessages, name }) {
|
function buildConversation({ recentMessages, name, now = Date.now() }) {
|
||||||
const messages = [];
|
const messages = [];
|
||||||
(recentMessages || []).forEach((entry) => {
|
(recentMessages || []).forEach((entry) => {
|
||||||
const text = String(entry?.text || '').trim();
|
const text = String(entry?.text || '').trim();
|
||||||
@@ -97,12 +112,13 @@ function buildConversation({ recentMessages, name }) {
|
|||||||
|
|
||||||
const isAssistant = Boolean(entry?.bot);
|
const isAssistant = Boolean(entry?.bot);
|
||||||
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
const nickname = String(entry?.nickname || (isAssistant ? name || 'Overseer' : 'user')).trim();
|
||||||
|
const timing = `[${formatTimeContext(entry?.ts, now)}]`;
|
||||||
if (isAssistant) {
|
if (isAssistant) {
|
||||||
if (text) messages.push({ role: 'assistant', content: text });
|
if (text) messages.push({ role: 'assistant', content: `${timing} ${text}` });
|
||||||
messages.push(...toolResultMessages);
|
messages.push(...toolResultMessages.map((message) => ({ ...message, content: `${message.content}\nmessage_time: ${timing}` })));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (text) messages.push({ role: 'user', content: `${nickname}: ${text}` });
|
if (text) messages.push({ role: 'user', content: `${timing} ${nickname}: ${text}` });
|
||||||
});
|
});
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
@@ -143,6 +159,7 @@ function buildModelMessages({
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
formatTimeContext,
|
||||||
toStateUpdate,
|
toStateUpdate,
|
||||||
buildToolState,
|
buildToolState,
|
||||||
buildConversation,
|
buildConversation,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const { getRole, roleEvents } = require('../roleService');
|
|||||||
const { getMode, MODES, modeEvents } = require('../modeManager');
|
const { getMode, MODES, modeEvents } = require('../modeManager');
|
||||||
const { verificationEvents } = require('../verificationService');
|
const { verificationEvents } = require('../verificationService');
|
||||||
const homeAssistantService = require('../homeAssistantService');
|
const homeAssistantService = require('../homeAssistantService');
|
||||||
|
const activityControls = require('../homeAssistantActivitiesService');
|
||||||
const neatoService = require('../neatoService');
|
const neatoService = require('../neatoService');
|
||||||
const liftService = require('../liftService');
|
const liftService = require('../liftService');
|
||||||
const buttonBoxService = require('../buttonBoxService');
|
const buttonBoxService = require('../buttonBoxService');
|
||||||
@@ -27,7 +28,7 @@ const {
|
|||||||
normalizeMs,
|
normalizeMs,
|
||||||
} = require('./constants');
|
} = require('./constants');
|
||||||
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
|
const { isAdminRole, buildAdminState, buildFailureInfo } = require('./runtimeHelpers');
|
||||||
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages } = require('./contextBuilder');
|
const { toStateUpdate, buildToolState, buildConversation, buildModelMessages, formatTimeContext } = require('./contextBuilder');
|
||||||
const { buildOllamaTools, executeToolAction } = require('./tools');
|
const { buildOllamaTools, executeToolAction } = require('./tools');
|
||||||
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
|
const { loadMemory, saveMemory, createDefaultMemory, summarizeMemory } = require('./memoryStore');
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ const runtime = {
|
|||||||
runHistory: [],
|
runHistory: [],
|
||||||
liveToolCalls: [],
|
liveToolCalls: [],
|
||||||
pendingDirectAddressRun: false,
|
pendingDirectAddressRun: false,
|
||||||
|
lastDecisionAt: null,
|
||||||
memoryStore: loadMemory(),
|
memoryStore: loadMemory(),
|
||||||
contextResetAt: Date.now(),
|
contextResetAt: Date.now(),
|
||||||
};
|
};
|
||||||
@@ -365,7 +367,7 @@ function buildToolCallFeedEntries(requestedActions = [], actionResults = []) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildRecentEventsSummary() {
|
function buildRecentEventsSummary(now) {
|
||||||
const events = (runtime.liveToolCalls || [])
|
const events = (runtime.liveToolCalls || [])
|
||||||
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
.filter((entry) => entry && (entry.phase === 'ok' || entry.phase === 'error' || entry.phase === 'blocked'))
|
||||||
.slice(-3)
|
.slice(-3)
|
||||||
@@ -373,40 +375,50 @@ function buildRecentEventsSummary() {
|
|||||||
const tool = String(entry.tool || 'unknown');
|
const tool = String(entry.tool || 'unknown');
|
||||||
const phase = String(entry.phase || 'unknown');
|
const phase = String(entry.phase || 'unknown');
|
||||||
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
|
const err = entry.error ? ` error=${String(entry.error).slice(0, 60)}` : '';
|
||||||
return `- tool=${tool} phase=${phase}${err}`;
|
return `- ${formatTimeContext(entry.at, now)} tool=${tool} phase=${phase}${err}`;
|
||||||
});
|
});
|
||||||
if (!events.length) return '- none';
|
if (!events.length) return '- none';
|
||||||
return events.join('\n');
|
return events.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDecision(triggerReason) {
|
async function runDecision(triggerReason) {
|
||||||
|
// Use one reference time so all ages in this model request agree.
|
||||||
|
const now = Date.now();
|
||||||
|
const previousRunAt = runtime.lastDecisionAt;
|
||||||
|
runtime.lastDecisionAt = now;
|
||||||
const runId = runtime.tickCount;
|
const runId = runtime.tickCount;
|
||||||
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
updateStatus({ phase: 'context_build', currentRunId: runId, lastTriggerReason: triggerReason, lastError: null, lastErrorDetails: null });
|
||||||
|
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
const homeAssistantState = getHomeAssistantState();
|
const homeAssistantState = getHomeAssistantState();
|
||||||
|
const activityState = activityControls.getState();
|
||||||
const neatoState = getNeatoState();
|
const neatoState = getNeatoState();
|
||||||
const liftState = getLiftState();
|
const liftState = getLiftState();
|
||||||
const roster = buildRosterSummary();
|
const roster = buildRosterSummary();
|
||||||
|
|
||||||
const stateUpdate = toStateUpdate({ mode, homeAssistantState, neatoState, liftState, roster, triggerReason });
|
const stateUpdate = toStateUpdate({ mode, homeAssistantState, activityState, neatoState, liftState, roster, triggerReason, timezone: loadConfig().timezone, now, previousRunAt });
|
||||||
const toolState = buildToolState({ mode, homeAssistantState, neatoState, liftState });
|
const toolState = buildToolState({ mode, homeAssistantState, activityState, neatoState, liftState });
|
||||||
|
|
||||||
|
let chatCount = 0;
|
||||||
|
let botCount = 0;
|
||||||
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
|
const recentConversation = getRecentMessages(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT + 20, { includeSystem: true })
|
||||||
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
.filter((entry) => Number(entry?.ts || 0) >= runtime.contextResetAt)
|
||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
if (!entry?.roverId) return true;
|
if (!entry?.roverId) return true;
|
||||||
return isPublicChatTargetId(entry.roverId);
|
return isPublicChatTargetId(entry.roverId);
|
||||||
})
|
})
|
||||||
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
// Select newest entries within each budget, then restore chronological order.
|
||||||
const conversationMessages = buildConversation({ recentMessages: recentConversation, name });
|
.reverse()
|
||||||
|
.filter((entry) => entry?.bot ? ++botCount <= MAX_BOT_CONTEXT : ++chatCount <= MAX_CHAT_CONTEXT)
|
||||||
|
.reverse();
|
||||||
|
const conversationMessages = buildConversation({ recentMessages: recentConversation, name, now });
|
||||||
|
|
||||||
const systemPrompt = await readPrompt();
|
const systemPrompt = await readPrompt();
|
||||||
const modelMessages = buildModelMessages({
|
const modelMessages = buildModelMessages({
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
stateUpdate,
|
stateUpdate,
|
||||||
memorySummary: summarizeMemory(runtime.memoryStore),
|
memorySummary: summarizeMemory(runtime.memoryStore),
|
||||||
recentEvents: buildRecentEventsSummary(),
|
recentEvents: buildRecentEventsSummary(now),
|
||||||
conversationMessages,
|
conversationMessages,
|
||||||
availableTools: toolState.available,
|
availableTools: toolState.available,
|
||||||
blockedTools: toolState.blocked,
|
blockedTools: toolState.blocked,
|
||||||
@@ -680,6 +692,7 @@ function clearHistory(reason = 'admin requested clear history', options = {}) {
|
|||||||
runtime.contextResetAt = Date.now();
|
runtime.contextResetAt = Date.now();
|
||||||
runtime.runHistory = [];
|
runtime.runHistory = [];
|
||||||
runtime.liveToolCalls = [];
|
runtime.liveToolCalls = [];
|
||||||
|
runtime.lastDecisionAt = null;
|
||||||
runtime.generationCount = 0;
|
runtime.generationCount = 0;
|
||||||
runtime.generationTotalMs = 0;
|
runtime.generationTotalMs = 0;
|
||||||
if (resetPersistentMemory) {
|
if (resetPersistentMemory) {
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
const activityControls = require('../../homeAssistantActivitiesService');
|
||||||
|
const { getMode } = require('../../modeManager');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
id: 'activity_act',
|
||||||
|
signature: 'activity_act(entity_id, value)',
|
||||||
|
description: 'Operate a configured Activity Control with normal user permissions. Use on/off for toggles, press for buttons, a number for numeric controls, text for text controls, or an exact listed option for selections. Locked and read-only items cannot be changed.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
entity_id: { type: 'string', minLength: 1 },
|
||||||
|
value: { type: ['string', 'number'] },
|
||||||
|
},
|
||||||
|
required: ['entity_id', 'value'],
|
||||||
|
additionalProperties: false,
|
||||||
|
},
|
||||||
|
availability(ctx = {}) {
|
||||||
|
if (ctx.mode === 'admin' || ctx.mode === 'lockdown') {
|
||||||
|
return { available: false, reason: `policy_lock:site_mode_${ctx.mode}` };
|
||||||
|
}
|
||||||
|
if (!ctx.activityState?.enabled || !ctx.activityState?.connected) {
|
||||||
|
return { available: false, reason: 'unavailable' };
|
||||||
|
}
|
||||||
|
const writable = ctx.activityState.items?.some((item) => item.available && !item.locked && !item.readOnly && item.type !== 'readOnly');
|
||||||
|
return { available: Boolean(writable), reason: writable ? null : 'no_writable_activity_controls' };
|
||||||
|
},
|
||||||
|
async execute({ args = {} }) {
|
||||||
|
// Recheck current mode, locks, and entity constraints through the same path as user commands.
|
||||||
|
await activityControls.act(args.entity_id, args.value, { role: 'user', mode: getMode() });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -12,6 +12,7 @@ const neatoClearErrors = require('./neatoClearErrors');
|
|||||||
const haSetEntity = require('./haSetEntity');
|
const haSetEntity = require('./haSetEntity');
|
||||||
const haSetLightColor = require('./haSetLightColor');
|
const haSetLightColor = require('./haSetLightColor');
|
||||||
const buttonBoxAddCount = require('./buttonBoxAddCount');
|
const buttonBoxAddCount = require('./buttonBoxAddCount');
|
||||||
|
const activityAct = require('./activityAct');
|
||||||
|
|
||||||
const TOOL_DEFINITIONS = [
|
const TOOL_DEFINITIONS = [
|
||||||
memoryRead,
|
memoryRead,
|
||||||
@@ -28,6 +29,7 @@ const TOOL_DEFINITIONS = [
|
|||||||
haSetEntity,
|
haSetEntity,
|
||||||
haSetLightColor,
|
haSetLightColor,
|
||||||
buttonBoxAddCount,
|
buttonBoxAddCount,
|
||||||
|
activityAct,
|
||||||
];
|
];
|
||||||
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
|
const TOOL_BY_ID = new Map(TOOL_DEFINITIONS.map((tool) => [tool.id, tool]));
|
||||||
|
|
||||||
|
|||||||
@@ -35,9 +35,9 @@ async function deliverReplay(payload = {}) {
|
|||||||
requestedBy: payload.requestedBy,
|
requestedBy: payload.requestedBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
|
||||||
let providerContext = null;
|
let providerContext = null;
|
||||||
try {
|
try {
|
||||||
|
jobStatus.emit(job, 'accepted', { message: buildAcceptedMessage(job) });
|
||||||
let providerError = null;
|
let providerError = null;
|
||||||
if (preferredDeliveryProvider?.begin) {
|
if (preferredDeliveryProvider?.begin) {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
|
// Purpose: Provides transport-neutral replay jobs, source lookup, status events, and user-facing progress text.
|
||||||
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
|
// Scope: Keeps Discord-command and web-triggered replay delivery on the same core status pipeline.
|
||||||
const Fuse = require('fuse.js');
|
const Fuse = require('fuse.js');
|
||||||
|
const { updateReplayStatus } = require('../replayEngineV2/cooldown');
|
||||||
|
|
||||||
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
const DEFAULT_ALLOWED_MENTIONS = { parse: [], repliedUser: false };
|
||||||
const FUZZY_THRESHOLD = 0.42;
|
const FUZZY_THRESHOLD = 0.42;
|
||||||
@@ -180,6 +181,22 @@ function createJobStatusEmitter({ io, logger, sanitizeMentions }) {
|
|||||||
|
|
||||||
function emit(job, status, extra = {}) {
|
function emit(job, status, extra = {}) {
|
||||||
const payload = buildPayload(job, status, extra);
|
const payload = buildPayload(job, status, extra);
|
||||||
|
updateReplayStatus(payload);
|
||||||
|
if (status === 'accepted') {
|
||||||
|
// Sound once per participating rover; a disconnected rover must not abort video creation.
|
||||||
|
const { issueCommand } = require('../commandService');
|
||||||
|
const roverIds = new Set(job.sources.filter((entry) => entry.type === 'rover').map((entry) => entry.id));
|
||||||
|
for (const roverId of roverIds) {
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, {
|
||||||
|
type: 'song',
|
||||||
|
song: { notes: [{ note: 72, duration: 8 }, { note: 76, duration: 8 }, { note: 79, duration: 16 }] },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger?.warn?.('Unable to play replay tune', { roverId, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
io.emit('replay:status', payload);
|
io.emit('replay:status', payload);
|
||||||
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
|
if (status === 'ready' && extra.media) io.emit('replay:ready', extra.media);
|
||||||
if (status === 'failed') io.emit('replay:failed', payload);
|
if (status === 'failed') io.emit('replay:failed', payload);
|
||||||
|
|||||||
@@ -4,40 +4,61 @@
|
|||||||
const EventEmitter = require('events');
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
const COOLDOWN_MS = 10 * 1000;
|
const COOLDOWN_MS = 10 * 1000;
|
||||||
|
const MAX_CONCURRENT_JOBS = 3;
|
||||||
const replayEvents = new EventEmitter();
|
const replayEvents = new EventEmitter();
|
||||||
|
const activeJobs = new Map();
|
||||||
|
let status = null;
|
||||||
let lastTriggeredAt = null;
|
let lastTriggeredAt = null;
|
||||||
let lastTriggeredBy = null;
|
let lastTriggeredBy = null;
|
||||||
|
|
||||||
function getRemainingMs(now = Date.now()) {
|
|
||||||
if (!lastTriggeredAt) return 0;
|
|
||||||
return Math.max(0, COOLDOWN_MS - (now - lastTriggeredAt));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getReplayState() {
|
function getReplayState() {
|
||||||
const remainingMs = getRemainingMs();
|
const remainingMs = lastTriggeredAt === null ? 0 : Math.max(0, COOLDOWN_MS - (Date.now() - lastTriggeredAt));
|
||||||
return {
|
return {
|
||||||
cooldownMs: COOLDOWN_MS,
|
cooldownMs: COOLDOWN_MS,
|
||||||
lastTriggeredAt,
|
lastTriggeredAt,
|
||||||
lastTriggeredBy,
|
lastTriggeredBy,
|
||||||
remainingMs,
|
remainingMs,
|
||||||
available: remainingMs === 0,
|
atJobLimit: activeJobs.size >= MAX_CONCURRENT_JOBS,
|
||||||
|
activeSourceKeys: [...new Set(Array.from(activeJobs.values()).flatMap((sources) =>
|
||||||
|
sources.map((source) => `${source.type}:${source.id}`),
|
||||||
|
))],
|
||||||
|
status,
|
||||||
|
available: remainingMs === 0 && activeJobs.size < MAX_CONCURRENT_JOBS,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryTriggerReplay(by = null) {
|
function emitUpdate() {
|
||||||
const remainingMs = getRemainingMs();
|
replayEvents.emit('update', { state: getReplayState(), by: lastTriggeredBy });
|
||||||
if (remainingMs > 0) {
|
|
||||||
return { ok: false, remainingMs, state: getReplayState() };
|
|
||||||
}
|
|
||||||
lastTriggeredAt = Date.now();
|
|
||||||
lastTriggeredBy = by || null;
|
|
||||||
const state = getReplayState();
|
|
||||||
replayEvents.emit('update', { state, by: lastTriggeredBy });
|
|
||||||
return { ok: true, state };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
function tryTriggerReplay({ jobId, by = null } = {}) {
|
||||||
tryTriggerReplay,
|
const state = getReplayState();
|
||||||
getReplayState,
|
if (state.atJobLimit) {
|
||||||
replayEvents,
|
return { ok: false, error: 'Replay limit reached: 3 jobs are already being made', state };
|
||||||
};
|
}
|
||||||
|
if (state.remainingMs > 0) {
|
||||||
|
return { ok: false, error: `Replay cooldown active. Try again in ${Math.ceil(state.remainingMs / 1000)}s.`, remainingMs: state.remainingMs, state };
|
||||||
|
}
|
||||||
|
if (!jobId) throw new Error('Replay job id required');
|
||||||
|
activeJobs.set(jobId, []);
|
||||||
|
status = { jobId, status: 'accepted' };
|
||||||
|
lastTriggeredAt = Date.now();
|
||||||
|
lastTriggeredBy = by;
|
||||||
|
emitUpdate();
|
||||||
|
return { ok: true, state: getReplayState() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateReplayStatus(payload) {
|
||||||
|
if (!activeJobs.has(payload.jobId)) return;
|
||||||
|
if (payload.jobId === status?.jobId) {
|
||||||
|
status = { jobId: payload.jobId, status: payload.status, title: payload.title, message: payload.message };
|
||||||
|
}
|
||||||
|
if (payload.status === 'ready' || payload.status === 'failed') {
|
||||||
|
activeJobs.delete(payload.jobId);
|
||||||
|
} else {
|
||||||
|
activeJobs.set(payload.jobId, payload.sources || []);
|
||||||
|
}
|
||||||
|
emitUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { tryTriggerReplay, getReplayState, replayEvents, updateReplayStatus };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const { promisify } = require('util');
|
|||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
const logger = require('../../globals/logger').child('replayEngineV2');
|
const logger = require('../../globals/logger').child('replayEngineV2');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { roomAudioEvents } = require('../roomAudioService');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -40,8 +41,8 @@ function getActiveSegmentRoot() {
|
|||||||
return runtime.activeSegmentRoot;
|
return runtime.activeSegmentRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
const workerManager = createWorkerManager({ getActiveSegmentRoot });
|
|
||||||
const segmentStore = createSegmentStore({ getActiveSegmentRoot });
|
const segmentStore = createSegmentStore({ getActiveSegmentRoot });
|
||||||
|
const workerManager = createWorkerManager({ getActiveSegmentRoot, onWorkerClosed: segmentStore.refreshIndexForWorker });
|
||||||
const sidebarRenderer = createSidebarRenderer({ execFileAsync, ensureDir });
|
const sidebarRenderer = createSidebarRenderer({ execFileAsync, ensureDir });
|
||||||
const replayBuilder = createReplayBuilder({
|
const replayBuilder = createReplayBuilder({
|
||||||
execFileAsync,
|
execFileAsync,
|
||||||
@@ -50,6 +51,7 @@ const replayBuilder = createReplayBuilder({
|
|||||||
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
renderSidebarVideo: sidebarRenderer.renderSidebarVideo,
|
||||||
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
getVideoEntriesForSource: segmentStore.getVideoEntriesForSource,
|
||||||
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
|
getAudioEntriesForSource: segmentStore.getAudioEntriesForSource,
|
||||||
|
getRoomAudioEntries: segmentStore.getRoomAudioEntries,
|
||||||
overlapping: segmentStore.overlapping,
|
overlapping: segmentStore.overlapping,
|
||||||
});
|
});
|
||||||
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefaultWebSources });
|
||||||
@@ -88,6 +90,10 @@ async function start() {
|
|||||||
}, CLEANUP_INTERVAL_MS);
|
}, CLEANUP_INTERVAL_MS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
roomAudioEvents.on('update', () => {
|
||||||
|
tick().catch((err) => logger.warn('room audio update tick failed', err.message));
|
||||||
|
});
|
||||||
|
|
||||||
roomCameraEvents.on('update', () => {
|
roomCameraEvents.on('update', () => {
|
||||||
tick().catch((err) => logger.warn('room camera update tick failed', err.message));
|
tick().catch((err) => logger.warn('room camera update tick failed', err.message));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ function buildChatEventsForWindow(startMs, endMs, limit = 22, preWindowCount = 1
|
|||||||
return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts);
|
return [...beforeWindow, ...inWindow].sort((a, b) => a.ts - b.ts);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, overlapping }) {
|
function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo, getVideoEntriesForSource, getAudioEntriesForSource, getRoomAudioEntries, overlapping }) {
|
||||||
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
function resolveReplayWindow({ sources = [], nowMs, guardMs, durationMs }) {
|
||||||
const tentativeEnd = nowMs - guardMs;
|
const tentativeEnd = nowMs - guardMs;
|
||||||
const sourceEnds = [];
|
const sourceEnds = [];
|
||||||
@@ -186,6 +186,28 @@ function createReplayBuilder({ execFileAsync, fsp, ensureDir, renderSidebarVideo
|
|||||||
|
|
||||||
if (!normalizedVideos.length) throw new Error('No replay segments available for selected sources');
|
if (!normalizedVideos.length) throw new Error('No replay segments available for selected sources');
|
||||||
|
|
||||||
|
const roomAudioEntries = await pinSegmentFiles(
|
||||||
|
overlapping(getRoomAudioEntries(), tStart, tEnd), tmpDir, 'room-audio-seg',
|
||||||
|
);
|
||||||
|
if (roomAudioEntries.length) {
|
||||||
|
// Place each segment at its recorded time. A mic joining late or
|
||||||
|
// reconnecting must leave silence in the gap, not shift its speech earlier.
|
||||||
|
const audioInputs = roomAudioEntries.flatMap((entry) => ['-i', entry.filePath]);
|
||||||
|
const audioFilters = roomAudioEntries.map((entry, index) => {
|
||||||
|
const start = Math.max(0, (tStart - entry.startMs) / 1000);
|
||||||
|
const duration = (Math.min(tEnd, entry.endMs) - Math.max(tStart, entry.startMs)) / 1000;
|
||||||
|
const delay = Math.max(0, Math.round(entry.startMs - tStart));
|
||||||
|
return `[${index}:a]atrim=start=${start.toFixed(3)}:duration=${duration.toFixed(3)},asetpts=PTS-STARTPTS,adelay=${delay}:all=1[room${index}]`;
|
||||||
|
});
|
||||||
|
const refs = roomAudioEntries.map((_, index) => `[room${index}]`).join('');
|
||||||
|
audioFilters.push(`${refs}amix=inputs=${roomAudioEntries.length}:normalize=0[roomout]`);
|
||||||
|
const roomAudioPath = path.join(tmpDir, 'room-audio.m4a');
|
||||||
|
await execFileAsync(FFMPEG_BIN, ['-y', '-hide_banner', '-loglevel', 'error', ...audioInputs,
|
||||||
|
'-filter_complex', audioFilters.join(';'), '-map', '[roomout]', '-vn', '-ac', '1', '-ar', '48000',
|
||||||
|
'-c:a', 'aac', '-b:a', '96k', roomAudioPath]);
|
||||||
|
normalizedAudios.push(roomAudioPath);
|
||||||
|
}
|
||||||
|
|
||||||
const layout = buildGridLayout(normalizedVideos.length);
|
const layout = buildGridLayout(normalizedVideos.length);
|
||||||
const { maxWidth, maxHeight } = await probeMaxFrameSize(normalizedVideos.map((v) => v.path));
|
const { maxWidth, maxHeight } = await probeMaxFrameSize(normalizedVideos.map((v) => v.path));
|
||||||
let tileWidth = maxWidth || 640;
|
let tileWidth = maxWidth || 640;
|
||||||
|
|||||||
@@ -6,9 +6,10 @@ const fs = require('fs');
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const logger = require('../../globals/logger').child('replayEngineV2');
|
const logger = require('../../globals/logger').child('replayEngineV2');
|
||||||
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
const { BUFFER_SECONDS, SEGMENT_SECONDS } = require('./constants');
|
||||||
const { workers, segmentIndex } = require('./state');
|
const { workers, pendingWorkerStarts, segmentIndex } = require('./state');
|
||||||
const { sourceKey, sourceDirForKey } = require('./sources');
|
const { sourceKey, sourceDirForKey } = require('./sources');
|
||||||
const ptzCameraService = require('../ptzCameraService');
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
const SEGMENT_FILENAME_PATTERN = /^seg-[a-f0-9-]+-\d{6}\.mp4$/;
|
||||||
|
|
||||||
async function ensureDir(dir) {
|
async function ensureDir(dir) {
|
||||||
await fsp.mkdir(dir, { recursive: true });
|
await fsp.mkdir(dir, { recursive: true });
|
||||||
@@ -25,7 +26,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const segmentFiles = files.filter((entry) => entry.isFile() && /^seg-\d{6}\.mp4$/.test(entry.name)).map((entry) => entry.name).sort();
|
const segmentFiles = files.filter((entry) => entry.isFile() && SEGMENT_FILENAME_PATTERN.test(entry.name)).map((entry) => entry.name).sort();
|
||||||
if (!segmentFiles.length) {
|
if (!segmentFiles.length) {
|
||||||
segmentIndex.set(key, []);
|
segmentIndex.set(key, []);
|
||||||
return;
|
return;
|
||||||
@@ -64,6 +65,13 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
|
|
||||||
async function cleanupOldFiles() {
|
async function cleanupOldFiles() {
|
||||||
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
const cutoff = Date.now() - BUFFER_SECONDS * 1000;
|
||||||
|
// Retention applies to stopped workers too; dynamic microphone sessions
|
||||||
|
// must not leave permanent index entries after their audio expires.
|
||||||
|
for (const [key, entries] of segmentIndex) {
|
||||||
|
const retained = entries.filter((entry) => entry.endMs >= cutoff);
|
||||||
|
if (!retained.length && !workers.has(key) && !pendingWorkerStarts.has(key)) segmentIndex.delete(key);
|
||||||
|
else segmentIndex.set(key, retained);
|
||||||
|
}
|
||||||
const root = getActiveSegmentRoot();
|
const root = getActiveSegmentRoot();
|
||||||
try {
|
try {
|
||||||
await ensureDir(root);
|
await ensureDir(root);
|
||||||
@@ -81,6 +89,10 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
if (stat.mtimeMs < cutoff) await fsp.unlink(filePath);
|
if (stat.mtimeMs < cutoff) await fsp.unlink(filePath);
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
if (!workers.has(dirent.name) && !pendingWorkerStarts.has(dirent.name)) {
|
||||||
|
// rmdir only removes empty directories, preserving buffered or newly written files.
|
||||||
|
try { await fsp.rmdir(dirPath); } catch {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('cleanup failed', err.message);
|
logger.warn('cleanup failed', err.message);
|
||||||
@@ -115,6 +127,14 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRoomAudioEntries() {
|
||||||
|
// Include retained buffers from microphones that disconnected during the
|
||||||
|
// replay window, rather than limiting history to currently live streams.
|
||||||
|
return [...segmentIndex.entries()]
|
||||||
|
.filter(([key]) => key.startsWith('roomAudio__audio__'))
|
||||||
|
.flatMap(([, entries]) => entries);
|
||||||
|
}
|
||||||
|
|
||||||
function overlapping(entries, startMs, endMs) {
|
function overlapping(entries, startMs, endMs) {
|
||||||
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
return entries.filter((entry) => entry.endMs > startMs && entry.startMs < endMs);
|
||||||
}
|
}
|
||||||
@@ -153,7 +173,7 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
try {
|
try {
|
||||||
const files = fs.readdirSync(dir);
|
const files = fs.readdirSync(dir);
|
||||||
for (const name of files) {
|
for (const name of files) {
|
||||||
if (!/^seg-\d{6}\.mp4$/.test(name)) continue;
|
if (!SEGMENT_FILENAME_PATTERN.test(name)) continue;
|
||||||
const full = path.join(dir, name);
|
const full = path.join(dir, name);
|
||||||
let stat;
|
let stat;
|
||||||
try { stat = fs.statSync(full); } catch { continue; }
|
try { stat = fs.statSync(full); } catch { continue; }
|
||||||
@@ -172,10 +192,12 @@ function createSegmentStore({ getActiveSegmentRoot }) {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
refreshSegmentIndex,
|
refreshSegmentIndex,
|
||||||
|
refreshIndexForWorker,
|
||||||
cleanupOldFiles,
|
cleanupOldFiles,
|
||||||
getVideoEntriesForSource,
|
getVideoEntriesForSource,
|
||||||
getAudioEntriesForRover,
|
getAudioEntriesForRover,
|
||||||
getAudioEntriesForSource,
|
getAudioEntriesForSource,
|
||||||
|
getRoomAudioEntries,
|
||||||
overlapping,
|
overlapping,
|
||||||
bootstrapIndexFromDisk,
|
bootstrapIndexFromDisk,
|
||||||
getReplayHealthSnapshot,
|
getReplayHealthSnapshot,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Replay Socket Hooks
|
// Replay Socket Hooks
|
||||||
// Purpose: Registers web socket replay-trigger handler that publishes replay requests.
|
// Purpose: Registers web socket replay-trigger handler that publishes replay requests.
|
||||||
// Scope: Applies replay mode/cooldown/source validation for socket-triggered replay requests.
|
// Scope: Applies replay mode/availability/source validation for socket-triggered replay requests.
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('replaySocket');
|
const logger = require('../../globals/logger').child('replaySocket');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
@@ -45,9 +45,9 @@ function registerReplaySocketHooks({ tryTriggerReplay, validateSources, getDefau
|
|||||||
const title = normalizeReplayTitle(payload?.title);
|
const title = normalizeReplayTitle(payload?.title);
|
||||||
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
|
const includeSidebar = normalizeIncludeSidebar(payload?.includeSidebar);
|
||||||
const jobId = buildReplayJobId('web');
|
const jobId = buildReplayJobId('web');
|
||||||
const attempt = tryTriggerReplay({ by: { source: 'web', requester } });
|
const attempt = tryTriggerReplay({ jobId, by: { source: 'web', requester } });
|
||||||
if (!attempt.ok) {
|
if (!attempt.ok) {
|
||||||
cb({ error: 'Replay cooldown active', remainingMs: attempt.remainingMs, state: attempt.state });
|
cb({ error: attempt.error, remainingMs: attempt.remainingMs, state: attempt.state });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
// Purpose: Resolves replay-capable media sources and ffmpeg worker arguments.
|
// Purpose: Resolves replay-capable media sources and ffmpeg worker arguments.
|
||||||
// Scope: Converts live rover/room state into stable source descriptors and stream worker config.
|
// Scope: Converts live rover/room state into stable source descriptors and stream worker config.
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { randomUUID } = require('crypto');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRoomCameras } = require('../roomCameraService');
|
const { getRoomCameras } = require('../roomCameraService');
|
||||||
|
const { getActiveRoomAudioStreams } = require('../roomAudioService');
|
||||||
const ptzCameraService = require('../ptzCameraService');
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
|
const { FFMPEG_BIN, SEGMENT_SECONDS, TARGET_FPS } = require('./constants');
|
||||||
|
|
||||||
@@ -51,12 +53,17 @@ function listDesiredSources() {
|
|||||||
// both come from the same live MediaMTX path, unlike rovers where audio is a
|
// both come from the same live MediaMTX path, unlike rovers where audio is a
|
||||||
// separate published stream.
|
// separate published stream.
|
||||||
sources.push(...ptzCameraService.getReplayWorkerSources());
|
sources.push(...ptzCameraService.getReplayWorkerSources());
|
||||||
|
for (const stream of getActiveRoomAudioStreams()) {
|
||||||
|
sources.push({ id: stream.id, sourceType: 'roomAudio', kind: 'audio', label: stream.name,
|
||||||
|
inputUrl: `rtsp://127.0.0.1:8554/room-audio/${encodeURIComponent(stream.id)}` });
|
||||||
|
}
|
||||||
return sources;
|
return sources;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildWorkerArgs(activeSegmentRoot, source) {
|
function buildWorkerArgs(activeSegmentRoot, source) {
|
||||||
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
|
const dir = sourceDirForKey(activeSegmentRoot, sourceKey(source));
|
||||||
const pattern = path.join(dir, 'seg-%06d.mp4');
|
// Each capture process owns its filenames so restarts preserve buffered segments.
|
||||||
|
const pattern = path.join(dir, `seg-${randomUUID()}-%06d.mp4`);
|
||||||
// MediaMTX and RTSP cameras use TCP so replay capture has one reliable
|
// MediaMTX and RTSP cameras use TCP so replay capture has one reliable
|
||||||
// transport and never falls back to separate RTP/RTCP UDP listeners.
|
// transport and never falls back to separate RTP/RTCP UDP listeners.
|
||||||
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
|
const inputTransport = /^rtsps?:\/\//i.test(source.inputUrl)
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ async function ensureDir(dir) {
|
|||||||
await fsp.mkdir(dir, { recursive: true });
|
await fsp.mkdir(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkerManager({ getActiveSegmentRoot }) {
|
function createWorkerManager({ getActiveSegmentRoot, onWorkerClosed }) {
|
||||||
function startWorker(source) {
|
function startWorker(source) {
|
||||||
const key = sourceKey(source);
|
const key = sourceKey(source);
|
||||||
if (workers.has(key) || pendingWorkerStarts.has(key)) return;
|
if (workers.has(key) || pendingWorkerStarts.has(key)) return;
|
||||||
@@ -21,27 +21,37 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
|||||||
const dir = sourceDirForKey(getActiveSegmentRoot(), key);
|
const dir = sourceDirForKey(getActiveSegmentRoot(), key);
|
||||||
ensureDir(dir)
|
ensureDir(dir)
|
||||||
.then(() => {
|
.then(() => {
|
||||||
if (workers.has(key)) {
|
// Directory creation can finish after a microphone has disconnected.
|
||||||
|
if (workers.has(key) || !listDesiredSources().some((entry) => sourceKey(entry) === key)) {
|
||||||
pendingWorkerStarts.delete(key);
|
pendingWorkerStarts.delete(key);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
const proc = spawn(FFMPEG_BIN, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
workers.set(key, { key, source, proc });
|
const worker = { key, source, proc, stopping: false };
|
||||||
|
workers.set(key, worker);
|
||||||
pendingWorkerStarts.delete(key);
|
pendingWorkerStarts.delete(key);
|
||||||
proc.stderr.on('data', (chunk) => {
|
proc.stderr.on('data', (chunk) => {
|
||||||
const text = String(chunk || '').trim();
|
const text = String(chunk || '').trim();
|
||||||
if (!text) return;
|
if (!text) return;
|
||||||
logger.warn('worker stderr', { key, text: text.slice(0, 500) });
|
logger.warn('worker stderr', { key, text: text.slice(0, 500) });
|
||||||
});
|
});
|
||||||
proc.on('exit', (code, signal) => {
|
proc.on('error', (err) => logger.warn('worker process failed', { key, error: err.message }));
|
||||||
const current = workers.get(key);
|
worker.finished = new Promise((resolve) => {
|
||||||
if (current?.proc === proc) workers.delete(key);
|
proc.once('close', async (code, signal) => {
|
||||||
|
clearTimeout(worker.killTimer);
|
||||||
|
// FFmpeg writes the final MP4 trailer before close. Index it before
|
||||||
|
// releasing this worker so a disconnect cannot drop its last audio.
|
||||||
|
try { await onWorkerClosed(worker); }
|
||||||
|
catch (err) { logger.warn('final worker indexing failed', { key, error: err.message }); }
|
||||||
|
if (workers.get(key) === worker) workers.delete(key);
|
||||||
|
resolve();
|
||||||
logger.warn('worker exited', { key, code, signal });
|
logger.warn('worker exited', { key, code, signal });
|
||||||
setTimeout(() => {
|
if (!worker.stopping) setTimeout(() => {
|
||||||
const desired = listDesiredSources().find((entry) => sourceKey(entry) === key);
|
const desired = listDesiredSources().find((entry) => sourceKey(entry) === key);
|
||||||
if (desired && !workers.has(key)) startWorker(desired);
|
if (desired && !workers.has(key)) startWorker(desired);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
pendingWorkerStarts.delete(key);
|
pendingWorkerStarts.delete(key);
|
||||||
@@ -52,8 +62,14 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
|||||||
function stopWorker(key) {
|
function stopWorker(key) {
|
||||||
const worker = workers.get(key);
|
const worker = workers.get(key);
|
||||||
if (!worker) return;
|
if (!worker) return;
|
||||||
try { worker.proc.kill('SIGTERM'); } catch {}
|
if (!worker.stopping) {
|
||||||
workers.delete(key);
|
worker.stopping = true;
|
||||||
|
worker.proc.kill('SIGTERM');
|
||||||
|
// Bound shutdown if a capture is blocked waiting on its network input.
|
||||||
|
worker.killTimer = setTimeout(() => worker.proc.kill('SIGKILL'), 3000);
|
||||||
|
worker.killTimer.unref();
|
||||||
|
}
|
||||||
|
return worker.finished;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncWorkers() {
|
async function syncWorkers() {
|
||||||
@@ -63,9 +79,11 @@ function createWorkerManager({ getActiveSegmentRoot }) {
|
|||||||
const key = sourceKey(source);
|
const key = sourceKey(source);
|
||||||
if (!workers.has(key)) startWorker(source);
|
if (!workers.has(key)) startWorker(source);
|
||||||
}
|
}
|
||||||
|
const stops = [];
|
||||||
for (const key of Array.from(workers.keys())) {
|
for (const key of Array.from(workers.keys())) {
|
||||||
if (!desiredKeys.has(key)) stopWorker(key);
|
if (!desiredKeys.has(key)) stops.push(stopWorker(key));
|
||||||
}
|
}
|
||||||
|
await Promise.all(stops);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { startWorker, stopWorker, syncWorkers };
|
return { startWorker, stopWorker, syncWorkers };
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
||||||
|
module.exports = {
|
||||||
|
key: 'roomAudio', feature: true,
|
||||||
|
defaultValue: { enabled: false, sources: [] },
|
||||||
|
schema: strictObject({
|
||||||
|
enabled: boolean({ description: 'Enable room audio and local browser microphone publishing.' }),
|
||||||
|
sources: { type: 'array', items: strictObject({
|
||||||
|
id: string({ minLength: 1, maxLength: 80, pattern: '^[a-zA-Z0-9_-]+$' }),
|
||||||
|
name: string({ minLength: 1, maxLength: 120 }),
|
||||||
|
type: { type: 'string', enum: ['device', 'stream'] },
|
||||||
|
input: string({ minLength: 1, maxLength: 2048, description: 'ALSA device (for example default or plughw:CARD=USB,DEV=0), or a network stream URL.' }),
|
||||||
|
}, { required: ['id', 'name', 'type', 'input'] }) },
|
||||||
|
}, { title: 'Room audio', required: ['enabled', 'sources'] }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
const { spawn } = require('child_process');
|
||||||
|
const EventEmitter = require('events');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const logger = require('../../globals/logger').child('roomAudio');
|
||||||
|
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
|
||||||
|
const { getSocketIp, isLocalNetwork } = require('../../helpers/ipResolver');
|
||||||
|
const { getMode, MODES } = require('../modeManager');
|
||||||
|
const { getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
|
const { canBecomeSpectator } = require('../authService');
|
||||||
|
const { isDeterred } = require('../verificationService');
|
||||||
|
const videoSessions = require('../videoSessions');
|
||||||
|
const roomAudioEvents = new EventEmitter();
|
||||||
|
const sources = new Map();
|
||||||
|
const mediaSessions = new Map();
|
||||||
|
let enabled = false;
|
||||||
|
let active = [];
|
||||||
|
let polling = false;
|
||||||
|
let generation = 0;
|
||||||
|
|
||||||
|
function canListen(socket) {
|
||||||
|
if (!enabled || !socket) return false;
|
||||||
|
const mode = getMode();
|
||||||
|
if (mode === MODES.LOCKDOWN) return isLockdownAdmin(socket);
|
||||||
|
if (isAdmin(socket)) return true;
|
||||||
|
if (getRole(socket) === 'spectator') return canBecomeSpectator(socket);
|
||||||
|
// Room audio follows general driving eligibility, without tying listening
|
||||||
|
// to rover assignment, private-rover access, or the current driving turn.
|
||||||
|
return getRole(socket) === 'user' && mode !== MODES.ADMIN && !isDeterred(socket);
|
||||||
|
}
|
||||||
|
function canPublish(socket, id) {
|
||||||
|
return enabled && isLocalNetwork(getSocketIp(socket)) && sources.get(id)?.socketId === socket.id;
|
||||||
|
}
|
||||||
|
function getRoomAudioStreams(socket) { return canListen(socket) ? active : []; }
|
||||||
|
function getActiveRoomAudioStreams() { return active; }
|
||||||
|
function trackMediaSession(id, token) { if (id) mediaSessions.set(id, token); }
|
||||||
|
function hasStream(id) { return active.some((source) => source.id === id); }
|
||||||
|
function stopSource(id) {
|
||||||
|
const source = sources.get(id);
|
||||||
|
if (!source) return;
|
||||||
|
sources.delete(id);
|
||||||
|
clearTimeout(source.timer);
|
||||||
|
source.child?.kill('SIGTERM');
|
||||||
|
videoSessions.revokeWhere((info) => ['roomAudio', 'roomMic'].includes(info.sourceType) && info.sourceId === id);
|
||||||
|
active = active.filter((entry) => entry.id !== id);
|
||||||
|
roomAudioEvents.emit('update');
|
||||||
|
}
|
||||||
|
function startWorker(source) {
|
||||||
|
if (sources.get(source.id) !== source) return;
|
||||||
|
const args = ['-nostdin', '-hide_banner', '-loglevel', 'warning'];
|
||||||
|
if (source.type === 'device') args.push('-f', 'alsa');
|
||||||
|
else if (/^rtsps?:/i.test(source.input)) args.push('-rtsp_transport', 'tcp', '-timeout', '10000000');
|
||||||
|
else args.push('-rw_timeout', '10000000');
|
||||||
|
args.push('-i', source.input, '-map', '0:a:0', '-vn', '-c:a', 'libopus', '-ar', '48000', '-ac', '1', '-b:a', '64k', '-f', 'rtsp', '-rtsp_transport', 'tcp', `rtsp://127.0.0.1:8554/room-audio/${source.id}`);
|
||||||
|
const child = spawn('ffmpeg', args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
source.child = child;
|
||||||
|
child.stderr.on('data', (data) => logger.warn('Room audio %s: %s', source.name, String(data).trim()));
|
||||||
|
child.on('error', (error) => logger.warn('Room audio %s: %s', source.name, error.message));
|
||||||
|
child.on('close', () => {
|
||||||
|
if (sources.get(source.id) !== source) return;
|
||||||
|
source.child = null;
|
||||||
|
source.timer = setTimeout(() => startWorker(source), 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function applyConfig(config = {}) {
|
||||||
|
const ids = (config.sources || []).map((source) => source.id);
|
||||||
|
if (new Set(ids).size !== ids.length) throw new Error('Room audio source IDs must be unique');
|
||||||
|
generation += 1;
|
||||||
|
enabled = Boolean(config.enabled);
|
||||||
|
const configured = new Map((config.sources || []).map((entry) => {
|
||||||
|
const source = { ...entry, id: `static-${entry.id}` };
|
||||||
|
return [source.id, source];
|
||||||
|
}));
|
||||||
|
for (const [id, source] of sources) {
|
||||||
|
const next = configured.get(id);
|
||||||
|
// Static-source edits should not interrupt browser microphones or restart
|
||||||
|
// unchanged captures. Disabling the feature still stops every source.
|
||||||
|
if (!enabled || (!source.socketId && (!next || next.type !== source.type || next.input !== source.input))) stopSource(id);
|
||||||
|
}
|
||||||
|
if (enabled) for (const [id, source] of configured) {
|
||||||
|
const existing = sources.get(id);
|
||||||
|
if (existing) existing.name = source.name;
|
||||||
|
else {
|
||||||
|
sources.set(id, source);
|
||||||
|
startWorker(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
roomAudioEvents.emit('update');
|
||||||
|
}
|
||||||
|
async function listMediaMtxItems(collection) {
|
||||||
|
const items = [];
|
||||||
|
let page = 0;
|
||||||
|
let pageCount = 1;
|
||||||
|
while (page < pageCount) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:9997/v3/${collection}/list?page=${page}`, { signal: AbortSignal.timeout(1500) });
|
||||||
|
if (!response.ok) throw new Error('MediaMTX unavailable');
|
||||||
|
const data = await response.json();
|
||||||
|
items.push(...(data.items || []));
|
||||||
|
pageCount = data.pageCount || 1;
|
||||||
|
page += 1;
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advertise actual ready MediaMTX paths, rather than a browser reservation or
|
||||||
|
// an ffmpeg process that may still be trying to connect to its input.
|
||||||
|
async function refreshStreams() {
|
||||||
|
if ((!enabled && !mediaSessions.size) || polling) return;
|
||||||
|
polling = true;
|
||||||
|
const currentGeneration = generation;
|
||||||
|
let ready = new Set();
|
||||||
|
try {
|
||||||
|
// HTTP authorization is checked at connection time. Recheck established
|
||||||
|
// browser sessions too so lockdown, disable, and disconnect stop live audio.
|
||||||
|
const previousSessionIds = [...mediaSessions.keys()];
|
||||||
|
const sessions = await listMediaMtxItems('webrtcsessions');
|
||||||
|
const liveSessionIds = new Set(sessions.map((session) => session.id));
|
||||||
|
for (const session of sessions) {
|
||||||
|
if (!session.path?.startsWith('room-audio/')) continue;
|
||||||
|
const info = videoSessions.getSession(mediaSessions.get(session.id));
|
||||||
|
const socket = info && io.sockets.sockets.get(info.socketId);
|
||||||
|
const allowed = socket && (session.state === 'publish'
|
||||||
|
? info.sourceType === 'roomMic' && canPublish(socket, info.sourceId)
|
||||||
|
: info.sourceType === 'roomAudio' && canListen(socket));
|
||||||
|
if (!allowed) await fetch(`http://127.0.0.1:9997/v3/webrtcsessions/kick/${session.id}`, { method: 'POST', signal: AbortSignal.timeout(1500) });
|
||||||
|
}
|
||||||
|
for (const id of previousSessionIds) if (!liveSessionIds.has(id)) mediaSessions.delete(id);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Room audio permission refresh failed: %s', error.message);
|
||||||
|
}
|
||||||
|
// Permission API failures must not make ready streams disappear. Only path
|
||||||
|
// discovery determines the catalog used by playback and replay capture.
|
||||||
|
try {
|
||||||
|
const paths = await listMediaMtxItems('paths');
|
||||||
|
for (const path of paths) if (path.ready) ready.add(path.name);
|
||||||
|
} catch { ready = new Set(); }
|
||||||
|
finally { polling = false; }
|
||||||
|
if (generation !== currentGeneration) return;
|
||||||
|
const next = [...sources.values()].filter((source) => ready.has(`room-audio/${source.id}`)).map(({ id, name }) => ({ id, name }));
|
||||||
|
if (JSON.stringify(next) !== JSON.stringify(active)) {
|
||||||
|
active = next;
|
||||||
|
roomAudioEvents.emit('update');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('roomAudio:start', (_payload, cb = () => {}) => {
|
||||||
|
if (!enabled || !isLocalNetwork(getSocketIp(socket))) return cb({ error: 'Room microphone publishing requires a local visitor and enabled room audio.' });
|
||||||
|
// One owned path per socket; retries replace credentials without accumulating streams.
|
||||||
|
const id = `mic-${socket.id}`;
|
||||||
|
videoSessions.revokeWhere((info) => info.sourceType === 'roomMic' && info.sourceId === id);
|
||||||
|
sources.set(id, { id, name: 'Room microphone', socketId: socket.id });
|
||||||
|
const token = videoSessions.createSession(socket, { type: 'roomMic', id });
|
||||||
|
cb({ id, token, url: `/video/room-audio/${id}/whip` });
|
||||||
|
});
|
||||||
|
socket.on('roomAudio:release', ({ token } = {}) => {
|
||||||
|
const info = videoSessions.getSession(token);
|
||||||
|
if (info?.socketId === socket.id && info.sourceType === 'roomAudio') videoSessions.revokeSession(token);
|
||||||
|
});
|
||||||
|
socket.on('roomAudio:stop', (_payload, cb = () => {}) => {
|
||||||
|
stopSource(`mic-${socket.id}`);
|
||||||
|
cb({ success: true });
|
||||||
|
});
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
stopSource(`mic-${socket.id}`);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
applyConfig(loadConfig().roomAudio);
|
||||||
|
registerConfigurationHandler('roomAudio', applyConfig);
|
||||||
|
const pollTimer = setInterval(refreshStreams, 2000);
|
||||||
|
pollTimer.unref();
|
||||||
|
process.once('exit', () => { for (const source of sources.values()) source.child?.kill('SIGTERM'); });
|
||||||
|
module.exports = { getActiveRoomAudioStreams, trackMediaSession, roomAudioEvents, getRoomAudioStreams, canListen, canPublish, hasStream };
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
// Room-Camera Configuration
|
// Room-Camera Configuration
|
||||||
// Purpose: Defines the optional named snapshot and stream camera catalog.
|
// Purpose: Defines the optional named snapshot and stream camera catalog.
|
||||||
// Scope: Contains configuration metadata only and never contacts a camera.
|
// Scope: Contains configuration metadata only and never contacts a camera.
|
||||||
const { strictObject, string, boolean } = require('../../configuration/schemaHelpers');
|
const { strictObject, string, boolean, integer } = require('../../configuration/schemaHelpers');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
key: 'roomCameras',
|
key: 'roomCameras',
|
||||||
@@ -10,6 +10,7 @@ module.exports = {
|
|||||||
// initial configuration while the feature switch prevents network requests.
|
// initial configuration while the feature switch prevents network requests.
|
||||||
defaultValue: {
|
defaultValue: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
streamIntervalMs: 2000,
|
||||||
cameras: [
|
cameras: [
|
||||||
{
|
{
|
||||||
id: 'lobby',
|
id: 'lobby',
|
||||||
@@ -29,6 +30,7 @@ module.exports = {
|
|||||||
},
|
},
|
||||||
schema: strictObject({
|
schema: strictObject({
|
||||||
enabled: boolean({ description: 'Immediately publishes the configured room-camera catalog and enables camera snapshots and streams.' }),
|
enabled: boolean({ description: 'Immediately publishes the configured room-camera catalog and enables camera snapshots and streams.' }),
|
||||||
|
streamIntervalMs: integer({ title: 'Stream interval (ms)', description: 'Interval for sending room camera images to clients. Lowering this increases bandwidth usage FAST because it sends a full image each time.', minimum: 1 }),
|
||||||
cameras: {
|
cameras: {
|
||||||
type: 'array',
|
type: 'array',
|
||||||
description: 'Room cameras available to the web UI and replay system.',
|
description: 'Room cameras available to the web UI and replay system.',
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ const replay = require('../replayEngineV2/roomCameraReplayBuilder');
|
|||||||
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
|
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
|
||||||
|
|
||||||
let enabled = false;
|
let enabled = false;
|
||||||
|
let streamIntervalMs = 2000;
|
||||||
|
|
||||||
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
|
const snapshotEngine = createSnapshotEngine({ getRoomCameras, roomCameraEvents });
|
||||||
function applyRoomCameraConfig(roomCameraConfig = {}) {
|
function applyRoomCameraConfig(roomCameraConfig = {}) {
|
||||||
enabled = Boolean(roomCameraConfig.enabled);
|
enabled = Boolean(roomCameraConfig.enabled);
|
||||||
|
streamIntervalMs = roomCameraConfig.streamIntervalMs ?? 2000;
|
||||||
// Loading an empty catalog on disable causes the snapshot engine's existing
|
// Loading an empty catalog on disable causes the snapshot engine's existing
|
||||||
// update listener to close every stream and timer without unregistering the
|
// update listener to close every stream and timer without unregistering the
|
||||||
// stable browser gateway.
|
// stable browser gateway.
|
||||||
@@ -19,6 +21,7 @@ function applyRoomCameraConfig(roomCameraConfig = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
registerRoomCameraSocketGateway({
|
registerRoomCameraSocketGateway({
|
||||||
|
getStreamIntervalMs: () => streamIntervalMs,
|
||||||
getRoomCamera,
|
getRoomCamera,
|
||||||
getRoomCameras,
|
getRoomCameras,
|
||||||
getRoomCameraState: snapshotEngine.getRoomCameraState,
|
getRoomCameraState: snapshotEngine.getRoomCameraState,
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
|||||||
|
|
||||||
const SUBSCRIBE_LIMIT = 50;
|
const SUBSCRIBE_LIMIT = 50;
|
||||||
const SUBSCRIBE_WINDOW_MS = 10000;
|
const SUBSCRIBE_WINDOW_MS = 10000;
|
||||||
const STREAM_INTERVAL_MS = 2000;
|
|
||||||
|
|
||||||
function passesMode(socket) {
|
function passesMode(socket) {
|
||||||
const mode = getMode();
|
const mode = getMode();
|
||||||
@@ -20,7 +19,7 @@ function passesMode(socket) {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoomCameraState, roomCameraStreamEvents }) {
|
function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoomCameraState, roomCameraStreamEvents, getStreamIntervalMs }) {
|
||||||
const cameraSubscribers = new Map();
|
const cameraSubscribers = new Map();
|
||||||
const socketSubscriptions = new Map();
|
const socketSubscriptions = new Map();
|
||||||
const subscribeBuckets = new Map();
|
const subscribeBuckets = new Map();
|
||||||
@@ -82,7 +81,7 @@ function registerRoomCameraSocketGateway({ getRoomCamera, getRoomCameras, getRoo
|
|||||||
}
|
}
|
||||||
const lastSent = lastMap.get(id) || 0;
|
const lastSent = lastMap.get(id) || 0;
|
||||||
const now = ts || Date.now();
|
const now = ts || Date.now();
|
||||||
if (now - lastSent < STREAM_INTERVAL_MS) return;
|
if (now - lastSent < getStreamIntervalMs()) return;
|
||||||
lastMap.set(id, now);
|
lastMap.set(id, now);
|
||||||
sendFrame(socket, id, { ts }, buffer);
|
sendFrame(socket, id, { ts }, buffer);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,20 +2,19 @@
|
|||||||
// Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state.
|
// Purpose: Publishes sustained 600-series Roomba trouble as roster and alert state.
|
||||||
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
|
// Scope: Integrates the pure monitor with roverManager, browser alerts, and the event bus.
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { issueCommand } = require('../commandService');
|
||||||
const { sendAlert } = require('../alertService');
|
const { sendAlert } = require('../alertService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
const { REASON_LABELS, createRoverHelpMonitor } = require('./monitor');
|
||||||
const { createHelpHornNotifier } = require('./hornNotifier');
|
const { createHelpHornNotifier } = require('./hornNotifier');
|
||||||
|
const logger = require('../../globals/logger').child('roverHelpService');
|
||||||
|
|
||||||
const HELP_ALERT_COLOR = '#ef4444';
|
const HELP_ALERT_COLOR = '#ef4444';
|
||||||
|
|
||||||
const hornNotifier = createHelpHornNotifier({
|
const hornNotifier = createHelpHornNotifier({
|
||||||
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
|
getRover: (roverId) => roverManager.rovers.get(String(roverId)),
|
||||||
// commandService imports roverManager, so resolving it only when a chirp is
|
issueCommand,
|
||||||
// actually issued avoids turning server startup order into a circular module
|
logger,
|
||||||
// dependency while retaining the established command transport.
|
|
||||||
issueCommand: (roverId, payload) => require('../commandService').issueCommand(roverId, payload),
|
|
||||||
logger: require('../../globals/logger').child('roverHelpService'),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const monitor = createRoverHelpMonitor({
|
const monitor = createRoverHelpMonitor({
|
||||||
@@ -42,6 +41,11 @@ const monitor = createRoverHelpMonitor({
|
|||||||
});
|
});
|
||||||
} else if (wasNeedingHelp && !needsHelp) {
|
} else if (wasNeedingHelp && !needsHelp) {
|
||||||
hornNotifier.stop(roverId);
|
hornNotifier.stop(roverId);
|
||||||
|
try {
|
||||||
|
issueCommand(roverId, { type: 'song', song: { notes: [{ note: 72, duration: 8 }, { note: 79, duration: 16 }] } });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to play HELP-cleared confirmation', { roverId, error: err.message });
|
||||||
|
}
|
||||||
publishEvent({
|
publishEvent({
|
||||||
source: 'roverHelpService',
|
source: 'roverHelpService',
|
||||||
type: 'rover.helpCleared',
|
type: 'rover.helpCleared',
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Match actuator configuration, not a rover's display name or changing telemetry.
|
||||||
|
const { isDeepStrictEqual } = require('node:util');
|
||||||
|
const POLICY_VERSION = 5;
|
||||||
|
|
||||||
|
function controlProfile(meta = {}) {
|
||||||
|
return Object.fromEntries(['cameraServo', 'headlight', 'roomba', 'platform']
|
||||||
|
.map((key) => [key, meta?.[key] ?? null]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function compatible(model, record) {
|
||||||
|
if (model?.specification?.version !== POLICY_VERSION || !record) return false;
|
||||||
|
const profile = controlProfile(record.meta);
|
||||||
|
// Retained recordings may include removed actuator fields. Compare only
|
||||||
|
// controls this policy actually uses, on both sides of the boundary.
|
||||||
|
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(controlProfile(saved), profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { POLICY_VERSION, controlProfile, compatible };
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// Metadata only: loading the configuration must never start the learning runtime.
|
||||||
|
const { strictObject, boolean, integer, number, string } = require('../../configuration/schemaHelpers');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
key: 'roverLearning',
|
||||||
|
feature: true,
|
||||||
|
defaultValue: {
|
||||||
|
enabled: false,
|
||||||
|
recording: { maxGiB: 20, minimumFreeGiB: 2, sessionSeconds: 300 },
|
||||||
|
driving: { threads: 4, actionThreshold: 0.7, staleMs: 2000 },
|
||||||
|
training: {
|
||||||
|
enabled: true, python: '/opt/rover-learning/bin/python', threads: 8,
|
||||||
|
batchSize: 16, maxSamples: 1024, minimumSamples: 128, passesPerJob: 2,
|
||||||
|
maxStepsPerJob: 200, checkpointEverySteps: 1000,
|
||||||
|
minimumDrivingMinutes: 30, newDrivingMinutesPerModel: 10,
|
||||||
|
intervalSeconds: 60, maxJobSeconds: 600,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
schema: strictObject({
|
||||||
|
enabled: boolean({ description: 'Enable human-session recording, background learning, and verified-user model control. Disabled starts no workers or storage.' }),
|
||||||
|
driving: strictObject({
|
||||||
|
threads: integer({ minimum: 1, maximum: 16, description: 'CPU threads per active model controller.' }),
|
||||||
|
actionThreshold: number({ minimum: 0.5, maximum: 0.99, description: 'Minimum model score for issuing a command, including drive and stop commands.' }),
|
||||||
|
staleMs: integer({ minimum: 1000, maximum: 5000, description: 'Stop when video, sensors, or model predictions are older than this many milliseconds.' }),
|
||||||
|
}, { description: 'Live model execution. Manual inputs do not stop or pause the model.', required: ['threads', 'actionThreshold', 'staleMs'] }),
|
||||||
|
recording: strictObject({
|
||||||
|
maxGiB: number({ minimum: 0.1, maximum: 100000, description: 'Rolling recording budget in GiB. Checked every 15 seconds; active files can temporarily exceed it. Published models are never pruned.' }),
|
||||||
|
minimumFreeGiB: number({ minimum: 0.1, maximum: 100000, description: 'Pause recording below this amount of available filesystem space, in GiB.' }),
|
||||||
|
sessionSeconds: integer({ minimum: 30, maximum: 3600, description: 'Rotate video and telemetry sessions after this many seconds so completed sessions can be pruned.' }),
|
||||||
|
}, { description: 'Bounded recording storage. Limits never delete published models.', required: ['maxGiB', 'minimumFreeGiB', 'sessionSeconds'] }),
|
||||||
|
training: strictObject({
|
||||||
|
enabled: boolean({ description: 'Train periodically from completed usable human recordings while the service is enabled.' }),
|
||||||
|
python: string({ minLength: 1, description: 'Python executable with the service requirements installed. The container supplies /opt/rover-learning/bin/python.' }),
|
||||||
|
threads: integer({ minimum: 1, maximum: 32, description: 'CPU threads for the lower-priority training process.' }),
|
||||||
|
batchSize: integer({ minimum: 1, maximum: 64, description: 'Training windows per optimizer step.' }),
|
||||||
|
maxSamples: integer({ minimum: 128, maximum: 2048, description: 'Maximum decoded windows retained in RAM per job. Each contains four 160x120 RGB frames.' }),
|
||||||
|
minimumSamples: integer({ minimum: 1, maximum: 128, description: 'Usable sampled windows required before training.' }),
|
||||||
|
passesPerJob: integer({ minimum: 1, maximum: 10, description: 'Shuffled passes over the sampled windows per job.' }),
|
||||||
|
minimumDrivingMinutes: number({ minimum: 1, maximum: 100000, description: 'Minimum distinct usable training footage in minutes before promoting Latest; repeated passes do not count.' }),
|
||||||
|
newDrivingMinutesPerModel: number({ minimum: 1, maximum: 100000, description: 'Additional distinct usable footage required between Latest promotions.' }),
|
||||||
|
maxStepsPerJob: integer({ minimum: 1, maximum: 10000, description: 'Optimizer step cap per finite training job.' }),
|
||||||
|
checkpointEverySteps: integer({ minimum: 1, maximum: 100000, description: 'Accumulated training steps between gated Latest promotions. Permanent named snapshots are created by administrators.' }),
|
||||||
|
intervalSeconds: integer({ minimum: 30, maximum: 86400, description: 'Minimum seconds between completed training jobs.' }),
|
||||||
|
maxJobSeconds: integer({ minimum: 30, maximum: 3600, description: 'Wall-clock budget including video decoding; slow/stuck workers are stopped.' }),
|
||||||
|
}, { description: 'Ongoing CPU learning, sampling, and gated Latest checkpoint updates.', required: ['enabled', 'python', 'threads', 'batchSize', 'maxSamples', 'minimumSamples', 'passesPerJob', 'minimumDrivingMinutes', 'newDrivingMinutesPerModel', 'maxStepsPerJob', 'checkpointEverySteps', 'intervalSeconds', 'maxJobSeconds'] }),
|
||||||
|
}, { description: 'Optional rover recording and learning service.', required: ['enabled', 'recording', 'training', 'driving'] }),
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
// Export an explicit allowlist, never the raw socket/session/configuration object.
|
||||||
|
const { redact } = require('./errors');
|
||||||
|
const { POLICY_VERSION } = require('./capabilities');
|
||||||
|
const os = require('node:os');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createHash } = require('node:crypto');
|
||||||
|
const applicationVersion = require('../../../package.json').version;
|
||||||
|
const hash = createHash('sha256');
|
||||||
|
for (const file of ['workers/policy.py', 'workers/dataset.py', 'workers/train.py', 'workers/infer.py', 'workers/observations.py', 'workers/learning.py',
|
||||||
|
'capabilities.js', 'errors.js', 'storage.js', 'runtime.js', 'models.js', 'driving.js', 'recording.js', 'training.js', 'socketGateway.js', 'diagnostics.js']) {
|
||||||
|
hash.update(fs.readFileSync(path.join(__dirname, file)));
|
||||||
|
}
|
||||||
|
const implementationSha256 = hash.digest('hex');
|
||||||
|
|
||||||
|
function errorCategory(value) {
|
||||||
|
if (!value) return null;
|
||||||
|
const text = String(value).toLowerCase();
|
||||||
|
for (const [pattern, category] of [
|
||||||
|
[/timestamp|pts|clock|aligned/, 'time_alignment'], [/sensor/, 'sensors'],
|
||||||
|
[/video|decode|ffmpeg|rtsp/, 'video'], [/permission|turn|control permission/, 'permission'],
|
||||||
|
[/schema|shape|version/, 'model_schema'], [/space|disk|enospc/, 'storage'],
|
||||||
|
[/sample|window|command/, 'dataset_or_command'], [/worker|exited|process/, 'worker'],
|
||||||
|
]) if (pattern.test(text)) return category;
|
||||||
|
return 'unclassified';
|
||||||
|
}
|
||||||
|
|
||||||
|
function report(state, controller) {
|
||||||
|
const training = state.training || {};
|
||||||
|
const metrics = training.diagnostics || {};
|
||||||
|
const session = state.session;
|
||||||
|
return {
|
||||||
|
reportVersion: 3, generatedAtUnixMs: state.updatedAt,
|
||||||
|
software: { applicationVersion, implementationSha256, policyVersion: POLICY_VERSION, node: process.version,
|
||||||
|
dependencies: metrics.dependencies || null },
|
||||||
|
host: { logicalCpus: os.cpus().length, totalMemoryBytes: os.totalmem(), freeMemoryBytes: os.freemem(), loadAverage: os.loadavg() },
|
||||||
|
settings: state.limits,
|
||||||
|
recording: { paused: state.recording.paused, bytes: state.recording.bytes,
|
||||||
|
freeBytes: state.recording.freeBytes, pendingBytes: state.recording.pendingBytes,
|
||||||
|
droppedPending: state.recording.droppedPending,
|
||||||
|
sessions: state.recording.sessions.map((item) => ({ startedAtUnixMs: item.startedAt,
|
||||||
|
status: item.video, droppedEvents: item.droppedEvents, error: redact(item.error), failureDetails: item.failureDetails || null, stderr: redact(item.stderr), errorCategory: errorCategory(item.error) })) },
|
||||||
|
training: { status: training.status, steps: training.steps, examples: training.examples,
|
||||||
|
selection: training.selection || null, evaluatedSteps: training.evaluatedSteps ?? null,
|
||||||
|
loss: training.loss, losses: training.losses || null, publication: training.publication || null,
|
||||||
|
trainingAudit: metrics.trainingAudit || null,
|
||||||
|
evaluation: training.evaluation || null, dataset: metrics.dataset || training.dataset || null,
|
||||||
|
resources: { decodeSeconds: metrics.decodeSeconds ?? null, trainingSeconds: metrics.trainingSeconds ?? null,
|
||||||
|
evaluationSeconds: metrics.evaluationSeconds ?? null, probeSeconds: metrics.probeSeconds ?? null, beforeAuditSeconds: metrics.beforeAuditSeconds ?? null, checkpointSeconds: metrics.checkpointSeconds ?? null, cpuSeconds: metrics.cpuSeconds ?? null,
|
||||||
|
peakRssMiB: metrics.peakRssMiB ?? null },
|
||||||
|
history: training.history || [], rejectionReasons: (training.rejectionReasons || []).map(redact), rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
|
||||||
|
error: redact(training.error), errorCategory: errorCategory(training.error), nextAttemptAtUnixMs: training.nextAttemptAt },
|
||||||
|
model: session ? { id: session.modelId, checkpointId: session.checkpointId, threshold: session.threshold, appliedThreshold: session.appliedThreshold } : null,
|
||||||
|
controller: controller ? { modelId: controller.modelId, sensorPresent: controller.sensorPresent || null,
|
||||||
|
stopReason: redact(controller.stopReason), stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
|
||||||
|
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
|
||||||
|
command: submission.command, result: redact(submission.result) })) })) } : null,
|
||||||
|
sensorFields: metrics.policy?.sensorFields || null,
|
||||||
|
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
|
||||||
|
events: state.activity.map(({ at, message, detail, error, stderr }) => ({ atUnixMs: at, message: redact(message), detail: redact(detail), error: error || null, stderr: redact(stderr) })),
|
||||||
|
unavailable: ['browser_video_latency', 'actual_physical_command_execution', 'live_training_cpu_utilization'],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
module.exports = { report };
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
// Autonomy owns a live user's control context, never an administrative identity.
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { isVerified, isDeterred } = require('../verificationService');
|
||||||
|
const { isAdmin } = require('../roleService');
|
||||||
|
const { submitCommand, issueCommand, subscribeCommandRecording } = require('../commandService');
|
||||||
|
const { recordCommandRequest } = require('../commandService/recording');
|
||||||
|
const { acquireModel } = require('./models');
|
||||||
|
const { compatible } = require('./capabilities');
|
||||||
|
|
||||||
|
const OPERATING = new Set(['drive', 'servo', 'headlight', 'raw']);
|
||||||
|
const MODE_COMMANDS = new Set([128, 131, 132, 143].map((opcode) => Buffer.from([opcode]).toString('base64')));
|
||||||
|
const clamp = (value, min, max) => Math.max(min, Math.min(max, Number(value) || 0));
|
||||||
|
|
||||||
|
function canControl(socket, roverId) {
|
||||||
|
return Boolean(socket?.connected && isVerified(socket) && (isAdmin(socket) || !isDeterred(socket))
|
||||||
|
&& roverManager.rovers.get(roverId)?.ws && roverManager.canDrive(roverId, socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDriving({ root, config, onChange, activity }) {
|
||||||
|
const sessions = new Map();
|
||||||
|
const recent = new Map();
|
||||||
|
let stopping = false;
|
||||||
|
let watchdog;
|
||||||
|
let unsubscribe;
|
||||||
|
|
||||||
|
function sendState(session, event) {
|
||||||
|
const input = session.worker.stdin;
|
||||||
|
if (input.destroyed || !input.writable || input.writableLength > 256 * 1024) return;
|
||||||
|
input.write(JSON.stringify(event) + '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function transmit(session, command) {
|
||||||
|
if (session.status === 'stopping') return;
|
||||||
|
if (!canControl(session.socket, session.roverId)) { stop(session.roverId, 'Control permission lost'); return; }
|
||||||
|
if (!OPERATING.has(command?.type)) throw new Error('Unsupported model command');
|
||||||
|
if (command.type === 'raw' && !MODE_COMMANDS.has(command.raw)) throw new Error('Unsupported model operating-mode command');
|
||||||
|
const record = roverManager.rovers.get(session.roverId);
|
||||||
|
if (command.type === 'drive') {
|
||||||
|
const limit = Math.min(500, Number(record.meta?.maxWheelSpeed) || 500);
|
||||||
|
command.driveDirect = { left: Math.round(clamp(command.driveDirect?.left, -limit, limit)), right: Math.round(clamp(command.driveDirect?.right, -limit, limit)) };
|
||||||
|
}
|
||||||
|
if (command.type === 'servo') {
|
||||||
|
const servo = record.meta?.cameraServo;
|
||||||
|
if (!servo?.enabled) { session.submissions.push({ command, result: 'Servo unavailable' }); return; }
|
||||||
|
command.servo.angle = clamp(command.servo?.angle, servo.minAngle ?? 0, servo.maxAngle ?? 180);
|
||||||
|
}
|
||||||
|
const { type, ...data } = command;
|
||||||
|
recordCommandRequest(session.socket, { roverId: session.roverId, type, data }, (result) => {
|
||||||
|
session.lastResult = result.error || result.reason || 'issued';
|
||||||
|
session.submissions.push({ command, result: session.lastResult });
|
||||||
|
if (result.id) session.lastAction = { at: Date.now(), command };
|
||||||
|
}, (request, reply) => submitCommand(session.socket, request, reply), 'model');
|
||||||
|
}
|
||||||
|
|
||||||
|
function stop(roverId, reason = 'Stopped by user') {
|
||||||
|
const session = sessions.get(roverId);
|
||||||
|
if (!session) return Promise.resolve();
|
||||||
|
if (session.done) return session.done;
|
||||||
|
session.status = 'stopping';
|
||||||
|
session.stopReason = reason;
|
||||||
|
// A stop must still reach the rover after turn permission is revoked.
|
||||||
|
for (const command of [
|
||||||
|
{ type: 'drive', driveDirect: { left: 0, right: 0 } },
|
||||||
|
]) { try { issueCommand(roverId, command); } catch { /* Rover may already be offline. */ } }
|
||||||
|
session.worker.kill('SIGTERM');
|
||||||
|
const timer = setTimeout(() => session.worker.kill('SIGKILL'), 2000);
|
||||||
|
session.done = session.closed.then(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
recent.delete(roverId);
|
||||||
|
recent.set(roverId, { modelId: session.modelId, stopReason: reason, history: session.history });
|
||||||
|
if (recent.size > 20) recent.delete(recent.keys().next().value);
|
||||||
|
sessions.delete(roverId);
|
||||||
|
activity('Controller stopped', { roverId, reason });
|
||||||
|
onChange();
|
||||||
|
});
|
||||||
|
onChange();
|
||||||
|
return session.done;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(socket, roverId, modelId) {
|
||||||
|
if (stopping || !canControl(socket, roverId)) throw new Error('You cannot control this rover');
|
||||||
|
const acquired = await acquireModel(root, modelId);
|
||||||
|
const { directory, metadata } = acquired;
|
||||||
|
try {
|
||||||
|
if (!compatible(metadata, roverManager.rovers.get(roverId))) throw new Error('Model is not compatible with this rover');
|
||||||
|
if (stopping || !canControl(socket, roverId)) throw new Error('Control permission changed');
|
||||||
|
if (sessions.has(roverId)) throw new Error('Stop the current controller before starting another model');
|
||||||
|
} catch (error) { await acquired.release(); throw error; }
|
||||||
|
const worker = spawn(config.training.python, [path.join(__dirname, 'workers/infer.py'), '--model', directory,
|
||||||
|
'--url', `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId)}`, '--threads', String(config.driving.threads),
|
||||||
|
'--threshold', String(config.driving.actionThreshold)], { stdio: ['pipe', 'pipe', 'pipe'], env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1' } });
|
||||||
|
const session = { worker, socket, roverId, modelId, checkpointId: metadata.id, modelName: modelId === 'latest' ? 'Latest' : modelId === 'previous' ? 'Previous' : metadata.name, status: 'starting', startedAt: Date.now(), lastPredictionAt: null, threshold: config.driving.actionThreshold, history: [] };
|
||||||
|
session.closed = new Promise((resolve) => worker.once('close', resolve));
|
||||||
|
session.closed.then(() => acquired.release()).catch((error) => activity('Checkpoint release failed', { error: error.message }));
|
||||||
|
sessions.set(roverId, session);
|
||||||
|
recent.delete(roverId);
|
||||||
|
let output = '';
|
||||||
|
let stderr = '';
|
||||||
|
worker.stdin.on('error', () => { stop(roverId, 'Worker input closed'); });
|
||||||
|
worker.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-1000); });
|
||||||
|
worker.on('error', (error) => { stop(roverId, error.message); });
|
||||||
|
worker.once('close', () => { stop(roverId, stderr || 'Worker exited'); });
|
||||||
|
worker.stdout.on('data', (chunk) => {
|
||||||
|
if (session.status === 'stopping') return;
|
||||||
|
output += chunk;
|
||||||
|
if (output.length > 1024 * 1024) { stop(roverId, 'Worker output exceeded limit'); return; }
|
||||||
|
let newline;
|
||||||
|
while ((newline = output.indexOf('\n')) >= 0) {
|
||||||
|
const line = output.slice(0, newline);
|
||||||
|
output = output.slice(newline + 1);
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
if (message.kind === 'error') throw new Error(message.message);
|
||||||
|
if (message.kind !== 'prediction') continue;
|
||||||
|
if (!Number.isFinite(message.frameAt) || Math.abs(Date.now() - message.frameAt) > config.driving.staleMs) throw new Error('Video is stale');
|
||||||
|
if (!Number.isFinite(message.sensorAt) || Math.abs(Date.now() - message.sensorAt) > config.driving.staleMs) throw new Error('Sensors are stale');
|
||||||
|
if (!canControl(socket, roverId)) throw new Error('Control permission lost');
|
||||||
|
session.status = 'running';
|
||||||
|
session.lastPredictionAt = Date.now();
|
||||||
|
session.frameAt = message.frameAt;
|
||||||
|
session.sensorAt = message.sensorAt;
|
||||||
|
session.latencyMs = message.latencyMs;
|
||||||
|
session.proposals = Array.isArray(message.proposals) ? message.proposals.slice(0, 64) : [];
|
||||||
|
session.appliedThreshold = message.threshold;
|
||||||
|
session.sensorPresent = message.sensorPresent;
|
||||||
|
session.submissions = [];
|
||||||
|
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
|
||||||
|
session.predictionStatus = message.commands.length ? `${message.commands.length} command(s) proposed` : 'No command proposed';
|
||||||
|
// Manual commands intentionally do not stop or pause this controller.
|
||||||
|
for (const command of message.commands) transmit(session, command);
|
||||||
|
// Bound report history independently of camera and UI frame rates.
|
||||||
|
if (!session.history.length || Date.now() - session.history.at(-1).at >= 500) {
|
||||||
|
session.history.push({ at: Date.now(),
|
||||||
|
latencyMs: session.latencyMs, frameAgeMs: Date.now() - session.frameAt,
|
||||||
|
sensorAgeMs: Date.now() - session.sensorAt, threshold: session.appliedThreshold,
|
||||||
|
proposals: [...session.proposals].sort((a, b) => b.score - a.score).slice(0, 8).map(({ slot, score, reason, command }) => ({ slot, score, reason, command })),
|
||||||
|
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result, command })) });
|
||||||
|
if (session.history.length > 60) session.history.shift();
|
||||||
|
}
|
||||||
|
} catch (error) { stop(roverId, error.message); break; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const sensors = roverManager.rovers.get(roverId)?.lastSensor?.decoded;
|
||||||
|
if (sensors) sendState(session, { kind: 'sensor', ts: Date.now(), data: sensors });
|
||||||
|
activity('Controller started', { roverId, model: metadata.name, socketId: socket.id });
|
||||||
|
onChange();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sensor(event) {
|
||||||
|
const session = sessions.get(String(event.roverId));
|
||||||
|
if (session) sendState(session, { kind: 'sensor', ts: Date.now(), data: event.sensors });
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach() {
|
||||||
|
roverManager.managerEvents.on('sensor', sensor);
|
||||||
|
unsubscribe = subscribeCommandRecording((event) => {
|
||||||
|
const session = sessions.get(String(event.roverId));
|
||||||
|
if (session && event.phase === 'dispatched') sendState(session, { kind: 'command', ts: event.ts, data: event.command });
|
||||||
|
});
|
||||||
|
watchdog = setInterval(() => {
|
||||||
|
for (const session of sessions.values()) {
|
||||||
|
if (!canControl(session.socket, session.roverId)) stop(session.roverId, 'Control permission lost');
|
||||||
|
else if (Date.now() - (session.lastPredictionAt || session.startedAt) > (session.lastPredictionAt ? config.driving.staleMs : 30000)) {
|
||||||
|
stop(session.roverId, 'No fresh model predictions');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
attach, start, stop, canControl,
|
||||||
|
setThreshold(socket, roverId, value) {
|
||||||
|
const session = sessions.get(roverId);
|
||||||
|
if (!session || session.status === 'stopping' || !canControl(socket, roverId)) throw new Error('You cannot adjust this controller');
|
||||||
|
if (typeof value !== 'number' || !Number.isFinite(value) || value < .05 || value > .99) throw new Error('Threshold must be between 0.05 and 0.99');
|
||||||
|
session.threshold = value;
|
||||||
|
sendState(session, { kind: 'settings', ts: Date.now(), threshold: value });
|
||||||
|
},
|
||||||
|
getDiagnostics(id) {
|
||||||
|
const session = sessions.get(id);
|
||||||
|
return session ? { modelId: session.modelId, sensorPresent: session.sensorPresent, history: session.history } : recent.get(id) || null;
|
||||||
|
},
|
||||||
|
isActive: (id) => sessions.has(id),
|
||||||
|
getState: (id) => {
|
||||||
|
const session = sessions.get(id);
|
||||||
|
if (!session) return null;
|
||||||
|
const { worker, socket, closed, done, history, ...state } = session;
|
||||||
|
return { ...state, startedBy: socket.data?.nickname || socket.id, socketId: socket.id };
|
||||||
|
},
|
||||||
|
shutdown: async () => {
|
||||||
|
stopping = true;
|
||||||
|
clearInterval(watchdog);
|
||||||
|
unsubscribe?.();
|
||||||
|
roverManager.managerEvents.off('sensor', sensor);
|
||||||
|
await Promise.all([...sessions.keys()].map((id) => stop(id, 'Service disabled')));
|
||||||
|
},
|
||||||
|
kill: () => { for (const session of sessions.values()) session.worker.kill('SIGKILL'); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDriving, canControl };
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// Keep actionable failures while removing credentials and user identifiers.
|
||||||
|
function redact(value) {
|
||||||
|
if (value == null) return null;
|
||||||
|
return String(value).slice(0, 12000)
|
||||||
|
.replace(/\b(?:https?|rtsp|rtsps):\/\/[^\s'"<>]+/gi, '[redacted URL]')
|
||||||
|
.replace(/\b(Bearer\s+)\S+/gi, '$1[redacted]')
|
||||||
|
.replace(/((?:password|token|secret|authorization|cookie|api[_-]?key)\s*[=:]\s*)[^\s,;]+/gi, '$1[redacted]')
|
||||||
|
.replace(/\bcu_[a-z0-9]+/gi, '[redacted user]')
|
||||||
|
.replace(/\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/gi, '[redacted email]')
|
||||||
|
.replace(/\/(?:home|Users)\/[^/\s]+/g, '/[user]');
|
||||||
|
}
|
||||||
|
function errorDetails(error, operation) {
|
||||||
|
return { atUnixMs: Date.now(), operation: redact(operation),
|
||||||
|
name: redact(error?.name), message: redact(error?.message ?? error),
|
||||||
|
code: redact(error?.code), syscall: redact(error?.syscall),
|
||||||
|
path: redact(error?.path), stack: redact(error?.stack) };
|
||||||
|
}
|
||||||
|
module.exports = { redact, errorDetails };
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
// The only resident part while disabled is this configuration lifecycle bridge.
|
||||||
|
const { loadConfig, registerConfigurationHandler } = require('../../configuration');
|
||||||
|
const logger = require('../../globals/logger').child('roverLearning');
|
||||||
|
|
||||||
|
let runtime = null;
|
||||||
|
let transitions = Promise.resolve();
|
||||||
|
let shuttingDown = false;
|
||||||
|
|
||||||
|
function apply(config) {
|
||||||
|
const transition = transitions.then(async () => {
|
||||||
|
if (runtime) {
|
||||||
|
const previous = runtime;
|
||||||
|
runtime = null;
|
||||||
|
await previous.stop();
|
||||||
|
}
|
||||||
|
if (!config?.enabled || shuttingDown) return;
|
||||||
|
const { createRuntime } = require('./runtime');
|
||||||
|
const next = createRuntime({ config, logger });
|
||||||
|
try {
|
||||||
|
await next.start();
|
||||||
|
runtime = next;
|
||||||
|
} catch (error) {
|
||||||
|
await next.stop();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
transitions = transition.catch(() => undefined);
|
||||||
|
return transition;
|
||||||
|
}
|
||||||
|
|
||||||
|
registerConfigurationHandler('roverLearning', apply);
|
||||||
|
apply(loadConfig().roverLearning).catch((error) => {
|
||||||
|
logger.error('Unable to start rover learning recording', { error: error.message });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Signal handlers are installed by the enabled runtime, not by this bridge.
|
||||||
|
module.exports = {
|
||||||
|
stop: () => { shuttingDown = true; return apply({ enabled: false }); },
|
||||||
|
};
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
const { POLICY_VERSION } = require('./capabilities');
|
||||||
|
// Mutable aliases reference immutable checkpoints. All readers and retention share
|
||||||
|
// a queue so a model cannot disappear between resolving its alias and loading it.
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { randomUUID, randomInt } = require('node:crypto');
|
||||||
|
const UUID = /^[0-9a-f-]{36}$/;
|
||||||
|
let queue = Promise.resolve();
|
||||||
|
const leases = new Map();
|
||||||
|
const retired = new Set();
|
||||||
|
function serial(task) {
|
||||||
|
const result = queue.then(task);
|
||||||
|
queue = result.catch(() => {});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
const checkpointRoot = (root) => path.join(root, 'training', 'checkpoints');
|
||||||
|
async function index(root) {
|
||||||
|
try { return JSON.parse(await fs.readFile(path.join(checkpointRoot(root), 'index.json'), 'utf8')); }
|
||||||
|
catch (error) { if (error.code === 'ENOENT') return {}; throw error; }
|
||||||
|
}
|
||||||
|
async function metadata(directory) {
|
||||||
|
const model = JSON.parse(await fs.readFile(path.join(directory, 'model.json'), 'utf8'));
|
||||||
|
if (model?.id !== path.basename(directory)) throw new Error('Model identity mismatch');
|
||||||
|
return model;
|
||||||
|
}
|
||||||
|
async function prune(root, state) {
|
||||||
|
const directory = checkpointRoot(root);
|
||||||
|
await fs.mkdir(directory, { recursive: true });
|
||||||
|
for (const entry of await fs.readdir(directory, { withFileTypes: true })) {
|
||||||
|
if (!entry.isDirectory()) continue;
|
||||||
|
if (/^\.[0-9a-f-]{36}\.pending$/.test(entry.name)) {
|
||||||
|
await fs.rm(path.join(directory, entry.name), { recursive: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (!UUID.test(entry.name)) continue;
|
||||||
|
const target = path.join(directory, entry.name);
|
||||||
|
if (!Object.values(state).includes(entry.name)) {
|
||||||
|
if (leases.has(target)) retired.add(target);
|
||||||
|
else { await fs.rm(target, { recursive: true }); retired.delete(target); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function adoptCheckpoint(root, model, promote) {
|
||||||
|
return serial(async () => {
|
||||||
|
const state = await index(root);
|
||||||
|
if (!UUID.test(model.id)) throw new Error('Invalid checkpoint identity');
|
||||||
|
await metadata(path.join(checkpointRoot(root), model.id));
|
||||||
|
state.candidate = model.id;
|
||||||
|
if (promote) { state.previous = state.latest || null; state.latest = model.id; }
|
||||||
|
const filename = path.join(checkpointRoot(root), 'index.json');
|
||||||
|
await fs.writeFile(`${filename}.tmp`, JSON.stringify(state));
|
||||||
|
await fs.rename(`${filename}.tmp`, filename);
|
||||||
|
await prune(root, state);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function acquireModel(root, id) {
|
||||||
|
return serial(async () => {
|
||||||
|
const state = await index(root);
|
||||||
|
const alias = ['latest', 'previous'].includes(id);
|
||||||
|
const resolved = alias ? state[id] : id;
|
||||||
|
if (!UUID.test(resolved || '')) throw new Error('Model is not available');
|
||||||
|
const directory = path.join(alias ? checkpointRoot(root) : path.join(root, 'models'), resolved);
|
||||||
|
const model = await metadata(directory);
|
||||||
|
leases.set(directory, (leases.get(directory) || 0) + 1);
|
||||||
|
let released = false;
|
||||||
|
return { directory, metadata: model, release: () => serial(async () => {
|
||||||
|
if (released) return;
|
||||||
|
released = true;
|
||||||
|
const count = leases.get(directory) - 1;
|
||||||
|
if (count) leases.set(directory, count); else leases.delete(directory);
|
||||||
|
// Delete only a specific checkpoint already retired by the trainer, not
|
||||||
|
// a worker's new export awaiting adoption.
|
||||||
|
if (!count && retired.has(directory)) {
|
||||||
|
await fs.rm(directory, { recursive: true, force: true });
|
||||||
|
retired.delete(directory);
|
||||||
|
}
|
||||||
|
}) };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
function saveSnapshot(root) {
|
||||||
|
return serial(async () => {
|
||||||
|
const state = await index(root);
|
||||||
|
if (!UUID.test(state.candidate || '')) throw new Error('No completed learner checkpoint yet');
|
||||||
|
const source = path.join(checkpointRoot(root), state.candidate);
|
||||||
|
const original = await metadata(source);
|
||||||
|
if (original.specification?.version !== POLICY_VERSION) throw new Error('Waiting for a completed command-policy checkpoint');
|
||||||
|
const id = randomUUID();
|
||||||
|
const words = [['amber', 'silver', 'quiet', 'curious'], ['otter', 'finch', 'fox', 'wren']];
|
||||||
|
const name = `${words[0][randomInt(4)]}-${words[1][randomInt(4)]}-${id.slice(0, 8)}`;
|
||||||
|
const model = { ...original, id, name, createdAt: Date.now(), sourceCheckpointId: original.id,
|
||||||
|
snapshot: true, experimental: original.evaluation !== 'passed imitation gate' };
|
||||||
|
const destination = path.join(root, 'models', id);
|
||||||
|
const pending = path.join(root, 'models', `.${id}.pending`);
|
||||||
|
await fs.mkdir(pending, { recursive: true });
|
||||||
|
try {
|
||||||
|
await fs.copyFile(path.join(source, 'weights.pt'), path.join(pending, 'weights.pt'));
|
||||||
|
await fs.writeFile(path.join(pending, 'model.json'), JSON.stringify(model));
|
||||||
|
await fs.rename(pending, destination);
|
||||||
|
} catch (error) { await fs.rm(pending, { recursive: true, force: true }); throw error; }
|
||||||
|
return model;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Retire recognized older policies before workers start. Unknown/malformed or
|
||||||
|
// future manifests are not evidence that a directory is disposable.
|
||||||
|
function removeObsoleteModels(root) {
|
||||||
|
return serial(async () => {
|
||||||
|
const obsolete = [];
|
||||||
|
for (const directory of [path.join(root, 'models'), checkpointRoot(root)]) {
|
||||||
|
let entries;
|
||||||
|
try { entries = await fs.readdir(directory, { withFileTypes: true }); }
|
||||||
|
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isDirectory() || !UUID.test(entry.name)) continue;
|
||||||
|
const target = path.join(directory, entry.name);
|
||||||
|
let model;
|
||||||
|
try { model = await metadata(target); }
|
||||||
|
catch (error) {
|
||||||
|
if (error.code === 'ENOENT' || error instanceof SyntaxError || error.message === 'Model identity mismatch') continue;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
const version = model.specification?.version;
|
||||||
|
if (Number.isInteger(version) && version >= 1 && version < POLICY_VERSION) {
|
||||||
|
obsolete.push({ target, checkpoint: directory === checkpointRoot(root), id: entry.name });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const state = await index(root);
|
||||||
|
const oldCheckpoints = new Set(obsolete.filter((entry) => entry.checkpoint).map((entry) => entry.id));
|
||||||
|
let changed = false;
|
||||||
|
for (const alias of ['candidate', 'latest', 'previous']) {
|
||||||
|
if (oldCheckpoints.has(state[alias])) { delete state[alias]; changed = true; }
|
||||||
|
}
|
||||||
|
// Remove aliases atomically first, so interruption cannot leave the catalog
|
||||||
|
// referring to a checkpoint that cleanup already deleted.
|
||||||
|
if (changed) {
|
||||||
|
const filename = path.join(checkpointRoot(root), 'index.json');
|
||||||
|
await fs.writeFile(`${filename}.tmp`, JSON.stringify(state));
|
||||||
|
await fs.rename(`${filename}.tmp`, filename);
|
||||||
|
}
|
||||||
|
for (const { target } of obsolete) {
|
||||||
|
if (leases.has(target)) retired.add(target);
|
||||||
|
else { await fs.rm(target, { recursive: true, force: true }); retired.delete(target); }
|
||||||
|
}
|
||||||
|
// These filenames belong to superseded optimizer/scheduler lineages, not
|
||||||
|
// policy versions. The current command policy uses lineage v6.
|
||||||
|
for (const suffix of ['', '-v1', '-v2', '-v3', '-v4', '-v5']) {
|
||||||
|
for (const filename of [`resume${suffix}.pt`, `resume${suffix}.tmp`,
|
||||||
|
`schedule${suffix}.json`, `schedule${suffix}.json.tmp`]) {
|
||||||
|
await fs.rm(path.join(root, 'training', filename), { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function listModels(root) {
|
||||||
|
return serial(async () => {
|
||||||
|
let entries;
|
||||||
|
try { entries = await fs.readdir(path.join(root, 'models'), { withFileTypes: true }); }
|
||||||
|
catch (error) { if (error.code !== 'ENOENT') throw error; entries = []; }
|
||||||
|
const models = [];
|
||||||
|
const state = await index(root);
|
||||||
|
for (const alias of ['latest', 'previous']) {
|
||||||
|
if (!state[alias]) continue;
|
||||||
|
const model = await metadata(path.join(checkpointRoot(root), state[alias]));
|
||||||
|
models.push({ ...model, id: alias, checkpointId: model.id, name: alias === 'latest' ? 'Latest' : 'Previous', automatic: true });
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.isDirectory() && UUID.test(entry.name)) models.push(await metadata(path.join(root, 'models', entry.name)));
|
||||||
|
}
|
||||||
|
return models;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
module.exports = { removeObsoleteModels, listModels, acquireModel, adoptCheckpoint, saveSnapshot };
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
// One bounded recording session. Video packets retain receiver wall-clock PTS;
|
||||||
|
// telemetry uses the same server clock, not an assumed browser playback clock.
|
||||||
|
const { errorDetails, redact } = require('./errors');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const fsp = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { randomUUID } = require('node:crypto');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { controlProfile } = require('./capabilities');
|
||||||
|
|
||||||
|
const MAX_BUFFER_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
async function saveMetadata(directory, metadata) {
|
||||||
|
const filename = path.join(directory, 'session.json');
|
||||||
|
await fsp.writeFile(`${filename}.tmp`, JSON.stringify(metadata, null, 2));
|
||||||
|
await fsp.rename(`${filename}.tmp`, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRecording({ root, roverId, snapshot, logger, docked = false }) {
|
||||||
|
const id = `${Date.now()}-${randomUUID()}`;
|
||||||
|
const directory = path.join(root, id);
|
||||||
|
await fsp.mkdir(directory);
|
||||||
|
const metadata = {
|
||||||
|
version: 1, id, roverId, startedAt: Date.now(), endedAt: null,
|
||||||
|
controlProfile: controlProfile(snapshot.meta), wheelSpeedLimit: snapshot.meta?.maxWheelSpeed ?? 500,
|
||||||
|
timing: 'Video PTS uses server receiver Unix seconds. Events use Unix milliseconds and process monotonic nanoseconds. Browser display delay is not measured.',
|
||||||
|
video: { file: 'video.mkv', codec: 'source-copy', timestampUnit: 'seconds', origin: 'unix' },
|
||||||
|
events: 'events.ndjson', droppedEvents: 0, bufferOnly: docked,
|
||||||
|
trainingStartedAt: docked ? null : Date.now(),
|
||||||
|
};
|
||||||
|
await saveMetadata(directory, metadata);
|
||||||
|
const output = fs.createWriteStream(path.join(directory, 'events.ndjson'), { flags: 'wx' });
|
||||||
|
let buffered = [];
|
||||||
|
let bufferedBytes = 0;
|
||||||
|
let dockedAt = null;
|
||||||
|
let pruning = Promise.resolve();
|
||||||
|
let failure = null;
|
||||||
|
let failureDetails = null;
|
||||||
|
let stopping = false;
|
||||||
|
let stopPromise;
|
||||||
|
let child;
|
||||||
|
let childClosed = false;
|
||||||
|
let stderr = '';
|
||||||
|
let videoResult = null;
|
||||||
|
let resolveVideo;
|
||||||
|
const videoClosed = new Promise((resolve) => { resolveVideo = resolve; });
|
||||||
|
output.on('error', (error) => {
|
||||||
|
failure = error.message; failureDetails = errorDetails(error, 'recording session');
|
||||||
|
// Stop file growth immediately when telemetry is no longer writable.
|
||||||
|
child?.kill('SIGTERM');
|
||||||
|
});
|
||||||
|
|
||||||
|
function append(kind, data, timestamp = {}) {
|
||||||
|
if (stopping || failure) return false;
|
||||||
|
try {
|
||||||
|
const line = JSON.stringify({
|
||||||
|
ts: Date.now(), monotonicNs: process.hrtime.bigint().toString(), ...timestamp, kind, data,
|
||||||
|
}) + '\n';
|
||||||
|
if (metadata.bufferOnly) {
|
||||||
|
const now = Date.now();
|
||||||
|
buffered.push({ at: now, line });
|
||||||
|
bufferedBytes += Buffer.byteLength(line);
|
||||||
|
while (buffered.length && buffered[0].at < now - 5000) {
|
||||||
|
bufferedBytes -= Buffer.byteLength(buffered.shift().line);
|
||||||
|
}
|
||||||
|
while (bufferedBytes > MAX_BUFFER_BYTES && buffered.length) {
|
||||||
|
metadata.droppedEvents += 1;
|
||||||
|
bufferedBytes -= Buffer.byteLength(buffered.shift().line);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// Never wait for disk from a control/sensor callback. Explicit loss counts
|
||||||
|
// prevent an overloaded recorder from pretending its history is complete.
|
||||||
|
if (output.writableLength + Buffer.byteLength(line) > MAX_BUFFER_BYTES) {
|
||||||
|
metadata.droppedEvents += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
output.write(line);
|
||||||
|
} catch (error) {
|
||||||
|
metadata.droppedEvents += 1;
|
||||||
|
failure = error.message; failureDetails = errorDetails(error, 'recording session');
|
||||||
|
child?.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
append('snapshot', snapshot);
|
||||||
|
// Read the local published stream, as replay does, without depending on the
|
||||||
|
// replay service or re-encoding camera video. No audio/chat is captured.
|
||||||
|
child = spawn(process.env.FFMPEG_BIN || 'ffmpeg', [
|
||||||
|
'-hide_banner', '-loglevel', 'warning', '-nostdin', '-n',
|
||||||
|
'-rtsp_transport', 'tcp', '-timeout', '10000000',
|
||||||
|
'-use_wallclock_as_timestamps', '1', '-copyts',
|
||||||
|
'-i', `rtsp://127.0.0.1:8554/${encodeURIComponent(roverId)}`,
|
||||||
|
'-map', '0:v:0', '-an', '-c:v', 'copy', '-avoid_negative_ts', 'disabled',
|
||||||
|
// Keyframe-aligned chunks keep a disposable dock buffer without repeatedly
|
||||||
|
// reconnecting to RTSP. Extra edge packets are filtered by trainingStartedAt.
|
||||||
|
'-f', 'segment', '-segment_time', '2', '-segment_format', 'matroska',
|
||||||
|
'-reset_timestamps', '0', path.join(directory, 'video-%09d.mkv'),
|
||||||
|
], { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||||
|
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk.toString()).slice(-4096); });
|
||||||
|
child.on('error', (error) => { failure = error.message; failureDetails = errorDetails(error, 'recording session'); });
|
||||||
|
child.once('close', (code, signal) => {
|
||||||
|
childClosed = true;
|
||||||
|
videoResult = { code, signal, stderr };
|
||||||
|
if (!stopping) failure ||= `Video recorder exited (${code ?? signal})`;
|
||||||
|
resolveVideo();
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateDock(isDocked) {
|
||||||
|
if (stopping) return;
|
||||||
|
if (!isDocked) {
|
||||||
|
dockedAt = null;
|
||||||
|
if (metadata.bufferOnly) {
|
||||||
|
metadata.bufferOnly = false;
|
||||||
|
metadata.trainingStartedAt = Date.now() - 5000;
|
||||||
|
for (const item of buffered) output.write(item.line);
|
||||||
|
buffered = [];
|
||||||
|
bufferedBytes = 0;
|
||||||
|
}
|
||||||
|
} else if (!metadata.bufferOnly && dockedAt == null) dockedAt = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pruneBuffer() {
|
||||||
|
pruning = pruning.then(async () => {
|
||||||
|
if (!metadata.bufferOnly || stopping) return;
|
||||||
|
const files = (await fsp.readdir(directory)).filter((name) => /^video-\d+\.mkv$/.test(name)).sort();
|
||||||
|
// Never unlink the open segment; retain five seconds plus the preceding
|
||||||
|
// keyframe segment so the decoder can recover the full lead-in.
|
||||||
|
for (const name of files.slice(0, -2)) {
|
||||||
|
const filename = path.join(directory, name);
|
||||||
|
const stat = await fsp.stat(filename);
|
||||||
|
if (!metadata.bufferOnly || stopping) return;
|
||||||
|
if (stat.mtimeMs < Date.now() - 5000) await fsp.unlink(filename);
|
||||||
|
}
|
||||||
|
}).catch((error) => { failure = error.message; failureDetails = errorDetails(error, 'recording session'); child.kill('SIGTERM'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(reason) {
|
||||||
|
if (stopPromise) return stopPromise;
|
||||||
|
stopPromise = (async () => {
|
||||||
|
append('sessionEnd', { reason, droppedEvents: metadata.droppedEvents });
|
||||||
|
stopping = true;
|
||||||
|
if (!childClosed) child.kill('SIGTERM');
|
||||||
|
// A stuck RTSP read must not leave a recorder behind after disable.
|
||||||
|
const killTimer = setTimeout(() => { if (!childClosed) child.kill('SIGKILL'); }, 2000);
|
||||||
|
await videoClosed;
|
||||||
|
await pruning;
|
||||||
|
clearTimeout(killTimer);
|
||||||
|
await new Promise((resolve) => {
|
||||||
|
if (output.closed) return resolve();
|
||||||
|
output.once('close', resolve);
|
||||||
|
output.end();
|
||||||
|
});
|
||||||
|
if (metadata.bufferOnly) {
|
||||||
|
await fsp.rm(directory, { recursive: true, force: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
metadata.video.files = (await fsp.readdir(directory)).filter((name) => /^video-\d+\.mkv$/.test(name)).sort();
|
||||||
|
metadata.endedAt = Date.now();
|
||||||
|
metadata.reason = reason;
|
||||||
|
metadata.error = failure;
|
||||||
|
metadata.failureDetails = failureDetails;
|
||||||
|
metadata.video.result = videoResult;
|
||||||
|
await saveMetadata(directory, metadata);
|
||||||
|
if (failure) logger.warn('Recording session incomplete', { roverId, id, error: failure, failure: failureDetails, stderr });
|
||||||
|
})();
|
||||||
|
return stopPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id, directory, startedAt: metadata.startedAt, append, stop, updateDock, pruneBuffer,
|
||||||
|
get buffered() { return metadata.bufferOnly; },
|
||||||
|
get dockTailComplete() { return dockedAt != null && Date.now() - dockedAt >= 5000; },
|
||||||
|
get trainingStartedAt() { return metadata.trainingStartedAt; },
|
||||||
|
get failed() { return Boolean(failure); },
|
||||||
|
get stopping() { return stopping; },
|
||||||
|
getStatus: () => ({ id, roverId, startedAt: metadata.startedAt, droppedEvents: metadata.droppedEvents,
|
||||||
|
video: childClosed ? 'stopped' : metadata.bufferOnly ? 'Dock lead-in buffer' : 'Recording human driving', error: redact(failure), failureDetails, stderr: redact(stderr), stopping }),
|
||||||
|
// Process exit is synchronous; normal disable uses stop() and awaits close.
|
||||||
|
kill: () => { if (!childClosed) child.kill('SIGKILL'); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRecording, saveMetadata };
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
// Enabled-only recording, training, controller, and card lifecycle.
|
||||||
|
const { redact, errorDetails } = require('./errors');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const { turnEvents } = require('../turnService');
|
||||||
|
const { modeEvents } = require('../modeManager');
|
||||||
|
const { isAdmin, roleEvents } = require('../roleService');
|
||||||
|
const { isDeterred, verificationEvents } = require('../verificationService');
|
||||||
|
const { subscribeCommandRecording, commandEvents } = require('../commandService');
|
||||||
|
const { createRecording } = require('./recording');
|
||||||
|
const { recoverRecordings, enforceRetention } = require('./storage');
|
||||||
|
const { createTrainer } = require('./training');
|
||||||
|
const { createDriving } = require('./driving');
|
||||||
|
const { removeObsoleteModels } = require('./models');
|
||||||
|
const { createGateway } = require('./socketGateway');
|
||||||
|
|
||||||
|
function createRuntime({ config, logger }) {
|
||||||
|
const root = resolveDataPath('rover-learning');
|
||||||
|
const recordingsRoot = path.join(root, 'recordings');
|
||||||
|
const recentActivity = [];
|
||||||
|
function activity(message, details = {}) {
|
||||||
|
recentActivity.push({ at: Date.now(), message, roverId: details.roverId || null,
|
||||||
|
detail: redact(details.reason || details.error || details.model),
|
||||||
|
error: details.failure || (details.error ? errorDetails(details.error, message) : null),
|
||||||
|
stderr: redact(details.stderr) });
|
||||||
|
if (recentActivity.length > 40) recentActivity.shift();
|
||||||
|
}
|
||||||
|
const baseLogger = logger;
|
||||||
|
logger = Object.fromEntries(['info', 'warn', 'error'].map((level) => [level, (message, details) => {
|
||||||
|
baseLogger[level](message, details); activity(message, details);
|
||||||
|
}]));
|
||||||
|
const trainer = createTrainer({ root, config: config.training, actionThreshold: config.driving.actionThreshold, logger });
|
||||||
|
const driving = createDriving({ root, config, onChange: refreshEligibility, activity });
|
||||||
|
let gateway;
|
||||||
|
let dockTimer;
|
||||||
|
let diskState = { bytes: null, free: null };
|
||||||
|
const sessions = new Map();
|
||||||
|
const unsubscribers = [];
|
||||||
|
let running = false;
|
||||||
|
let timer;
|
||||||
|
let maintenance = Promise.resolve();
|
||||||
|
let stopPromise;
|
||||||
|
let diskPaused = false;
|
||||||
|
let busy = false;
|
||||||
|
let wakeRequested = false;
|
||||||
|
let pending = [];
|
||||||
|
let pendingBytes = 0;
|
||||||
|
let droppedPending = 0;
|
||||||
|
let eligibleRovers = new Set();
|
||||||
|
|
||||||
|
function hasHumanController(roverId) {
|
||||||
|
// Even manual inputs during autonomy remain excluded: model-influenced
|
||||||
|
// video must not silently become human demonstration training data.
|
||||||
|
if (driving.isActive(roverId)) return false;
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record?.ws) return false;
|
||||||
|
// Admin permission alone must not record the whole fleet. Require actual
|
||||||
|
// driver membership, a live browser socket, and current control eligibility.
|
||||||
|
for (const socketId of record.drivers || []) {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
if (socket?.connected && (isAdmin(socket) || !isDeterred(socket))
|
||||||
|
&& roverManager.canDrive(roverId, socket)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentEligibleRovers() {
|
||||||
|
return new Set([...roverManager.rovers.keys()].filter(hasHumanController));
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshEligibility() {
|
||||||
|
const next = currentEligibleRovers();
|
||||||
|
const changed = next.size !== eligibleRovers.size || [...next].some((id) => !eligibleRovers.has(id));
|
||||||
|
eligibleRovers = next;
|
||||||
|
if (!changed) return;
|
||||||
|
// Stop the stream immediately even if disk maintenance is in progress.
|
||||||
|
// The serialized maintenance pass still owns closing/removing the session.
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
if (!next.has(id)) session.stop('no-human-controller').catch((error) => {
|
||||||
|
logger.warn('Unable to finalize unattended recording', { roverId: id, error: error.message, failure: errorDetails(error, 'rover learning runtime') });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
|
||||||
|
function append(kind, event) {
|
||||||
|
if (!running || event?.roverId == null) return;
|
||||||
|
const roverId = String(event.roverId);
|
||||||
|
if (!hasHumanController(roverId)) return;
|
||||||
|
const timestamp = { ts: Date.now(), monotonicNs: process.hrtime.bigint().toString() };
|
||||||
|
if (sessions.get(roverId)?.append(kind, event, timestamp)) return;
|
||||||
|
if (diskPaused || !roverManager.rovers.has(roverId)) return;
|
||||||
|
// Session creation/rotation is asynchronous. Keep a bounded snapshot of
|
||||||
|
// events arriving during that gap, with their original observation times.
|
||||||
|
try {
|
||||||
|
const serialized = JSON.stringify(event);
|
||||||
|
const bytes = Buffer.byteLength(serialized);
|
||||||
|
if (pendingBytes + bytes > 4 * 1024 * 1024) {
|
||||||
|
droppedPending += 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pending.push({ roverId, kind, serialized, timestamp, bytes });
|
||||||
|
pendingBytes += bytes;
|
||||||
|
} catch { droppedPending += 1; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(emitter, name, handler) {
|
||||||
|
emitter.on(name, handler);
|
||||||
|
unsubscribers.push(() => emitter.off(name, handler));
|
||||||
|
}
|
||||||
|
|
||||||
|
function snapshot(record) {
|
||||||
|
// Do not serialize the live record: it contains sockets and circular state.
|
||||||
|
return {
|
||||||
|
meta: record.meta, sensors: record.lastSensor, hostStats: record.lastHostStats,
|
||||||
|
batteryState: record.batteryState, headlightState: record.headlightState,
|
||||||
|
laserState: record.laserState, drivers: [...(record.drivers || [])],
|
||||||
|
locked: record.locked, privateOpen: record.privateOpen,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closeSession(roverId, reason) {
|
||||||
|
const session = sessions.get(roverId);
|
||||||
|
if (!session) return;
|
||||||
|
try {
|
||||||
|
await session.stop(reason);
|
||||||
|
} finally {
|
||||||
|
// Keep closing children reachable by the synchronous process-exit hook.
|
||||||
|
sessions.delete(roverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function maintain() {
|
||||||
|
if (!running) return;
|
||||||
|
pending = pending.filter((event) => hasHumanController(event.roverId));
|
||||||
|
pendingBytes = pending.reduce((sum, event) => sum + event.bytes, 0);
|
||||||
|
for (const [roverId, session] of sessions) {
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!hasHumanController(roverId) || session.stopping || session.failed || session.dockTailComplete || (!session.buffered && Date.now() - session.trainingStartedAt >= config.recording.sessionSeconds * 1000)) {
|
||||||
|
await closeSession(roverId, !record?.ws ? 'offline' : !hasHumanController(roverId) ? 'no-human-controller' : session.failed ? 'recorder-failed' : session.dockTailComplete ? 'docked' : 'rotation');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!running) return;
|
||||||
|
const retention = await enforceRetention(
|
||||||
|
recordingsRoot, new Set([...sessions.values()].map((session) => session.id).concat([...trainer.leases])),
|
||||||
|
config.recording.maxGiB * 1024 ** 3, config.recording.minimumFreeGiB * 1024 ** 3,
|
||||||
|
);
|
||||||
|
diskState = retention;
|
||||||
|
if (retention.paused) {
|
||||||
|
if (!diskPaused) logger.warn('Recording paused for disk budget', retention);
|
||||||
|
diskPaused = true;
|
||||||
|
pending = [];
|
||||||
|
pendingBytes = 0;
|
||||||
|
await trainer.cancel();
|
||||||
|
// Close active files to make them eligible for pruning on the next tick.
|
||||||
|
await Promise.all([...sessions.keys()].map((id) => closeSession(id, 'disk-budget')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (diskPaused) logger.info('Recording resumed after disk budget recovery');
|
||||||
|
diskPaused = false;
|
||||||
|
for (const [key, record] of roverManager.rovers) {
|
||||||
|
if (!running) break;
|
||||||
|
const roverId = String(key);
|
||||||
|
if (!hasHumanController(roverId) || sessions.has(roverId)) continue;
|
||||||
|
const session = await createRecording({ root: recordingsRoot, roverId, snapshot: snapshot(record), logger, docked: Boolean(record.lastSensor?.decoded?.chargingSources?.homeBase) });
|
||||||
|
sessions.set(roverId, session);
|
||||||
|
// Filesystem awaits can span a disconnect or turn change.
|
||||||
|
if (!running || !hasHumanController(roverId)) {
|
||||||
|
await closeSession(roverId, running ? 'no-human-controller' : 'disabled');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (droppedPending) {
|
||||||
|
session.append('captureGap', { droppedEventsAcrossRovers: droppedPending });
|
||||||
|
droppedPending = 0;
|
||||||
|
}
|
||||||
|
pending = pending.filter((event) => {
|
||||||
|
if (event.roverId !== roverId) return true;
|
||||||
|
session.append(event.kind, JSON.parse(event.serialized), event.timestamp);
|
||||||
|
pendingBytes -= event.bytes;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (running) {
|
||||||
|
try {
|
||||||
|
await trainer.tick(new Set([...sessions.values()].map((session) => session.id)));
|
||||||
|
} catch (error) {
|
||||||
|
// Training availability must not tear down otherwise healthy capture.
|
||||||
|
logger.error('Unable to schedule training', { error: error.message, failure: errorDetails(error, 'rover learning runtime') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (!running) return;
|
||||||
|
if (busy) { wakeRequested = true; return; }
|
||||||
|
clearTimeout(timer);
|
||||||
|
busy = true;
|
||||||
|
// The next tick is scheduled after completion: disk slowness cannot build
|
||||||
|
// an unbounded backlog of retention tasks or overlapping recorder starts.
|
||||||
|
maintenance = maintain().catch(async (error) => {
|
||||||
|
logger.error('Recording maintenance failed; closing recorders', { error: error.message, failure: errorDetails(error, 'recording maintenance') });
|
||||||
|
await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'storage-error')));
|
||||||
|
}).finally(() => {
|
||||||
|
busy = false;
|
||||||
|
if (running) timer = setTimeout(schedule, wakeRequested ? 0 : 15000);
|
||||||
|
wakeRequested = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function killChildren() {
|
||||||
|
driving.kill();
|
||||||
|
trainer.kill();
|
||||||
|
for (const session of sessions.values()) session.kill();
|
||||||
|
}
|
||||||
|
function onSignal() {
|
||||||
|
// Other server services can exit the process before asynchronous cleanup
|
||||||
|
// finishes. Signal children immediately, and keep an exit fallback too.
|
||||||
|
for (const session of sessions.values()) session.stop('server-shutdown').catch(() => undefined);
|
||||||
|
stop().catch((error) => logger.warn('Recording shutdown failed', { error: error.message, failure: errorDetails(error, 'rover learning runtime') }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
await fs.mkdir(recordingsRoot, { recursive: true });
|
||||||
|
await recoverRecordings(recordingsRoot);
|
||||||
|
await removeObsoleteModels(root);
|
||||||
|
await trainer.start();
|
||||||
|
running = true;
|
||||||
|
driving.attach();
|
||||||
|
dockTimer = setInterval(() => {
|
||||||
|
for (const [id, session] of sessions) {
|
||||||
|
session.updateDock(Boolean(roverManager.rovers.get(id)?.lastSensor?.decoded?.chargingSources?.homeBase));
|
||||||
|
session.pruneBuffer();
|
||||||
|
if (session.dockTailComplete) schedule();
|
||||||
|
}
|
||||||
|
}, 250);
|
||||||
|
gateway = createGateway({ root, driving, activity, getSystemState: () => ({
|
||||||
|
recording: { paused: diskPaused, bytes: diskState.bytes, freeBytes: diskState.free,
|
||||||
|
pendingBytes, droppedPending, sessions: [...sessions.values()].map((session) => session.getStatus()) },
|
||||||
|
training: trainer.getState(),
|
||||||
|
limits: { recording: config.recording, training: { ...config.training, python: undefined }, driving: config.driving },
|
||||||
|
activity: [...recentActivity].reverse(),
|
||||||
|
}) });
|
||||||
|
eligibleRovers = currentEligibleRovers();
|
||||||
|
unsubscribers.push(subscribeCommandRecording((event) => append('command', event)));
|
||||||
|
// ACK analytics also provide latency/type context; detailed acknowledgements
|
||||||
|
// arrive through the recording hook and share the command ID.
|
||||||
|
listen(commandEvents, 'observation', (event) => append('commandOutcome', event));
|
||||||
|
listen(roverManager.managerEvents, 'sensor', (event) => append('sensor', {
|
||||||
|
...event,
|
||||||
|
// The manager stores the exact frame before emitting this synchronous
|
||||||
|
// event, so raw capture needs no extra hook in rover sensor processing.
|
||||||
|
frame: roverManager.rovers.get(event.roverId)?.lastSensor?.raw,
|
||||||
|
}));
|
||||||
|
for (const kind of ['hostStats', 'driver', 'switch', 'lock', 'private', 'privateSafety', 'help', 'dockGuard']) {
|
||||||
|
listen(roverManager.managerEvents, kind, (event) => {
|
||||||
|
append(kind, event);
|
||||||
|
if (['driver', 'switch', 'lock', 'private', 'privateSafety'].includes(kind)) refreshEligibility();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const kind of ['activeDriver', 'queue']) {
|
||||||
|
listen(turnEvents, kind, (event) => { append(`turn:${kind}`, event); refreshEligibility(); });
|
||||||
|
}
|
||||||
|
listen(modeEvents, 'change', refreshEligibility);
|
||||||
|
listen(roleEvents, 'change', refreshEligibility);
|
||||||
|
listen(verificationEvents, 'change', refreshEligibility);
|
||||||
|
listen(roverManager.managerEvents, 'rover', (event) => {
|
||||||
|
append('rover', { roverId: event.roverId, action: event.action, snapshot: event.record ? snapshot(event.record) : null });
|
||||||
|
if (event.action === 'upsert' || event.action === 'removed') refreshEligibility();
|
||||||
|
});
|
||||||
|
process.on('SIGTERM', onSignal);
|
||||||
|
process.on('SIGINT', onSignal);
|
||||||
|
process.on('exit', killChildren);
|
||||||
|
busy = true;
|
||||||
|
maintenance = maintain();
|
||||||
|
try {
|
||||||
|
await maintenance;
|
||||||
|
} finally {
|
||||||
|
busy = false;
|
||||||
|
}
|
||||||
|
if (running) timer = setTimeout(schedule, wakeRequested ? 0 : 15000);
|
||||||
|
wakeRequested = false;
|
||||||
|
logger.info('Rover learning enabled', { root, training: config.training.enabled });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop() {
|
||||||
|
if (stopPromise) return stopPromise;
|
||||||
|
running = false;
|
||||||
|
gateway?.close();
|
||||||
|
clearTimeout(timer);
|
||||||
|
clearInterval(dockTimer);
|
||||||
|
for (const unsubscribe of unsubscribers.splice(0)) unsubscribe();
|
||||||
|
stopPromise = (async () => {
|
||||||
|
const trainingStop = trainer.stop();
|
||||||
|
const drivingStop = driving.shutdown();
|
||||||
|
await maintenance.catch(() => undefined);
|
||||||
|
const results = await Promise.allSettled([...sessions.keys()].map((id) => closeSession(id, 'disabled')));
|
||||||
|
await trainingStop;
|
||||||
|
await drivingStop;
|
||||||
|
process.off('SIGTERM', onSignal);
|
||||||
|
process.off('SIGINT', onSignal);
|
||||||
|
process.off('exit', killChildren);
|
||||||
|
pending = [];
|
||||||
|
pendingBytes = 0;
|
||||||
|
const failed = results.find((result) => result.status === 'rejected');
|
||||||
|
if (failed) throw failed.reason;
|
||||||
|
})();
|
||||||
|
return stopPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { start, stop, getTrainingState: trainer.getState };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createRuntime };
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Complete card snapshots; live diagnostics opt into 10 Hz, ordinary cards use 1 Hz.
|
||||||
|
const io = require('../../globals/io');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const { isVerified } = require('../verificationService');
|
||||||
|
const { isAdmin } = require('../roleService');
|
||||||
|
const { listModels, saveSnapshot } = require('./models');
|
||||||
|
const { report } = require('./diagnostics');
|
||||||
|
const { compatible } = require('./capabilities');
|
||||||
|
|
||||||
|
function createGateway({ root, driving, getSystemState, activity }) {
|
||||||
|
const bindings = new Map();
|
||||||
|
const viewers = new Map();
|
||||||
|
let models = [];
|
||||||
|
let modelError = null;
|
||||||
|
let lastCatalog = 0;
|
||||||
|
let closed = false;
|
||||||
|
let refreshing = false;
|
||||||
|
let lastOrdinary = 0;
|
||||||
|
|
||||||
|
function stateFor(socket, roverId) {
|
||||||
|
if (!isVerified(socket)) return { available: false };
|
||||||
|
const visible = roverId && roverManager.canSeeRover(roverId, socket);
|
||||||
|
const id = visible ? roverId : null;
|
||||||
|
const session = id ? driving.getState(id) : null;
|
||||||
|
const canControl = Boolean(id && driving.canControl(socket, id));
|
||||||
|
const system = getSystemState();
|
||||||
|
const state = {
|
||||||
|
available: true, roverId: id, updatedAt: Date.now(),
|
||||||
|
control: { canSnapshot: isAdmin(socket), canStart: canControl && !session, canAdjust: canControl && Boolean(session), canStop: Boolean(session && (canControl || session.socketId === socket.id)),
|
||||||
|
reason: !id ? 'Select a rover first' : !canControl ? 'You do not currently have control of this rover' : null },
|
||||||
|
session,
|
||||||
|
models: models.filter((model) => model.trainedRovers?.some((rover) => roverManager.canSeeRover(rover, socket)))
|
||||||
|
.map((model) => ({ id: model.id, checkpointId: model.checkpointId || model.id, automatic: Boolean(model.automatic), experimental: Boolean(model.experimental), name: model.name, createdAt: model.createdAt,
|
||||||
|
trainingSteps: model.trainingSteps, examplesProcessed: model.examplesProcessed,
|
||||||
|
weightBytes: model.weightBytes, parameters: model.parameters, trainingLoss: model.trainingLoss,
|
||||||
|
formatVersion: model.specification?.version, evaluation: model.evaluation, validation: model.validation, distinctMinutes: model.distinctMinutes, controls: model.specification?.commands?.length || 0,
|
||||||
|
compatible: Boolean(id && compatible(model, roverManager.rovers.get(id))) })),
|
||||||
|
catalogError: modelError,
|
||||||
|
recording: { ...system.recording, sessions: system.recording.sessions.filter((s) => roverManager.canSeeRover(s.roverId, socket)) },
|
||||||
|
training: { ...system.training, sessions: undefined },
|
||||||
|
limits: system.limits,
|
||||||
|
activity: system.activity.filter((entry) => !entry.roverId || roverManager.canSeeRover(entry.roverId, socket)),
|
||||||
|
};
|
||||||
|
state.diagnostics = viewers.get(socket)?.live ? report(state, id ? driving.getDiagnostics(id) : null) : null;
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh() {
|
||||||
|
if (closed || refreshing) return;
|
||||||
|
refreshing = true;
|
||||||
|
try {
|
||||||
|
if (Date.now() - lastCatalog >= 15000) {
|
||||||
|
lastCatalog = Date.now();
|
||||||
|
try { models = await listModels(root); modelError = null; }
|
||||||
|
catch (error) { modelError = error.message; }
|
||||||
|
}
|
||||||
|
if (closed) return;
|
||||||
|
const ordinary = Date.now() - lastOrdinary >= 1000;
|
||||||
|
if (ordinary) lastOrdinary = Date.now();
|
||||||
|
for (const [socket, view] of viewers) {
|
||||||
|
if (ordinary || view.live || driving.isActive(view.roverId)) socket.volatile.emit('roverLearning:state', stateFor(socket, view.roverId));
|
||||||
|
}
|
||||||
|
} finally { refreshing = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect(socket) {
|
||||||
|
const subscribe = (payload = {}, reply) => {
|
||||||
|
const respond = typeof reply === 'function' ? reply : () => {};
|
||||||
|
if (!isVerified(socket)) { respond({ error: 'Verification required' }); return; }
|
||||||
|
const roverId = typeof payload?.roverId === 'string' ? payload.roverId : null;
|
||||||
|
viewers.set(socket, { roverId, live: payload.live === true });
|
||||||
|
respond({ state: stateFor(socket, roverId) });
|
||||||
|
refresh().catch((error) => activity('Card update failed', { error: error.message }));
|
||||||
|
};
|
||||||
|
const action = async (payload = {}, reply) => {
|
||||||
|
const respond = typeof reply === 'function' ? reply : () => {};
|
||||||
|
try {
|
||||||
|
if (!isVerified(socket)) throw new Error('Verification required');
|
||||||
|
const roverId = String(payload.roverId || '');
|
||||||
|
if (payload.action === 'snapshot') {
|
||||||
|
if (!isAdmin(socket)) throw new Error('Administrator access required');
|
||||||
|
const model = await saveSnapshot(root);
|
||||||
|
models = await listModels(root);
|
||||||
|
lastCatalog = Date.now();
|
||||||
|
activity('Learner snapshot saved', { model: model.name });
|
||||||
|
} else if (payload.action === 'start') await driving.start(socket, roverId, String(payload.modelId || ''));
|
||||||
|
else if (payload.action === 'threshold') driving.setThreshold(socket, roverId, payload.value);
|
||||||
|
else if (payload.action === 'stop') {
|
||||||
|
const session = driving.getState(roverId);
|
||||||
|
if (!session || !(driving.canControl(socket, roverId) || session.socketId === socket.id)) throw new Error('You cannot stop this controller');
|
||||||
|
await driving.stop(roverId);
|
||||||
|
} else throw new Error('Unknown action');
|
||||||
|
viewers.set(socket, { roverId, live: viewers.get(socket)?.live || false });
|
||||||
|
respond({ state: stateFor(socket, roverId) });
|
||||||
|
} catch (error) { respond({ error: error.message }); }
|
||||||
|
refresh().catch((error) => activity('Card update failed', { error: error.message }));
|
||||||
|
};
|
||||||
|
const unsubscribe = () => viewers.delete(socket);
|
||||||
|
const disconnect = () => { unsubscribe(); detach(socket); };
|
||||||
|
const handlers = { 'roverLearning:subscribe': subscribe, 'roverLearning:action': action,
|
||||||
|
'roverLearning:unsubscribe': unsubscribe, disconnect };
|
||||||
|
for (const [name, handler] of Object.entries(handlers)) socket.on(name, handler);
|
||||||
|
bindings.set(socket, handlers);
|
||||||
|
}
|
||||||
|
|
||||||
|
function detach(socket) {
|
||||||
|
for (const [name, handler] of Object.entries(bindings.get(socket) || {})) socket.off(name, handler);
|
||||||
|
bindings.delete(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
io.on('connection', connect);
|
||||||
|
for (const socket of io.sockets.sockets.values()) connect(socket);
|
||||||
|
const timer = setInterval(() => { refresh().catch((error) => activity('Card update failed', { error: error.message })); }, 100);
|
||||||
|
refresh().catch((error) => activity('Catalog unavailable', { error: error.message }));
|
||||||
|
return {
|
||||||
|
close() {
|
||||||
|
closed = true;
|
||||||
|
clearInterval(timer);
|
||||||
|
io.off('connection', connect);
|
||||||
|
for (const socket of viewers.keys()) socket.emit('roverLearning:state', { available: false });
|
||||||
|
viewers.clear();
|
||||||
|
for (const socket of bindings.keys()) detach(socket);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createGateway };
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Recording retention only. Published models and training state are never visited.
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { saveMetadata } = require('./recording');
|
||||||
|
|
||||||
|
async function listRecordings(root) {
|
||||||
|
const recordings = [];
|
||||||
|
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
||||||
|
// Directory names are generated, never rover IDs or user-supplied paths.
|
||||||
|
if (!entry.isDirectory() || !/^\d+-[0-9a-f-]{36}$/.test(entry.name)) continue;
|
||||||
|
const directory = path.join(root, entry.name);
|
||||||
|
let bytes = 0;
|
||||||
|
let files;
|
||||||
|
try { files = await fs.readdir(directory, { withFileTypes: true }); }
|
||||||
|
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.isFile()) continue;
|
||||||
|
try { bytes += (await fs.stat(path.join(directory, file.name))).size; }
|
||||||
|
catch (error) {
|
||||||
|
// Dock-buffer pruning and atomic metadata replacement can remove a
|
||||||
|
// listed file before stat. Absence is normal; other I/O failures are not.
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
recordings.push({ id: entry.name, directory, bytes, startedAt: Number(entry.name.split('-')[0]) });
|
||||||
|
}
|
||||||
|
return recordings.sort((a, b) => a.startedAt - b.startedAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function recoverRecordings(root) {
|
||||||
|
for (const recording of await listRecordings(root)) {
|
||||||
|
let metadata;
|
||||||
|
try {
|
||||||
|
metadata = JSON.parse(await fs.readFile(path.join(recording.directory, 'session.json'), 'utf8'));
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT' && !(error instanceof SyntaxError)) throw error;
|
||||||
|
// An interrupted initial metadata write is retained as an unusable session
|
||||||
|
// until ordinary retention removes it, rather than blocking all startup.
|
||||||
|
metadata = { version: 1, id: recording.id, startedAt: recording.startedAt };
|
||||||
|
}
|
||||||
|
if (metadata.bufferOnly) {
|
||||||
|
await fs.rm(recording.directory, { recursive: true, force: true });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (metadata.endedAt != null) continue;
|
||||||
|
await saveMetadata(recording.directory, {
|
||||||
|
...metadata, endedAt: Date.now(), reason: 'interrupted',
|
||||||
|
error: 'Previous process did not close this session; final timestamps and video may be incomplete.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enforceRetention(root, activeIds, maxBytes, minimumFreeBytes) {
|
||||||
|
const recordings = await listRecordings(root);
|
||||||
|
let bytes = recordings.reduce((total, recording) => total + recording.bytes, 0);
|
||||||
|
const stat = await fs.statfs(root);
|
||||||
|
let free = stat.bavail * stat.bsize;
|
||||||
|
for (const recording of recordings) {
|
||||||
|
if (bytes < maxBytes && free >= minimumFreeBytes) break;
|
||||||
|
if (activeIds.has(recording.id)) continue;
|
||||||
|
await fs.rm(recording.directory, { recursive: true });
|
||||||
|
bytes -= recording.bytes;
|
||||||
|
// Re-read actual free space; sparse/compressed files and other services can
|
||||||
|
// make a deleted file's logical size a misleading estimate of freed space.
|
||||||
|
const current = await fs.statfs(root);
|
||||||
|
free = current.bavail * current.bsize;
|
||||||
|
}
|
||||||
|
return { bytes, free, paused: bytes >= maxBytes || free < minimumFreeBytes };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { recoverRecordings, enforceRetention, listRecordings };
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
// One owned finite worker at a time. Leases are acquired in the runtime's
|
||||||
|
// serialized maintenance pass, before retention can next inspect recordings.
|
||||||
|
const { errorDetails } = require('./errors');
|
||||||
|
const fs = require('node:fs/promises');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { spawn } = require('node:child_process');
|
||||||
|
const { randomInt, createHash } = require('node:crypto');
|
||||||
|
const { adoptCheckpoint } = require('./models');
|
||||||
|
const { listRecordings } = require('./storage');
|
||||||
|
|
||||||
|
function createTrainer({ root, config, actionThreshold, logger }) {
|
||||||
|
const leases = new Set();
|
||||||
|
const trainingRoot = path.join(root, 'training');
|
||||||
|
let ledger = {};
|
||||||
|
const history = [];
|
||||||
|
let child = null;
|
||||||
|
let completion = Promise.resolve();
|
||||||
|
let stopped = false;
|
||||||
|
let nextRun = 0;
|
||||||
|
let startupError = false;
|
||||||
|
let state = { status: config.enabled ? 'waiting' : 'disabled', steps: 0, examples: 0 };
|
||||||
|
|
||||||
|
async function start() {
|
||||||
|
if (!config.enabled) return;
|
||||||
|
try {
|
||||||
|
await fs.mkdir(trainingRoot, { recursive: true });
|
||||||
|
await fs.mkdir(path.join(root, 'models'), { recursive: true });
|
||||||
|
// Only unpublished staging directories from an interrupted old worker
|
||||||
|
// are disposable. Permanent UUID model directories are never removed.
|
||||||
|
for (const entry of await fs.readdir(path.join(root, 'models'), { withFileTypes: true })) {
|
||||||
|
if (entry.isDirectory() && /^\.[0-9a-f-]{36}\.pending$/.test(entry.name)) {
|
||||||
|
await fs.rm(path.join(root, 'models', entry.name), { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const saved = JSON.parse(await fs.readFile(path.join(trainingRoot, 'schedule-v6.json'), 'utf8'));
|
||||||
|
ledger = saved.sessions;
|
||||||
|
state = { ...saved.progress, status: 'waiting' };
|
||||||
|
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
|
||||||
|
// Retry this reader-specific rejection once after the fixed-grid repair.
|
||||||
|
// Keep optimizer progress and unrelated recording failures intact.
|
||||||
|
if (saved.samplingVersion !== 2) {
|
||||||
|
for (const [id, entry] of Object.entries(ledger)) {
|
||||||
|
if (entry.rejected === 'No complete, aligned observation/action windows') delete ledger[id];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) { if (error.code !== 'ENOENT') throw error; }
|
||||||
|
} catch (error) {
|
||||||
|
startupError = true;
|
||||||
|
state = { ...state, status: 'error', error: error.message };
|
||||||
|
logger.error('Training initialization failed; recording remains available', { error: error.message, failure: errorDetails(error, 'training initialization') });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveLedger() {
|
||||||
|
const filename = path.join(trainingRoot, 'schedule-v6.json');
|
||||||
|
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ samplingVersion: 2, sessions: ledger, progress: state }));
|
||||||
|
await fs.rename(`${filename}.tmp`, filename);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function tick(activeIds) {
|
||||||
|
if (!config.enabled || startupError || stopped || child || Date.now() < nextRun) return;
|
||||||
|
nextRun = Date.now() + config.intervalSeconds * 1000;
|
||||||
|
const recordings = await listRecordings(path.join(root, 'recordings'));
|
||||||
|
const present = new Set(recordings.map((recording) => recording.id));
|
||||||
|
// Scheduling history is bounded by recording retention, unlike models.
|
||||||
|
for (const id of Object.keys(ledger)) if (!present.has(id)) delete ledger[id];
|
||||||
|
const eligible = [];
|
||||||
|
for (const recording of recordings) {
|
||||||
|
if (activeIds.has(recording.id) || ledger[recording.id]?.rejected) continue;
|
||||||
|
try {
|
||||||
|
const metadata = JSON.parse(await fs.readFile(path.join(recording.directory, 'session.json'), 'utf8'));
|
||||||
|
if (metadata.bufferOnly || !metadata.endedAt || metadata.error || metadata.droppedEvents || metadata.reason === 'interrupted') continue;
|
||||||
|
eligible.push(recording.id);
|
||||||
|
} catch (error) {
|
||||||
|
ledger[recording.id] = { rejected: `Unreadable session metadata: ${error.message}` };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Random cohorts mix old and new retained sessions without repeatedly
|
||||||
|
// favoring the first files in directory order.
|
||||||
|
for (let index = eligible.length - 1; index > 0; index -= 1) {
|
||||||
|
const other = randomInt(index + 1);
|
||||||
|
[eligible[index], eligible[other]] = [eligible[other], eligible[index]];
|
||||||
|
}
|
||||||
|
// Match workers/train.py exactly. Never move a previously trained session
|
||||||
|
// into evaluation just to fill a slot: that would contaminate the holdout.
|
||||||
|
const partitions = { training: [], heldOut: [] };
|
||||||
|
for (const id of eligible) {
|
||||||
|
const heldOut = parseInt(createHash('sha256').update(id).digest('hex').slice(0, 8), 16) % 5 === 0;
|
||||||
|
partitions[heldOut ? 'heldOut' : 'training'].push(id);
|
||||||
|
}
|
||||||
|
const trainingSessions = partitions.training.slice(0, partitions.heldOut.length ? 3 : 4);
|
||||||
|
const heldOutSessions = trainingSessions.length ? partitions.heldOut.slice(0, 2) : [];
|
||||||
|
const selected = [...trainingSessions, ...heldOutSessions];
|
||||||
|
const selection = {
|
||||||
|
atUnixMs: Date.now(),
|
||||||
|
availableTraining: partitions.training.length, availableHeldOut: partitions.heldOut.length,
|
||||||
|
selectedTraining: trainingSessions.length, selectedHeldOut: heldOutSessions.length,
|
||||||
|
reason: !trainingSessions.length ? 'Waiting for completed usable training-partition recordings'
|
||||||
|
: !heldOutSessions.length ? 'Training without evaluation: no eligible held-out recordings' : null,
|
||||||
|
};
|
||||||
|
state = { ...state, selection };
|
||||||
|
nextRun = Date.now() + config.intervalSeconds * 1000;
|
||||||
|
if (!selected.length) {
|
||||||
|
state = { ...state, status: 'waiting', reason: selection.reason };
|
||||||
|
await saveLedger();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const jobPath = path.join(trainingRoot, 'job.json');
|
||||||
|
await fs.writeFile(jobPath, JSON.stringify({ root, config, actionThreshold, sessions: selected }));
|
||||||
|
if (stopped) return;
|
||||||
|
selected.forEach((id) => leases.add(id));
|
||||||
|
state = { ...state, status: 'training', reason: selection.reason, sessions: selected };
|
||||||
|
state.startedAt = Date.now();
|
||||||
|
child = spawn(config.python, [path.join(__dirname, 'workers/train.py'), '--job', jobPath], {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
env: { ...process.env, PYTHONDONTWRITEBYTECODE: '1', OMP_NUM_THREADS: String(config.threads), MKL_NUM_THREADS: String(config.threads) },
|
||||||
|
});
|
||||||
|
let output = '';
|
||||||
|
let stderr = '';
|
||||||
|
let result = null;
|
||||||
|
let errorMessage = null;
|
||||||
|
let forcedTimer;
|
||||||
|
const worker = child;
|
||||||
|
const deadline = setTimeout(() => {
|
||||||
|
worker.kill('SIGTERM');
|
||||||
|
forcedTimer = setTimeout(() => worker.kill('SIGKILL'), 5000);
|
||||||
|
}, (config.maxJobSeconds + 15) * 1000);
|
||||||
|
child.on('error', (error) => { errorMessage = error.message; });
|
||||||
|
child.stderr.on('data', (chunk) => { stderr = (stderr + chunk).slice(-8192); });
|
||||||
|
child.stdout.on('data', (chunk) => {
|
||||||
|
output += chunk;
|
||||||
|
if (output.length > 1024 * 1024) {
|
||||||
|
errorMessage = 'Training worker output exceeded protocol bound';
|
||||||
|
worker.kill('SIGKILL');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let newline;
|
||||||
|
while ((newline = output.indexOf('\n')) >= 0) {
|
||||||
|
const line = output.slice(0, newline);
|
||||||
|
output = output.slice(newline + 1);
|
||||||
|
try {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
if (message.kind === 'complete') result = message;
|
||||||
|
if (message.kind === 'error') errorMessage = message.message;
|
||||||
|
if (message.kind === 'skipped') logger.warn('Training skipped recording', message);
|
||||||
|
if (message.kind === 'dataset') state = { ...state, lastSampledWindows: message.sampled, lastAvailableWindows: message.windows, dataset: message.diagnostics };
|
||||||
|
if (message.kind === 'progress') state = { ...state, steps: message.steps, examples: message.examples, loss: message.loss, losses: message.losses };
|
||||||
|
} catch { errorMessage = 'Invalid training worker message'; }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
completion = new Promise((resolve) => {
|
||||||
|
child.once('close', async (code, signal) => {
|
||||||
|
clearTimeout(deadline);
|
||||||
|
clearTimeout(forcedTimer);
|
||||||
|
try {
|
||||||
|
if (code !== 0 || errorMessage || !result) {
|
||||||
|
state = { ...state, status: stopped ? 'stopped' : 'error', error: errorMessage || `Worker exited ${code ?? signal}` };
|
||||||
|
if (!stopped) logger.error('Training job failed', { error: state.error, stderr });
|
||||||
|
} else {
|
||||||
|
for (const [id, reason] of Object.entries(result.rejected || {})) ledger[id] = { rejected: reason };
|
||||||
|
if (result.published) await adoptCheckpoint(root, result.published, result.promote);
|
||||||
|
if (result.trained) {
|
||||||
|
for (const id of result.accepted) ledger[id] = { rounds: (ledger[id]?.rounds || 0) + 1 };
|
||||||
|
}
|
||||||
|
state = { ...state, status: stopped ? 'stopped' : 'waiting', steps: result.steps ?? state.steps,
|
||||||
|
completedAt: Date.now(), elapsedSeconds: result.elapsedSeconds ?? null, error: null,
|
||||||
|
examples: result.examples ?? state.examples, loss: result.loss ?? null,
|
||||||
|
evaluatedSteps: result.trained ? result.steps : state.evaluatedSteps,
|
||||||
|
diagnostics: result.diagnostics ?? state.diagnostics, publication: result.publication ?? state.publication, evaluation: result.evaluation ?? state.evaluation,
|
||||||
|
reason: result.reason ?? 'Waiting for the next scheduled training job' };
|
||||||
|
await saveLedger();
|
||||||
|
logger.info('Training job completed', { ...state, model: result.published?.name || null });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
state = { ...state, status: 'error', error: error.message };
|
||||||
|
logger.error('Training result persistence failed', { error: error.message, failure: errorDetails(error, 'training result persistence') });
|
||||||
|
} finally {
|
||||||
|
history.push({ at: Date.now(), status: state.status, steps: state.steps, loss: state.loss,
|
||||||
|
losses: state.losses || null, evaluation: state.evaluation ? Object.fromEntries(
|
||||||
|
['status', 'windows', 'precision', 'recall', 'f1', 'predictedActions', 'positiveActions', 'numericMse', 'missedStops', 'gateReasons']
|
||||||
|
.map((key) => [key, state.evaluation[key]])) : null,
|
||||||
|
elapsedSeconds: state.elapsedSeconds || null });
|
||||||
|
if (history.length > 20) history.shift();
|
||||||
|
leases.clear();
|
||||||
|
child = null;
|
||||||
|
nextRun = Date.now() + config.intervalSeconds * 1000;
|
||||||
|
resolve();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cancel() {
|
||||||
|
if (!child) return;
|
||||||
|
const worker = child;
|
||||||
|
worker.kill('SIGTERM');
|
||||||
|
const timer = setTimeout(() => worker.kill('SIGKILL'), 5000);
|
||||||
|
try { await completion; } finally { clearTimeout(timer); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
leases, start, tick, cancel,
|
||||||
|
getState: () => ({ ...state, history, nextAttemptAt: nextRun || null, leasedSessions: leases.size,
|
||||||
|
visitedSessions: Object.values(ledger).filter((entry) => entry.rounds).length,
|
||||||
|
skippedSessions: Object.values(ledger).filter((entry) => entry.rejected).length,
|
||||||
|
rejectionReasons: [...new Set(Object.values(ledger).filter((entry) => entry.rejected).map((entry) => entry.rejected))].slice(0, 10) }),
|
||||||
|
stop: async () => { stopped = true; await cancel(); },
|
||||||
|
kill: () => child?.kill('SIGKILL'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createTrainer };
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
"""Read closed recordings with causal state/action alignment and bounded memory."""
|
||||||
|
import bisect
|
||||||
|
import collections
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import av
|
||||||
|
import numpy as np
|
||||||
|
from observations import decision_frames, history_frames
|
||||||
|
|
||||||
|
from policy import SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, command_family, is_stop, encode_command, sensor_vector
|
||||||
|
|
||||||
|
|
||||||
|
def load_events(directory):
|
||||||
|
metadata = json.loads((directory / 'session.json').read_text())
|
||||||
|
if (metadata.get('version') != 1 or not metadata.get('endedAt') or metadata.get('error')
|
||||||
|
or metadata.get('droppedEvents') or metadata.get('reason') == 'interrupted'):
|
||||||
|
raise ValueError('Incomplete/interrupted recording or lost events')
|
||||||
|
if metadata.get('video', {}).get('origin') != 'unix':
|
||||||
|
raise ValueError('Unsupported video time origin')
|
||||||
|
video_result = metadata.get('video', {}).get('result') or {}
|
||||||
|
if video_result.get('signal') == 'SIGKILL' or video_result.get('code') not in (0, 255, None):
|
||||||
|
raise ValueError('Video recorder failed or was forcibly killed')
|
||||||
|
event_file = directory / 'events.ndjson'
|
||||||
|
if event_file.stat().st_size > 64 * 1024 * 1024:
|
||||||
|
raise ValueError('Event file exceeds 64 MiB read bound')
|
||||||
|
events = []
|
||||||
|
with event_file.open() as stream:
|
||||||
|
for line in stream:
|
||||||
|
event = json.loads(line)
|
||||||
|
if event['kind'] == 'captureGap':
|
||||||
|
raise ValueError('Recording contains a capture gap')
|
||||||
|
if not isinstance(event.get('ts'), (int, float)) or not math.isfinite(event['ts']):
|
||||||
|
raise ValueError('Invalid event timestamp')
|
||||||
|
events.append(event)
|
||||||
|
if not events or not any(e['kind'] == 'sessionEnd' for e in events):
|
||||||
|
raise ValueError('Missing completed event stream')
|
||||||
|
# File order can differ across rotation buffering. Monotonic time detects
|
||||||
|
# wall-clock steps; sorting Unix timestamps alone would hide those steps.
|
||||||
|
timed = sorted(events, key=lambda e: int(e['monotonicNs']))
|
||||||
|
origin = timed[0]
|
||||||
|
for event in timed:
|
||||||
|
drift = (event['ts'] - origin['ts']) - (int(event['monotonicNs']) - int(origin['monotonicNs'])) / 1e6
|
||||||
|
if abs(drift) > 250:
|
||||||
|
raise ValueError('Server wall clock shifted during recording')
|
||||||
|
return metadata, sorted(events, key=lambda e: e['ts'])
|
||||||
|
|
||||||
|
|
||||||
|
def prepare(directory, schema, stats, extend):
|
||||||
|
metadata, events = load_events(directory)
|
||||||
|
requests = {}
|
||||||
|
accepted = set()
|
||||||
|
failed = set()
|
||||||
|
sensors, dispatches, boundaries = [], [], []
|
||||||
|
for event in events:
|
||||||
|
data = event['data']
|
||||||
|
if event['kind'] == 'sensor':
|
||||||
|
sensors.append((event['ts'], sensor_vector(data.get('sensors') or {})))
|
||||||
|
if event['kind'] in ('driver', 'turn:activeDriver', 'lock', 'private'):
|
||||||
|
boundaries.append(event['ts'])
|
||||||
|
if event['kind'] != 'command':
|
||||||
|
continue
|
||||||
|
if data.get('phase') == 'requested' and data.get('source') == 'client':
|
||||||
|
requests[data.get('requestId')] = data
|
||||||
|
if data.get('phase') == 'result' and data.get('result', {}).get('id'):
|
||||||
|
accepted.add(data['result']['id'])
|
||||||
|
if data.get('phase') == 'acknowledged' and data.get('acknowledgement', {}).get('error'):
|
||||||
|
failed.add(data.get('commandId'))
|
||||||
|
if data.get('phase') == 'dispatched':
|
||||||
|
dispatches.append(data)
|
||||||
|
if not sensors:
|
||||||
|
raise ValueError('No sensor observations')
|
||||||
|
candidate = copy.deepcopy(schema)
|
||||||
|
history, targets, unsupported_times = [], [], []
|
||||||
|
unsupported = {}
|
||||||
|
dispatch_delays = []
|
||||||
|
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
|
||||||
|
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][len(SENSORS):] for row in sensors], axis=0).tolist()})
|
||||||
|
for data in dispatches:
|
||||||
|
command = data.get('command') or {}
|
||||||
|
if command.get('type') in ('reset', 'reboot', 'update'):
|
||||||
|
boundaries.append(data['ts'])
|
||||||
|
try:
|
||||||
|
encoded = encode_command(command, candidate, extend=extend)
|
||||||
|
except ValueError as error:
|
||||||
|
# Unrepresentable controls invalidate only nearby observations/targets,
|
||||||
|
# not the rest of a recording. Never turn them into negative labels.
|
||||||
|
request = requests.get(data.get('requestId'))
|
||||||
|
unsupported_times.extend([data['ts'], request['ts'] if request else data['ts']])
|
||||||
|
key = json.dumps(command, sort_keys=True)
|
||||||
|
if key not in unsupported and len(unsupported) < 32:
|
||||||
|
unsupported[key] = {'command': command, 'reason': str(error), 'count': 0}
|
||||||
|
if key in unsupported:
|
||||||
|
unsupported[key]['count'] += 1
|
||||||
|
continue
|
||||||
|
if encoded is None:
|
||||||
|
continue
|
||||||
|
stamp = data['ts']
|
||||||
|
history.append((stamp, *encoded))
|
||||||
|
request = requests.get(data.get('requestId'))
|
||||||
|
if (data.get('source') == 'client' and request
|
||||||
|
and data.get('commandId') in accepted and data.get('commandId') not in failed):
|
||||||
|
target_time = request['ts']
|
||||||
|
if not 0 <= stamp - target_time <= 1000:
|
||||||
|
raise ValueError('Invalid request/dispatch timing')
|
||||||
|
dispatch_delays.append(stamp - target_time)
|
||||||
|
targets.append((target_time, *encoded))
|
||||||
|
else:
|
||||||
|
# Server safety overrides belong in history, never demonstration
|
||||||
|
# targets. Exclude windows around them rather than teaching inaction.
|
||||||
|
boundaries.append(stamp)
|
||||||
|
stats['requestToDispatchMs'] = {'mean': sum(dispatch_delays) / max(1, len(dispatch_delays)),
|
||||||
|
'max': max(dispatch_delays, default=0)}
|
||||||
|
stats['acceptedCommandEvents'] = len(targets)
|
||||||
|
stats['representedCommandEvents'] = 0
|
||||||
|
stats['commandEventCounts'] = [0] * SLOTS
|
||||||
|
stats['unsupportedCommands'] = list(unsupported.values())
|
||||||
|
stats['unsupportedCommandEvents'] = len(unsupported_times) // 2
|
||||||
|
if not targets and not unsupported_times:
|
||||||
|
raise ValueError('No accepted client operating commands')
|
||||||
|
schema[:] = candidate
|
||||||
|
return metadata, sensors, sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries), sorted(unsupported_times)
|
||||||
|
|
||||||
|
|
||||||
|
def windows(directory, schema, cancelled=lambda: False, stats=None, extend=True):
|
||||||
|
directory = Path(directory)
|
||||||
|
stats = stats if stats is not None else {}
|
||||||
|
metadata, sensors, history, targets, boundaries, unsupported_times = prepare(directory, schema, stats, extend)
|
||||||
|
stats.update({'usableWindows': 0, 'commandWindows': 0, 'stopWindows': 0, 'noCommandWindows': 0,
|
||||||
|
'staleSensorWindows': 0, 'boundaryWindows': 0, 'maxSensorAgeMs': 0, 'maxFrameAgeMs': 0,
|
||||||
|
'unsupportedWindows': 0, 'ambiguousWindows': 0, 'videoFrames': 0, 'maxVideoGapMs': 0})
|
||||||
|
sensor_times = [row[0] for row in sensors]
|
||||||
|
target_times = [row[0] for row in targets]
|
||||||
|
available = np.zeros(SLOTS, dtype=np.float32)
|
||||||
|
available[:len(schema)] = 1
|
||||||
|
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||||
|
command_times = np.full(SLOTS, -np.inf)
|
||||||
|
cursor = 0
|
||||||
|
recent = collections.deque(maxlen=32)
|
||||||
|
last_pts = -math.inf
|
||||||
|
def frames():
|
||||||
|
nonlocal last_pts
|
||||||
|
files = metadata['video'].get('files', ['video.mkv'])
|
||||||
|
for name in files:
|
||||||
|
if Path(name).name != name:
|
||||||
|
raise ValueError('Invalid video segment path')
|
||||||
|
with av.open(str(directory / name)) as container:
|
||||||
|
stream = container.streams.video[0]
|
||||||
|
stream.codec_context.thread_count = 1
|
||||||
|
for frame in container.decode(stream):
|
||||||
|
if frame.pts is None:
|
||||||
|
raise ValueError('Video frame has no timestamp')
|
||||||
|
stamp = float(frame.pts * frame.time_base) * 1000
|
||||||
|
stats['videoFrames'] += 1
|
||||||
|
if math.isfinite(last_pts):
|
||||||
|
stats['maxVideoGapMs'] = max(stats['maxVideoGapMs'], stamp - last_pts)
|
||||||
|
last_pts = stamp
|
||||||
|
if not metadata['startedAt'] - 2000 <= stamp <= metadata['endedAt'] + 2000:
|
||||||
|
raise ValueError('Video PTS is not aligned to session Unix time')
|
||||||
|
yield frame
|
||||||
|
|
||||||
|
represented = set()
|
||||||
|
for stamp, frame, captured_at in decision_frames(frames()):
|
||||||
|
if cancelled():
|
||||||
|
return
|
||||||
|
if stamp < metadata.get('trainingStartedAt', metadata['startedAt']):
|
||||||
|
continue
|
||||||
|
while cursor < len(history) and history[cursor][0] < stamp:
|
||||||
|
when, slot, values = history[cursor]
|
||||||
|
# Do not let drive predictions simply copy earlier human/model drive commands.
|
||||||
|
if schema[slot]['template']['type'] != 'drive':
|
||||||
|
command_values[slot] = values
|
||||||
|
command_times[slot] = when
|
||||||
|
cursor += 1
|
||||||
|
sensor_index = bisect.bisect_left(sensor_times, stamp) - 1
|
||||||
|
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
|
||||||
|
stats['staleSensorWindows'] += 1
|
||||||
|
recent.clear()
|
||||||
|
continue
|
||||||
|
stats['maxSensorAgeMs'] = max(stats['maxSensorAgeMs'], stamp - sensor_times[sensor_index])
|
||||||
|
stats['maxFrameAgeMs'] = max(stats['maxFrameAgeMs'], stamp - captured_at)
|
||||||
|
seen = np.isfinite(command_times).astype(np.float32)
|
||||||
|
ages = np.minimum((stamp - command_times) / 10000, 1).astype(np.float32)
|
||||||
|
state = np.concatenate((sensors[sensor_index][1], command_values.flatten(), ages, seen))
|
||||||
|
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
|
||||||
|
recent.append((stamp, pixels, state))
|
||||||
|
chosen = history_frames(recent, stamp)
|
||||||
|
if len(chosen) != 4 or stamp + 100 > metadata['endedAt']:
|
||||||
|
continue
|
||||||
|
if bisect.bisect_right(unsupported_times, stamp + 100) > bisect.bisect_left(unsupported_times, chosen[0][0]):
|
||||||
|
stats['unsupportedWindows'] += 1
|
||||||
|
continue
|
||||||
|
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
|
||||||
|
stats['boundaryWindows'] += 1
|
||||||
|
continue
|
||||||
|
start = bisect.bisect_left(target_times, stamp)
|
||||||
|
end = bisect.bisect_left(target_times, stamp + 100)
|
||||||
|
actions = np.zeros(SLOTS, dtype=np.float32)
|
||||||
|
values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||||
|
ambiguous = False
|
||||||
|
families = set()
|
||||||
|
for _, slot, vector in targets[start:end]:
|
||||||
|
template = schema[slot]['template']
|
||||||
|
family = command_family(template)
|
||||||
|
if ((actions[slot] and (template['type'] in ('headlight', 'raw') or not np.array_equal(values[slot], vector)))
|
||||||
|
or (not actions[slot] and family in families)):
|
||||||
|
ambiguous = True
|
||||||
|
break
|
||||||
|
families.add(family)
|
||||||
|
actions[slot], values[slot] = 1, vector
|
||||||
|
if ambiguous:
|
||||||
|
stats['ambiguousWindows'] += 1
|
||||||
|
continue
|
||||||
|
represented.update(range(start, end))
|
||||||
|
stats['representedCommandEvents'] = len(represented)
|
||||||
|
for slot in np.flatnonzero(actions):
|
||||||
|
stats['commandEventCounts'][slot] += 1
|
||||||
|
stats['usableWindows'] += 1
|
||||||
|
stats['commandWindows' if actions.any() else 'noCommandWindows'] += 1
|
||||||
|
stats['stopWindows'] += int(any(actions[slot] and is_stop(descriptor) for slot, descriptor in enumerate(schema)))
|
||||||
|
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Live policy worker. JSON state on stdin, proposals on stdout; no rover writes."""
|
||||||
|
import argparse
|
||||||
|
import collections
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
|
||||||
|
import av
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from observations import decision_frames, history_frames
|
||||||
|
|
||||||
|
from policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, SENSORS, select_slots, decode_command, encode_command, sensor_vector
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--model', required=True)
|
||||||
|
parser.add_argument('--url', required=True)
|
||||||
|
parser.add_argument('--threads', type=int, default=4)
|
||||||
|
parser.add_argument('--threshold', type=float, default=0.7)
|
||||||
|
args = parser.parse_args()
|
||||||
|
torch.set_num_threads(args.threads)
|
||||||
|
torch.set_num_interop_threads(1)
|
||||||
|
directory = Path(args.model)
|
||||||
|
metadata = json.loads((directory / 'model.json').read_text())
|
||||||
|
if metadata['specification']['version'] != VERSION:
|
||||||
|
raise ValueError('Model specification is incompatible')
|
||||||
|
if hashlib.sha256((directory / 'weights.pt').read_bytes()).hexdigest() != metadata['sha256']:
|
||||||
|
raise ValueError('Model weights checksum mismatch')
|
||||||
|
schema = metadata['specification']['commands']
|
||||||
|
model = Policy().eval()
|
||||||
|
model.load_state_dict(torch.load(directory / 'weights.pt', map_location='cpu', weights_only=True))
|
||||||
|
observations = collections.deque(maxlen=2048)
|
||||||
|
mutex = threading.Lock()
|
||||||
|
closed = threading.Event()
|
||||||
|
|
||||||
|
def read_input():
|
||||||
|
try:
|
||||||
|
for line in sys.stdin:
|
||||||
|
event = json.loads(line)
|
||||||
|
with mutex:
|
||||||
|
observations.append(event)
|
||||||
|
finally:
|
||||||
|
closed.set()
|
||||||
|
|
||||||
|
threading.Thread(target=read_input, daemon=True).start()
|
||||||
|
values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
||||||
|
last_commands = np.full(SLOTS, -np.inf)
|
||||||
|
sensors, sensor_time = {}, 0
|
||||||
|
history = collections.deque(maxlen=32)
|
||||||
|
latched = set()
|
||||||
|
with av.open(args.url, options={'rtsp_transport': 'tcp', 'timeout': '3000000',
|
||||||
|
'use_wallclock_as_timestamps': '1'}) as video:
|
||||||
|
stream = video.streams.video[0]
|
||||||
|
stream.codec_context.thread_count = 1
|
||||||
|
for stamp, frame, captured_at in decision_frames(video.decode(stream)):
|
||||||
|
if closed.is_set():
|
||||||
|
return
|
||||||
|
now = time.time() * 1000
|
||||||
|
if abs(now - captured_at) > 2000:
|
||||||
|
raise ValueError('Video time is stale or not aligned to server time')
|
||||||
|
with mutex:
|
||||||
|
while observations and observations[0]['ts'] < stamp:
|
||||||
|
event = observations.popleft()
|
||||||
|
if event['kind'] == 'settings':
|
||||||
|
args.threshold = max(.05, min(.99, float(event['threshold'])))
|
||||||
|
elif event['kind'] == 'sensor':
|
||||||
|
sensors, sensor_time = event['data'], event['ts']
|
||||||
|
elif event['kind'] == 'command' and event['data'].get('type') != 'drive':
|
||||||
|
try:
|
||||||
|
encoded = encode_command(event['data'], schema)
|
||||||
|
if encoded:
|
||||||
|
slot, vector = encoded
|
||||||
|
values[slot], last_commands[slot] = vector, event['ts']
|
||||||
|
except ValueError:
|
||||||
|
pass # Unseen manual controls do not stop autonomy.
|
||||||
|
if stamp - sensor_time > 1000:
|
||||||
|
history.clear()
|
||||||
|
continue
|
||||||
|
state = np.concatenate((sensor_vector(sensors), values.flatten(),
|
||||||
|
np.minimum((stamp - last_commands) / 10000, 1).astype(np.float32),
|
||||||
|
np.isfinite(last_commands).astype(np.float32)))
|
||||||
|
pixels = frame.to_ndarray(width=WIDTH, height=HEIGHT, format='rgb24').transpose(2, 0, 1).copy()
|
||||||
|
history.append((stamp, pixels, state))
|
||||||
|
selected = history_frames(history, stamp)
|
||||||
|
if len(selected) != 4:
|
||||||
|
continue
|
||||||
|
before = time.perf_counter()
|
||||||
|
with torch.inference_mode():
|
||||||
|
logits, numeric = model(torch.from_numpy(np.stack([row[1] for row in selected])).unsqueeze(0).float() / 255,
|
||||||
|
torch.from_numpy(np.stack([row[2] for row in selected])).unsqueeze(0))
|
||||||
|
probabilities = logits[0].sigmoid().tolist()
|
||||||
|
predictions = numeric[0].tolist()
|
||||||
|
if not all(math.isfinite(value) for value in probabilities):
|
||||||
|
raise ValueError('Nonfinite model output')
|
||||||
|
winners = select_slots(probabilities, schema, args.threshold)
|
||||||
|
active = {slot for slot in range(len(schema)) if probabilities[slot] >= args.threshold}
|
||||||
|
latched.intersection_update(active)
|
||||||
|
proposals, commands = [], []
|
||||||
|
for slot, descriptor in enumerate(schema):
|
||||||
|
command = decode_command(descriptor, predictions[slot])
|
||||||
|
discrete = command['type'] in ('headlight', 'raw')
|
||||||
|
reason = ('Below threshold' if slot not in active else
|
||||||
|
'Competing action' if slot not in winners else
|
||||||
|
'Held discrete action' if discrete and slot in latched else 'Proposed')
|
||||||
|
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command, 'reason': reason})
|
||||||
|
if reason == 'Proposed':
|
||||||
|
commands.append(command)
|
||||||
|
if slot in winners and discrete:
|
||||||
|
latched.add(slot)
|
||||||
|
print(json.dumps({'kind': 'prediction', 'frameAt': captured_at, 'decisionAt': stamp, 'sensorAt': sensor_time,
|
||||||
|
'latencyMs': (time.perf_counter() - before) * 1000,
|
||||||
|
'sensorPresent': sensor_vector(sensors)[len(SENSORS):].tolist(),
|
||||||
|
'commands': commands, 'proposals': proposals, 'threshold': args.threshold}, allow_nan=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
print(json.dumps({'kind': 'error', 'message': str(error)}), flush=True)
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
"""Training objective and bounded, observational learning diagnostics."""
|
||||||
|
import copy
|
||||||
|
import time
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from torch.nn import functional as F
|
||||||
|
|
||||||
|
from policy import SLOTS, VALUES, select_slots, decode_command, is_stop
|
||||||
|
|
||||||
|
|
||||||
|
def loss_setup(samples, schema):
|
||||||
|
numeric_mask = torch.zeros(SLOTS, VALUES)
|
||||||
|
for slot, descriptor in enumerate(schema):
|
||||||
|
numeric_mask[slot, :len(descriptor['fields'])] = 1
|
||||||
|
positives = np.sum([sample[2] for sample in samples], axis=0)
|
||||||
|
opportunities = np.sum([sample[4] for sample in samples], axis=0)
|
||||||
|
weight = torch.from_numpy(np.clip((opportunities - positives) / np.maximum(positives, 1), 1, 20)).float()
|
||||||
|
return numeric_mask, weight
|
||||||
|
|
||||||
|
|
||||||
|
def tensors(samples):
|
||||||
|
images, states, targets, values, available = [torch.from_numpy(np.stack(items)) for items in zip(*samples)]
|
||||||
|
return images.float().div_(255), states, targets, values, available
|
||||||
|
|
||||||
|
|
||||||
|
def losses(logits, predictions, targets, values, available, numeric_mask, positive_weight):
|
||||||
|
events = F.binary_cross_entropy_with_logits(logits, targets, pos_weight=positive_weight, reduction='none')
|
||||||
|
events = (events * available).sum() / available.sum().clamp_min(1)
|
||||||
|
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
||||||
|
parameters = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
||||||
|
return events, parameters
|
||||||
|
|
||||||
|
|
||||||
|
def distribution(values):
|
||||||
|
values = np.asarray(values, dtype=np.float64)
|
||||||
|
if not values.size:
|
||||||
|
return {'count': 0, 'mean': None, 'p50': None, 'p95': None, 'max': None}
|
||||||
|
return {'count': int(values.size), 'mean': float(values.mean()),
|
||||||
|
'p50': float(np.quantile(values, .5)), 'p95': float(np.quantile(values, .95)), 'max': float(values.max())}
|
||||||
|
|
||||||
|
|
||||||
|
def decisions(scores, schema, threshold):
|
||||||
|
selected = np.zeros_like(scores, dtype=bool)
|
||||||
|
for row, vector in enumerate(scores):
|
||||||
|
for slot in select_slots(vector, schema, threshold):
|
||||||
|
selected[row, slot] = True
|
||||||
|
return selected
|
||||||
|
|
||||||
|
|
||||||
|
def counts(predicted, actual):
|
||||||
|
matched = int((predicted & actual).sum())
|
||||||
|
proposed, wanted = int(predicted.sum()), int(actual.sum())
|
||||||
|
empty = ~actual.any(axis=1)
|
||||||
|
return {'predictedActions': proposed, 'positiveActions': wanted, 'matchedActions': matched,
|
||||||
|
'precision': matched / max(1, proposed), 'recall': matched / max(1, wanted),
|
||||||
|
'f1': 2 * matched / max(1, proposed + wanted),
|
||||||
|
'falsePositiveActions': proposed - matched,
|
||||||
|
'noCommandWindows': int(empty.sum()), 'unwantedCommandWindows': int((empty & predicted.any(axis=1)).sum())}
|
||||||
|
|
||||||
|
|
||||||
|
def summarize(scores, actual, schema, threshold, frequencies):
|
||||||
|
active_scores = scores[:, :len(schema)]
|
||||||
|
labels = actual[:, :len(schema)]
|
||||||
|
thresholds = sorted(set([.1, .3, .5, .7, .9, threshold]))
|
||||||
|
sweep = [{'threshold': value, **counts(decisions(scores, schema, value), actual)} for value in thresholds]
|
||||||
|
frequent = np.broadcast_to(frequencies, scores.shape)
|
||||||
|
baseline = [{'threshold': value, **counts(decisions(frequent, schema, value), actual)} for value in thresholds]
|
||||||
|
return {'scoreOnHumanCommands': distribution(active_scores[labels]),
|
||||||
|
'scoreOnAbsentCommands': distribution(active_scores[~labels]),
|
||||||
|
'maxScoreOnNoCommandWindows': distribution(active_scores[~actual.any(axis=1)].max(axis=1)) if (~actual.any(axis=1)).any() else distribution([]),
|
||||||
|
'thresholds': sweep, 'baselines': {'sendNothing': counts(np.zeros_like(actual), actual),
|
||||||
|
'trainingFrequency': baseline, 'frequencySource': 'Training windows only; no held-out fitting'},
|
||||||
|
'perCommandScores': [{'slot': slot, 'onHumanCommand': distribution(scores[actual[:, slot], slot]),
|
||||||
|
'otherwise': distribution(scores[~actual[:, slot], slot])} for slot in range(len(schema))]}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(model, samples, schema, threshold, frequencies, batch_size, cancelled):
|
||||||
|
"""Same score selection as runtime; isolated windows cannot measure latching."""
|
||||||
|
model.eval()
|
||||||
|
score_rows, target_rows = [], []
|
||||||
|
value_error = value_count = 0
|
||||||
|
decoded = {}
|
||||||
|
with torch.inference_mode():
|
||||||
|
for offset in range(0, len(samples), batch_size):
|
||||||
|
if cancelled():
|
||||||
|
break
|
||||||
|
images, states, targets, values, _ = tensors(samples[offset:offset + batch_size])
|
||||||
|
logits, numeric = model(images, states)
|
||||||
|
if not torch.isfinite(logits).all() or not torch.isfinite(numeric).all():
|
||||||
|
raise ValueError('Nonfinite evaluation output')
|
||||||
|
score_rows.append(logits.sigmoid().numpy())
|
||||||
|
target_rows.append(targets.numpy().astype(bool))
|
||||||
|
for row, slot in targets.nonzero().tolist():
|
||||||
|
descriptor = schema[slot]
|
||||||
|
n = len(descriptor['fields'])
|
||||||
|
value_error += (numeric[row, slot, :n] - values[row, slot, :n]).square().sum().item()
|
||||||
|
value_count += n
|
||||||
|
prediction = decode_command(descriptor, numeric[row, slot])
|
||||||
|
expected = decode_command(descriptor, values[row, slot])
|
||||||
|
for field in descriptor['fields']:
|
||||||
|
a, b = prediction, expected
|
||||||
|
for key in field['path']:
|
||||||
|
a, b = a[key], b[key]
|
||||||
|
key = descriptor['template']['type'] + '.' + '.'.join(map(str, field['path']))
|
||||||
|
total, count = decoded.get(key, (0, 0))
|
||||||
|
decoded[key] = (total + abs(a - b), count + 1)
|
||||||
|
if not score_rows:
|
||||||
|
return {'windows': 0, 'complete': False}
|
||||||
|
scores, actual = np.concatenate(score_rows), np.concatenate(target_rows)
|
||||||
|
predicted = decisions(scores, schema, threshold)
|
||||||
|
stop_slots = [i for i, d in enumerate(schema) if is_stop(d)]
|
||||||
|
move_slots = [i for i, d in enumerate(schema) if d['template']['type'] == 'drive' and not is_stop(d)]
|
||||||
|
stop_count = int(actual[:, stop_slots].sum())
|
||||||
|
stop_match = int((actual[:, stop_slots] & predicted[:, stop_slots]).sum())
|
||||||
|
move_count = int(actual[:, move_slots].sum())
|
||||||
|
move_match = int((actual[:, move_slots] & predicted[:, move_slots]).sum())
|
||||||
|
return {'windows': len(scores), 'complete': len(scores) == len(samples),
|
||||||
|
'stopEvents': stop_count, 'missedStops': stop_count - stop_match,
|
||||||
|
'stopRecall': stop_match / max(1, stop_count),
|
||||||
|
'falseStops': int((predicted[:, stop_slots] & ~actual[:, stop_slots]).sum()),
|
||||||
|
'movementEvents': move_count, 'movementRecall': move_match / max(1, move_count),
|
||||||
|
'commands': [{'slot': i, 'command': d['template'], 'actual': int(actual[:, i].sum()),
|
||||||
|
'predicted': int(predicted[:, i].sum()),
|
||||||
|
'matched': int((actual[:, i] & predicted[:, i]).sum())} for i, d in enumerate(schema)],
|
||||||
|
'numericMse': value_error / max(1, value_count),
|
||||||
|
'decodedParameterMaeBeforeServerLimits': {key: total / count for key, (total, count) in decoded.items()},
|
||||||
|
**counts(decisions(scores, schema, threshold), actual),
|
||||||
|
**summarize(scores, actual, schema, threshold, frequencies)}
|
||||||
|
|
||||||
|
|
||||||
|
def probe(model, samples, schema, threshold, frequencies, cancelled, max_seconds=20):
|
||||||
|
"""Try fitting a small real training subset on a disposable copy, never publish it."""
|
||||||
|
started = time.monotonic()
|
||||||
|
# Include both event and quiet windows; source is training only. No random
|
||||||
|
# state/optimizer changes escape into the continuing learner.
|
||||||
|
quiet = [row for row in samples if not row[2].any()][:4]
|
||||||
|
# A reservoir is grouped by category: taking its first events could probe
|
||||||
|
# only zero-speed drive and falsely suggest all controls were learnable.
|
||||||
|
by_slot = [[i for i, row in enumerate(samples) if row[2][slot]] for slot in range(len(schema))]
|
||||||
|
chosen = []
|
||||||
|
for rank in range(8):
|
||||||
|
for indices in by_slot:
|
||||||
|
if rank < len(indices) and indices[rank] not in chosen and len(chosen) < 8:
|
||||||
|
chosen.append(indices[rank])
|
||||||
|
subset = [samples[i] for i in chosen] + quiet
|
||||||
|
if not chosen:
|
||||||
|
return {'status': 'No command examples', 'steps': 0}
|
||||||
|
with torch.random.fork_rng(devices=[]):
|
||||||
|
candidate = copy.deepcopy(model)
|
||||||
|
optimizer = torch.optim.AdamW(candidate.parameters(), lr=.001)
|
||||||
|
numeric_mask, weights = loss_setup(subset, schema)
|
||||||
|
images, states, targets, values, available = tensors(subset)
|
||||||
|
initial_loss = final_loss = gradient_norm = None
|
||||||
|
completed = 0
|
||||||
|
expired = lambda: cancelled() or time.monotonic() - started >= max_seconds
|
||||||
|
for _ in range(80):
|
||||||
|
if expired():
|
||||||
|
break
|
||||||
|
candidate.train()
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
logits, predictions = candidate(images, states)
|
||||||
|
event_loss, value_loss = losses(logits, predictions, targets, values, available, numeric_mask, weights)
|
||||||
|
loss = event_loss + value_loss
|
||||||
|
if not torch.isfinite(loss):
|
||||||
|
raise ValueError('Nonfinite learning probe loss')
|
||||||
|
if initial_loss is None:
|
||||||
|
initial_loss = loss.item()
|
||||||
|
loss.backward()
|
||||||
|
norm = torch.nn.utils.clip_grad_norm_(candidate.parameters(), 1)
|
||||||
|
if not torch.isfinite(norm):
|
||||||
|
raise ValueError('Nonfinite learning probe gradient')
|
||||||
|
gradient_norm = float(norm)
|
||||||
|
optimizer.step()
|
||||||
|
final_loss = loss.item()
|
||||||
|
completed += 1
|
||||||
|
result = evaluate(candidate, subset, schema, threshold, frequencies, 16, cancelled)
|
||||||
|
return {'status': 'completed' if completed == 80 else 'budget limited', 'steps': completed,
|
||||||
|
'windows': len(subset), 'initialLoss': initial_loss, 'lastStepLoss': final_loss,
|
||||||
|
'lastGradientNormBeforeClipping': gradient_norm, 'elapsedSeconds': time.monotonic() - started,
|
||||||
|
'evaluation': result, 'meaning': 'Memorization on a disposable copy of training data, not generalization'}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Shared causal 10 Hz sampling for recorded and live video."""
|
||||||
|
import math
|
||||||
|
|
||||||
|
|
||||||
|
def decision_frames(frames):
|
||||||
|
"""Yield decision time, latest past image, and its actual capture timestamp.
|
||||||
|
|
||||||
|
Sampling every third/fourth decoded frame leaves holes at rates such as 25
|
||||||
|
FPS. A fixed grid covers each 100ms command interval exactly once. A frame
|
||||||
|
after the decision can release the pending tick, but is never its input.
|
||||||
|
"""
|
||||||
|
previous = None
|
||||||
|
previous_time = None
|
||||||
|
tick = None
|
||||||
|
for frame in frames:
|
||||||
|
if frame.pts is None:
|
||||||
|
raise ValueError('Video frame has no timestamp')
|
||||||
|
stamp = float(frame.pts * frame.time_base) * 1000
|
||||||
|
if not math.isfinite(stamp) or (previous_time is not None and stamp < previous_time):
|
||||||
|
raise ValueError('Invalid/nonmonotonic video timestamps')
|
||||||
|
if tick is None:
|
||||||
|
tick = stamp
|
||||||
|
while tick < stamp - .01:
|
||||||
|
# Do not synthesize observations over missing/stalled video. History
|
||||||
|
# selection will reject a temporal gap until fresh history exists.
|
||||||
|
if previous is not None and tick - previous_time <= 200:
|
||||||
|
yield tick, previous, previous_time
|
||||||
|
elif stamp - tick > 200:
|
||||||
|
tick += max(1, math.floor((stamp - tick) / 100)) * 100
|
||||||
|
continue
|
||||||
|
tick += 100
|
||||||
|
if abs(stamp - tick) <= .01:
|
||||||
|
yield tick, frame, stamp
|
||||||
|
tick += 100
|
||||||
|
previous, previous_time = frame, stamp
|
||||||
|
|
||||||
|
|
||||||
|
def history_frames(history, stamp):
|
||||||
|
selected = []
|
||||||
|
for offset in (900, 600, 300, 0):
|
||||||
|
candidates = [row for row in history if row[0] <= stamp - offset + .01]
|
||||||
|
if not candidates or stamp - offset - candidates[-1][0] > 200:
|
||||||
|
return []
|
||||||
|
selected.append(candidates[-1])
|
||||||
|
return selected
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
"""Versioned, fixed-size vision/control policy and command encoding.
|
||||||
|
|
||||||
|
Slots describe command shapes, not arbitrary executable instructions. Numeric
|
||||||
|
leaves are regressed; string/bool/list values remain exact template constants.
|
||||||
|
No command is sent by this module. Execution must use server authorization.
|
||||||
|
"""
|
||||||
|
import base64
|
||||||
|
import itertools
|
||||||
|
import copy
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import torch
|
||||||
|
from torch import nn
|
||||||
|
|
||||||
|
VERSION = 5
|
||||||
|
SLOTS = 64
|
||||||
|
VALUES = 8
|
||||||
|
FRAMES = 4
|
||||||
|
HEIGHT, WIDTH = 120, 160
|
||||||
|
OPERATING_TYPES = {"drive", "servo", "headlight", "raw"}
|
||||||
|
MODE_COMMANDS = tuple(base64.b64encode(bytes([opcode])).decode() for opcode in (128, 131, 132, 143))
|
||||||
|
SENSORS = [
|
||||||
|
("bumpsAndWheelDrops.bumpLeft", 1), ("bumpsAndWheelDrops.bumpRight", 1),
|
||||||
|
("bumpsAndWheelDrops.wheelDropLeft", 1), ("bumpsAndWheelDrops.wheelDropRight", 1),
|
||||||
|
("cliffLeft", 1), ("cliffFrontLeft", 1), ("cliffFrontRight", 1), ("cliffRight", 1),
|
||||||
|
("wall", 1), ("virtualWall", 1), ("wallSignal", 4096),
|
||||||
|
("voltageMv", 16000), ("currentMa", 3000), ("batteryChargeMah", 4000),
|
||||||
|
("batteryCapacityMah", 4000), ("batteryTemperatureC", 50),
|
||||||
|
("chargingState.code", 5), ("chargingSources.homeBase", 1), ("oiMode.code", 4),
|
||||||
|
("wheelLeftCurrentMa", 2000), ("wheelRightCurrentMa", 2000),
|
||||||
|
("mainBrushCurrentMa", 2000), ("sideBrushCurrentMa", 2000),
|
||||||
|
("lightBumpLeftSignal", 4096), ("lightBumpRightSignal", 4096),
|
||||||
|
("cliffLeftSignal", 4096), ("cliffRightSignal", 4096),
|
||||||
|
("distanceMm", 500), ("angleDeg", 180), ("stasis", 1),
|
||||||
|
]
|
||||||
|
STATE_SIZE = len(SENSORS) * 2 + SLOTS * (VALUES + 2)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize(value, scale):
|
||||||
|
return math.atan(float(value) / scale) * 2 / math.pi
|
||||||
|
|
||||||
|
|
||||||
|
def sensor_vector(sensors):
|
||||||
|
values, present = [], []
|
||||||
|
for name, scale in SENSORS:
|
||||||
|
value = sensors
|
||||||
|
for key in name.split('.'):
|
||||||
|
value = value.get(key) if isinstance(value, dict) else None
|
||||||
|
known = isinstance(value, (int, float)) and math.isfinite(value)
|
||||||
|
values.append(normalize(value, scale) if known else 0)
|
||||||
|
present.append(float(known))
|
||||||
|
return np.array(values + present, dtype=np.float32)
|
||||||
|
|
||||||
|
|
||||||
|
def describe_command(command):
|
||||||
|
if command.get('type') not in OPERATING_TYPES:
|
||||||
|
return None
|
||||||
|
# Raw is restricted to the existing mode/dock buttons, not arbitrary OI
|
||||||
|
# packets that could reintroduce excluded actuators or songs.
|
||||||
|
if command['type'] == 'raw' and command.get('raw') not in MODE_COMMANDS:
|
||||||
|
return None
|
||||||
|
if len(json.dumps(command)) > 4096:
|
||||||
|
raise ValueError('Operating command exceeds 4096-byte schema limit')
|
||||||
|
values, fields = [], []
|
||||||
|
|
||||||
|
def visit(value, path=()):
|
||||||
|
if isinstance(value, dict):
|
||||||
|
return {key: visit(item, (*path, key)) for key, item in sorted(value.items())}
|
||||||
|
# Exact actuator zeros preserve demonstrated releases.
|
||||||
|
if value == 0:
|
||||||
|
return copy.deepcopy(value)
|
||||||
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||||
|
if not math.isfinite(value) or len(values) >= VALUES:
|
||||||
|
raise ValueError('Nonfinite command or too many numeric control fields')
|
||||||
|
scale = {'drive': 500, 'servo': 180}.get(command['type'], 1)
|
||||||
|
fields.append({'path': list(path), 'scale': scale, 'integer': command['type'] in ('drive', 'servo') or isinstance(value, int)})
|
||||||
|
values.append(normalize(value, scale))
|
||||||
|
return {'$number': len(values) - 1}
|
||||||
|
# Mode bytes and named actions remain exact constants.
|
||||||
|
return copy.deepcopy(value)
|
||||||
|
|
||||||
|
template = visit(command)
|
||||||
|
return {'template': template, 'fields': fields}, values
|
||||||
|
|
||||||
|
|
||||||
|
def encode_command(command, schema, extend=False):
|
||||||
|
described = describe_command(command)
|
||||||
|
if described is None:
|
||||||
|
return None
|
||||||
|
descriptor, values = described
|
||||||
|
try:
|
||||||
|
slot = schema.index(descriptor)
|
||||||
|
except ValueError:
|
||||||
|
if not extend:
|
||||||
|
raise ValueError('Command shape missing from model schema')
|
||||||
|
if len(schema) >= SLOTS:
|
||||||
|
raise ValueError('64 command shapes exhausted; affected windows excluded')
|
||||||
|
slot = len(schema)
|
||||||
|
schema.append(descriptor)
|
||||||
|
vector = np.zeros(VALUES, dtype=np.float32)
|
||||||
|
vector[:len(values)] = values
|
||||||
|
return slot, vector
|
||||||
|
|
||||||
|
|
||||||
|
def initial_schema():
|
||||||
|
# Match the retained commandPipeline.js controls. Values describe fields,
|
||||||
|
# not artificial demonstrations; zero-speed drive remains a drive command.
|
||||||
|
commands = []
|
||||||
|
for left, right in itertools.product((0, 1), repeat=2):
|
||||||
|
commands.append({'type': 'drive', 'driveDirect': {'left': left, 'right': right}})
|
||||||
|
for angle in (0, 1):
|
||||||
|
commands.append({'type': 'servo', 'servo': {'angle': angle}})
|
||||||
|
for action in ('on', 'off', 'toggle'):
|
||||||
|
commands.append({'type': 'headlight', 'headlight': {'action': action}})
|
||||||
|
for raw in MODE_COMMANDS:
|
||||||
|
commands.append({'type': 'raw', 'raw': raw})
|
||||||
|
schema = []
|
||||||
|
for command in commands:
|
||||||
|
encode_command(command, schema, extend=True)
|
||||||
|
return schema
|
||||||
|
|
||||||
|
|
||||||
|
def command_family(command):
|
||||||
|
return (command['type'],)
|
||||||
|
|
||||||
|
|
||||||
|
def select_slots(scores, schema, threshold):
|
||||||
|
winners = {}
|
||||||
|
for slot, descriptor in enumerate(schema):
|
||||||
|
if scores[slot] < threshold:
|
||||||
|
continue
|
||||||
|
family = command_family(descriptor['template'])
|
||||||
|
if family not in winners or scores[slot] > scores[winners[family]]:
|
||||||
|
winners[family] = slot
|
||||||
|
return set(winners.values())
|
||||||
|
|
||||||
|
|
||||||
|
def decode_command(descriptor, values):
|
||||||
|
command = copy.deepcopy(descriptor['template'])
|
||||||
|
for index, field in enumerate(descriptor['fields']):
|
||||||
|
number = float(values[index])
|
||||||
|
if not math.isfinite(number):
|
||||||
|
raise ValueError('Nonfinite control value')
|
||||||
|
number = math.tan(max(-.98, min(.98, number)) * math.pi / 2) * field['scale']
|
||||||
|
target = command
|
||||||
|
for key in field['path'][:-1]:
|
||||||
|
target = target[key]
|
||||||
|
target[field['path'][-1]] = round(number) if field['integer'] else number
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
|
def is_stop(descriptor):
|
||||||
|
command = descriptor['template']
|
||||||
|
direct = command.get('driveDirect') or {}
|
||||||
|
return command['type'] == 'drive' and direct.get('left') == 0 and direct.get('right') == 0
|
||||||
|
|
||||||
|
|
||||||
|
class Policy(nn.Module):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.vision = nn.Sequential(
|
||||||
|
nn.Conv2d(3, 24, 5, 2, 2), nn.ReLU(),
|
||||||
|
nn.Conv2d(24, 48, 3, 2, 1), nn.ReLU(),
|
||||||
|
nn.Conv2d(48, 96, 3, 2, 1), nn.ReLU(),
|
||||||
|
nn.Conv2d(96, 128, 3, 2, 1), nn.ReLU(),
|
||||||
|
nn.AdaptiveAvgPool2d((3, 4)), nn.Flatten(),
|
||||||
|
nn.Linear(128 * 3 * 4, 128), nn.ReLU(),
|
||||||
|
)
|
||||||
|
self.history = nn.GRU(128 + STATE_SIZE, 128, batch_first=True)
|
||||||
|
self.actions = nn.Linear(128, SLOTS)
|
||||||
|
self.values = nn.Linear(128, SLOTS * VALUES)
|
||||||
|
|
||||||
|
def forward(self, images, state):
|
||||||
|
batch, frames = images.shape[:2]
|
||||||
|
encoded = self.vision(images.reshape(-1, 3, HEIGHT, WIDTH)).reshape(batch, frames, -1)
|
||||||
|
history, _ = self.history(torch.cat((encoded, state), dim=-1))
|
||||||
|
last = history[:, -1]
|
||||||
|
return self.actions(last), self.values(last).reshape(batch, SLOTS, VALUES).tanh()
|
||||||
|
|
||||||
|
|
||||||
|
def specification(schema):
|
||||||
|
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-events-v5',
|
||||||
|
'frames': FRAMES, 'height': HEIGHT, 'width': WIDTH,
|
||||||
|
'historyStrideMs': 300, 'decisionIntervalMs': 100,
|
||||||
|
'stateSize': STATE_SIZE, 'sensorFields': SENSORS,
|
||||||
|
'normalization': '2/pi * atan(value/scale); missing sensors have mask=0',
|
||||||
|
'maxCommandShapes': SLOTS, 'numericFieldsPerShape': VALUES,
|
||||||
|
'commands': schema, 'actionSemantics': 'All controls are command events in the next 100ms; no event preserves existing rover state',
|
||||||
|
'driveCommandHistoryInput': False, 'zeroNumericValues': 'Exact template constants',
|
||||||
|
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
--extra-index-url https://download.pytorch.org/whl/cpu
|
||||||
|
torch==2.14.0+cpu
|
||||||
|
numpy==2.5.3
|
||||||
|
av==18.1.0
|
||||||
@@ -0,0 +1,353 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""One finite CPU training job; Node owns scheduling, leases, and shutdown.
|
||||||
|
|
||||||
|
Usage: python train.py --job /path/to/job.json
|
||||||
|
Dependencies are packaged at image build time, never installed at runtime.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import copy
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
import random
|
||||||
|
import resource
|
||||||
|
import signal
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def emit(kind, **data):
|
||||||
|
print(json.dumps({'kind': kind, **data}, allow_nan=False), flush=True)
|
||||||
|
|
||||||
|
|
||||||
|
def atomic_torch_save(torch, value, path):
|
||||||
|
temporary = path.with_suffix('.tmp')
|
||||||
|
with temporary.open('wb') as stream:
|
||||||
|
torch.save(value, stream)
|
||||||
|
stream.flush()
|
||||||
|
os.fsync(stream.fileno())
|
||||||
|
temporary.replace(path)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--job')
|
||||||
|
parser.add_argument('--check-dependencies', action='store_true', help='Describe the installed production policy and exit without creating storage')
|
||||||
|
args = parser.parse_args()
|
||||||
|
# --help works even before Python dependencies are installed.
|
||||||
|
import numpy as np
|
||||||
|
import av
|
||||||
|
import torch
|
||||||
|
from learning import loss_setup, tensors, losses as policy_losses, evaluate, probe
|
||||||
|
from dataset import windows
|
||||||
|
from policy import Policy, VERSION, SLOTS, VALUES, specification, is_stop, select_slots, initial_schema
|
||||||
|
|
||||||
|
if args.check_dependencies:
|
||||||
|
emit('dependencies', torch=str(torch.__version__), numpy=str(np.__version__), av=str(av.__version__),
|
||||||
|
parameters=sum(p.numel() for p in Policy().parameters()), specification=specification(initial_schema()))
|
||||||
|
return
|
||||||
|
if not args.job:
|
||||||
|
parser.error('--job is required unless --check-dependencies is used')
|
||||||
|
|
||||||
|
job = json.loads(Path(args.job).read_text())
|
||||||
|
root = Path(job['root'])
|
||||||
|
config = job['config']
|
||||||
|
torch.set_num_threads(config['threads'])
|
||||||
|
torch.set_num_interop_threads(1)
|
||||||
|
# Training yields CPU priority to the Node/media processes. Thread count is
|
||||||
|
# an additional bound, not a claim to reserve specific physical cores.
|
||||||
|
os.nice(10)
|
||||||
|
stopped = False
|
||||||
|
|
||||||
|
def stop(_signal, _frame):
|
||||||
|
nonlocal stopped
|
||||||
|
stopped = True
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, stop)
|
||||||
|
signal.signal(signal.SIGINT, stop)
|
||||||
|
started = time.monotonic()
|
||||||
|
cancelled = lambda: stopped or time.monotonic() - started >= config['maxJobSeconds']
|
||||||
|
model = Policy().cpu()
|
||||||
|
optimizer = torch.optim.AdamW(model.parameters(), lr=0.0001)
|
||||||
|
# The command-event policy has different inputs, targets and weights. Keep its
|
||||||
|
# optimizer lineage separate; reuse recordings with their unchanged partition.
|
||||||
|
resume_path = root / 'training' / 'resume-v6.pt'
|
||||||
|
schema, steps, examples, last_published = initial_schema(), 0, 0, 0
|
||||||
|
trained_rovers = set()
|
||||||
|
control_profiles = []
|
||||||
|
distinct_windows = {}
|
||||||
|
published_windows = 0
|
||||||
|
last_probe_steps, learning_probe = 0, None
|
||||||
|
if resume_path.exists():
|
||||||
|
saved = torch.load(resume_path, map_location='cpu', weights_only=True)
|
||||||
|
if saved['version'] != VERSION:
|
||||||
|
raise ValueError('Resume schema incompatible; refusing to reinterpret existing weights')
|
||||||
|
model.load_state_dict(saved['weights'])
|
||||||
|
optimizer.load_state_dict(saved['optimizer'])
|
||||||
|
schema = saved['schema']
|
||||||
|
steps, examples = saved['steps'], saved['examples']
|
||||||
|
last_published = saved['lastPublished']
|
||||||
|
trained_rovers = set(saved['trainedRovers'])
|
||||||
|
control_profiles = saved.get('controlProfiles', [])
|
||||||
|
distinct_windows = saved.get('distinctWindows', {})
|
||||||
|
published_windows = saved.get('publishedWindows', 0)
|
||||||
|
last_probe_steps = saved.get('lastProbeSteps', 0)
|
||||||
|
learning_probe = saved.get('learningProbe')
|
||||||
|
cpu_started = time.process_time()
|
||||||
|
decode_started = time.monotonic()
|
||||||
|
dataset_stats = []
|
||||||
|
initial_steps = steps
|
||||||
|
pool, validation, accepted, rejected = [], [], [], {}
|
||||||
|
observed_windows = {}
|
||||||
|
sessions = list(job['sessions'])
|
||||||
|
random.shuffle(sessions)
|
||||||
|
is_held_out = lambda session_id: int(hashlib.sha256(session_id.encode()).hexdigest()[:8], 16) % 5 == 0
|
||||||
|
sessions.sort(key=is_held_out)
|
||||||
|
per_session = max(1, config['maxSamples'] // max(1, len(sessions)))
|
||||||
|
for session_id in sessions:
|
||||||
|
if cancelled():
|
||||||
|
break
|
||||||
|
candidate_schema = copy.deepcopy(schema)
|
||||||
|
samples, seen = [], 0
|
||||||
|
stats = {}
|
||||||
|
buckets = {key: [] for key in ('stop', 'command', 'none')}
|
||||||
|
counts = dict.fromkeys(buckets, 0)
|
||||||
|
held_out = is_held_out(session_id)
|
||||||
|
try:
|
||||||
|
directory = root / 'recordings' / session_id
|
||||||
|
for sample in windows(directory, candidate_schema, cancelled, stats, extend=not held_out):
|
||||||
|
seen += 1
|
||||||
|
# Keep evaluation naturally distributed. Training reserves space
|
||||||
|
# for recorded commands and releases so periods without commands
|
||||||
|
# cannot consume the entire sample budget. No scripted driving required.
|
||||||
|
if held_out:
|
||||||
|
reservoir, count, capacity = samples, seen, per_session
|
||||||
|
else:
|
||||||
|
category = ('stop' if any(sample[2][slot] and is_stop(descriptor)
|
||||||
|
for slot, descriptor in enumerate(candidate_schema)) else
|
||||||
|
'command' if sample[2].any() else 'none')
|
||||||
|
counts[category] += 1
|
||||||
|
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 3)
|
||||||
|
if len(reservoir) < capacity:
|
||||||
|
reservoir.append(sample)
|
||||||
|
else:
|
||||||
|
index = random.randrange(count)
|
||||||
|
if index < capacity:
|
||||||
|
reservoir[index] = sample
|
||||||
|
if not held_out:
|
||||||
|
samples = [sample for bucket in buckets.values() for sample in bucket]
|
||||||
|
if cancelled():
|
||||||
|
break # Do not call a partially decoded recording validated.
|
||||||
|
if not samples:
|
||||||
|
if held_out and stats.get('unsupportedCommandEvents'):
|
||||||
|
dataset_stats.append({**stats, 'partition': 'heldOut', 'sampledWindows': 0,
|
||||||
|
'skipReason': 'No supported aligned windows'})
|
||||||
|
continue
|
||||||
|
raise ValueError('No complete, aligned observation/action windows')
|
||||||
|
# Entire sessions stay in one partition across all jobs. Neighboring
|
||||||
|
# video windows must never straddle training and validation.
|
||||||
|
held_out = is_held_out(session_id)
|
||||||
|
if held_out:
|
||||||
|
validation.extend(samples)
|
||||||
|
else:
|
||||||
|
schema = candidate_schema
|
||||||
|
pool.extend(samples)
|
||||||
|
observed_windows[session_id] = seen
|
||||||
|
accepted.append(session_id)
|
||||||
|
metadata = json.loads((directory / 'session.json').read_text())
|
||||||
|
if not held_out:
|
||||||
|
trained_rovers.add(str(metadata['roverId']))
|
||||||
|
profile = metadata.get('controlProfile')
|
||||||
|
if not held_out and profile is not None and profile not in control_profiles:
|
||||||
|
control_profiles.append(profile)
|
||||||
|
stats.update({'partition': 'heldOut' if held_out else 'training', 'sampledWindows': len(samples)})
|
||||||
|
dataset_stats.append(stats)
|
||||||
|
emit('dataset', sessionId=session_id, windows=seen, sampled=len(samples), diagnostics=stats)
|
||||||
|
except (ValueError, KeyError, TypeError, OSError, EOFError, IndexError, OverflowError, av.error.FFmpegError) as error:
|
||||||
|
rejected[session_id] = str(error)[:500]
|
||||||
|
emit('skipped', sessionId=session_id, reason=rejected[session_id])
|
||||||
|
if len(pool) < config['minimumSamples'] or not any(sample[2].any() for sample in pool) or cancelled():
|
||||||
|
emit('complete', trained=False, accepted=[], rejected=rejected, samples=len(pool),
|
||||||
|
diagnostics={'dataset': dataset_stats, 'decodeSeconds': time.monotonic() - decode_started},
|
||||||
|
reason='Stopped/time limit' if cancelled() else 'Waiting for enough training-partition samples with human actions')
|
||||||
|
return
|
||||||
|
|
||||||
|
decode_seconds = time.monotonic() - decode_started
|
||||||
|
audit_started = time.monotonic()
|
||||||
|
numeric_mask, positive_weight = loss_setup(pool, schema)
|
||||||
|
training_rows = [row for row in dataset_stats if row.get('partition') == 'training']
|
||||||
|
natural_count = sum(row.get('usableWindows', 0) for row in training_rows)
|
||||||
|
frequencies = np.sum([row.get('commandEventCounts', [0] * SLOTS) for row in training_rows], axis=0) / max(1, natural_count)
|
||||||
|
audit_subset = pool[::max(1, len(pool) // 96)][:96]
|
||||||
|
before_training = evaluate(model, audit_subset, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
|
||||||
|
before_audit_seconds = time.monotonic() - audit_started
|
||||||
|
train_started = time.monotonic()
|
||||||
|
gradient_norms = []
|
||||||
|
losses = []
|
||||||
|
model.train()
|
||||||
|
for _ in range(config['passesPerJob']):
|
||||||
|
random.shuffle(pool)
|
||||||
|
for offset in range(0, len(pool), config['batchSize']):
|
||||||
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||||
|
break
|
||||||
|
batch = pool[offset:offset + config['batchSize']]
|
||||||
|
images, states, targets, values, available = tensors(batch)
|
||||||
|
optimizer.zero_grad(set_to_none=True)
|
||||||
|
action_logits, predictions = model(images, states)
|
||||||
|
event_loss, value_loss = policy_losses(action_logits, predictions, targets, values, available, numeric_mask, positive_weight)
|
||||||
|
loss = event_loss + value_loss
|
||||||
|
if not torch.isfinite(loss):
|
||||||
|
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
||||||
|
loss.backward()
|
||||||
|
gradient_norm = torch.nn.utils.clip_grad_norm_(model.parameters(), 1)
|
||||||
|
if not torch.isfinite(gradient_norm):
|
||||||
|
raise ValueError('Nonfinite training gradient; checkpoint not saved')
|
||||||
|
gradient_norms.append(float(gradient_norm))
|
||||||
|
optimizer.step()
|
||||||
|
steps += 1
|
||||||
|
examples += len(batch)
|
||||||
|
losses.append(loss.item())
|
||||||
|
if steps % 10 == 0:
|
||||||
|
emit('progress', steps=steps, examples=examples, loss=loss.item(),
|
||||||
|
losses={'events': event_loss.item(), 'commandValues': value_loss.item()})
|
||||||
|
if cancelled() or steps - initial_steps >= config['maxStepsPerJob']:
|
||||||
|
break
|
||||||
|
if steps == initial_steps:
|
||||||
|
emit('complete', trained=False, accepted=[], rejected=rejected, reason='No training steps completed')
|
||||||
|
return
|
||||||
|
|
||||||
|
training_seconds = time.monotonic() - train_started
|
||||||
|
evaluation_started = time.monotonic()
|
||||||
|
distinct_windows.update(observed_windows)
|
||||||
|
unique_count = sum(distinct_windows.values())
|
||||||
|
coverage = [row for row in dataset_stats if row.get('partition') == 'heldOut']
|
||||||
|
unsupported_events = sum(row.get('unsupportedCommandEvents', 0) for row in coverage)
|
||||||
|
unsupported_windows = sum(row.get('unsupportedWindows', 0) for row in coverage)
|
||||||
|
evaluation = {'status': 'No supported held-out windows' if unsupported_events and not validation else 'waiting for held-out sessions',
|
||||||
|
'windows': len(validation), 'unsupportedCommandEvents': unsupported_events,
|
||||||
|
'unsupportedWindows': unsupported_windows}
|
||||||
|
if validation and not cancelled():
|
||||||
|
measured = evaluate(model, validation, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
|
||||||
|
evaluated = measured['windows']
|
||||||
|
precision, recall = measured.get('precision', 0), measured.get('recall', 0)
|
||||||
|
actual_positive = measured.get('positiveActions', 0)
|
||||||
|
mse = measured.get('numericMse', 0)
|
||||||
|
stop_actual = measured.get('stopEvents', 0)
|
||||||
|
stop_recall = measured.get('stopRecall', 0)
|
||||||
|
drive_actual = measured.get('movementEvents', 0)
|
||||||
|
drive_recall = measured.get('movementRecall', 0)
|
||||||
|
frequency_f1 = max((row['f1'] for row in measured.get('baselines', {}).get('trainingFrequency', [])), default=0)
|
||||||
|
held_out_sessions = sum(row.get('partition') == 'heldOut' and not row.get('skipReason') for row in dataset_stats)
|
||||||
|
gate_reasons = []
|
||||||
|
for condition, reason in [
|
||||||
|
(unsupported_events == 0, 'Held-out controls are not fully represented; see unsupportedCommands'),
|
||||||
|
(evaluated == len(validation), 'Evaluation interrupted'),
|
||||||
|
(held_out_sessions >= 2, 'Need two usable held-out recordings'),
|
||||||
|
(evaluated >= 128, 'Need 128 held-out windows'),
|
||||||
|
(actual_positive >= 32, 'Need 32 held-out command events'),
|
||||||
|
(stop_actual >= 8, 'Need eight held-out stop events'),
|
||||||
|
(drive_actual >= 16, 'Need 16 held-out movement commands'),
|
||||||
|
(drive_recall >= .5, 'Movement command recall below 0.5'),
|
||||||
|
(precision >= .5, 'Command precision below 0.5'),
|
||||||
|
(recall >= .5, 'Command recall below 0.5'),
|
||||||
|
(stop_recall >= .5, 'Stop recall below 0.5'),
|
||||||
|
(measured.get('f1', 0) > frequency_f1, 'Command F1 does not beat the training-frequency baseline'),
|
||||||
|
(mse <= .1, 'Command parameter error above 0.1'),
|
||||||
|
]:
|
||||||
|
if not condition:
|
||||||
|
gate_reasons.append(reason)
|
||||||
|
evaluation = {**measured, 'status': 'imitation gate not met' if gate_reasons else 'passed imitation gate',
|
||||||
|
'unsupportedCommandEvents': unsupported_events, 'unsupportedWindows': unsupported_windows,
|
||||||
|
'method': 'Isolated held-out 100ms events; live selection, no temporal latch/physical rollout',
|
||||||
|
'threshold': job['actionThreshold'], 'heldOutSessions': held_out_sessions,
|
||||||
|
'gateReasons': gate_reasons}
|
||||||
|
after_training = evaluate(model, audit_subset, schema, job['actionThreshold'], frequencies, config['batchSize'], cancelled)
|
||||||
|
probe_seconds = 0
|
||||||
|
if not cancelled() and (learning_probe is None or steps - last_probe_steps >= 1000):
|
||||||
|
remaining = config['maxJobSeconds'] - (time.monotonic() - started)
|
||||||
|
if remaining > 10:
|
||||||
|
probe_started = time.monotonic()
|
||||||
|
learning_probe = probe(model, audit_subset, schema, job['actionThreshold'], frequencies,
|
||||||
|
cancelled, max_seconds=min(20, remaining / 2))
|
||||||
|
learning_probe['atTrainingStep'] = steps
|
||||||
|
last_probe_steps = steps
|
||||||
|
probe_seconds = time.monotonic() - probe_started
|
||||||
|
training_audit = {'before': before_training, 'after': after_training,
|
||||||
|
'gradientNormBeforeClipping': {'min': min(gradient_norms), 'max': max(gradient_norms)},
|
||||||
|
'positiveWeights': positive_weight[:len(schema)].tolist(),
|
||||||
|
'naturalTrainingCommandFrequency': frequencies[:len(schema)].tolist(),
|
||||||
|
'sampledCommandFrequency': np.mean([row[2] for row in pool], axis=0)[:len(schema)].tolist(),
|
||||||
|
'learningProbe': learning_probe}
|
||||||
|
evaluation_seconds = time.monotonic() - evaluation_started - probe_seconds
|
||||||
|
publication = {'distinctMinutes': unique_count / 600,
|
||||||
|
'newMinutes': (unique_count - published_windows) / 600,
|
||||||
|
'minimumMinutes': config['minimumDrivingMinutes'],
|
||||||
|
'requiredNewMinutes': config['newDrivingMinutesPerModel']}
|
||||||
|
reasons = []
|
||||||
|
if publication['distinctMinutes'] < config['minimumDrivingMinutes']:
|
||||||
|
reasons.append('Collecting distinct driving footage')
|
||||||
|
if publication['newMinutes'] < config['newDrivingMinutesPerModel']:
|
||||||
|
reasons.append('Waiting for new driving footage since last publication')
|
||||||
|
if steps - last_published < config['checkpointEverySteps']:
|
||||||
|
reasons.append('Training steps below publication interval')
|
||||||
|
if evaluation['status'] != 'passed imitation gate':
|
||||||
|
reasons.append(evaluation['status'])
|
||||||
|
if cancelled():
|
||||||
|
reasons.append('Job time budget reached')
|
||||||
|
publication['reason'] = '; '.join(reasons) or 'Latest updated'
|
||||||
|
save_started = time.monotonic()
|
||||||
|
published = None
|
||||||
|
# Every completed job exports an immutable candidate; Node owns promotion and retention.
|
||||||
|
adjectives = ['amber', 'curious', 'gentle', 'quiet', 'silver', 'bright', 'merry', 'sleepy']
|
||||||
|
animals = ['otter', 'finch', 'badger', 'robin', 'fox', 'heron', 'marten', 'wren']
|
||||||
|
model_id = str(uuid.uuid4())
|
||||||
|
name = f'{random.choice(adjectives)}-{random.choice(animals)}-{model_id[:8]}'
|
||||||
|
checkpoint_root = root / 'training' / 'checkpoints'
|
||||||
|
checkpoint_root.mkdir(exist_ok=True)
|
||||||
|
staging = checkpoint_root / f'.{model_id}.pending'
|
||||||
|
staging.mkdir()
|
||||||
|
weights_path = staging / 'weights.pt'
|
||||||
|
atomic_torch_save(torch, model.state_dict(), weights_path)
|
||||||
|
published = {
|
||||||
|
'id': model_id, 'name': name, 'createdAt': int(time.time() * 1000),
|
||||||
|
'trainingSteps': steps, 'examplesProcessed': examples,
|
||||||
|
'parameters': sum(p.numel() for p in model.parameters()),
|
||||||
|
'weightBytes': weights_path.stat().st_size,
|
||||||
|
'sha256': hashlib.sha256(weights_path.read_bytes()).hexdigest(),
|
||||||
|
'specification': specification(schema), 'trainedRovers': sorted(trained_rovers),
|
||||||
|
'controlProfiles': control_profiles,
|
||||||
|
'sourceSessions': accepted, 'trainingLoss': sum(losses) / len(losses),
|
||||||
|
'evaluation': evaluation['status'], 'validation': evaluation, 'distinctMinutes': publication['distinctMinutes'], 'dtype': 'float32', 'torchVersion': str(torch.__version__),
|
||||||
|
}
|
||||||
|
(staging / 'model.json').write_text(json.dumps(published, indent=2, allow_nan=False))
|
||||||
|
# A catalog reader sees both weights and metadata, or neither. Existing
|
||||||
|
# versions are never overwritten or deleted by the trainer.
|
||||||
|
staging.rename(checkpoint_root / model_id)
|
||||||
|
if not reasons:
|
||||||
|
last_published = steps
|
||||||
|
published_windows = unique_count
|
||||||
|
atomic_torch_save(torch, {
|
||||||
|
'version': VERSION, 'weights': model.state_dict(), 'optimizer': optimizer.state_dict(),
|
||||||
|
'schema': schema, 'steps': steps, 'examples': examples,
|
||||||
|
'lastPublished': last_published, 'trainedRovers': sorted(trained_rovers),
|
||||||
|
'controlProfiles': control_profiles, 'distinctWindows': distinct_windows,
|
||||||
|
'publishedWindows': published_windows, 'lastProbeSteps': last_probe_steps, 'learningProbe': learning_probe,
|
||||||
|
}, resume_path)
|
||||||
|
diagnostics = {'dataset': dataset_stats, 'trainingAudit': training_audit, 'probeSeconds': probe_seconds, 'beforeAuditSeconds': before_audit_seconds, 'decodeSeconds': decode_seconds,
|
||||||
|
'trainingSeconds': training_seconds, 'evaluationSeconds': evaluation_seconds, 'checkpointSeconds': time.monotonic() - save_started,
|
||||||
|
'cpuSeconds': time.process_time() - cpu_started,
|
||||||
|
'peakRssMiB': resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024,
|
||||||
|
'dependencies': {'torch': str(torch.__version__), 'numpy': str(np.__version__), 'av': str(av.__version__)},
|
||||||
|
'policy': specification(schema), 'browserVideoLatencyMs': None}
|
||||||
|
emit('complete', trained=True, accepted=accepted, rejected=rejected,
|
||||||
|
steps=steps, examples=examples, loss=sum(losses) / len(losses),
|
||||||
|
published=published, promote=not reasons, publication=publication, evaluation=evaluation, diagnostics=diagnostics, elapsedSeconds=time.monotonic() - started)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
try:
|
||||||
|
main()
|
||||||
|
except Exception as error:
|
||||||
|
emit('error', message=str(error))
|
||||||
|
raise SystemExit(1)
|
||||||
@@ -274,6 +274,7 @@ odometerService.odometerEvents.on('update', ({ roverId, odometer }) => {
|
|||||||
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
|
setInterval(tickPrivateAutoClose, PRIVATE_AUTO_CLOSE_TICK_MS);
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
stopDockGuard,
|
||||||
upsertRover,
|
upsertRover,
|
||||||
removeRover,
|
removeRover,
|
||||||
lockRover,
|
lockRover,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ function createRosterLifecycle(deps) {
|
|||||||
offline, while removeRoverDrivers still receives the captured record so
|
offline, while removeRoverDrivers still receives the captured record so
|
||||||
it can clean the reverse membership index and Socket.IO room membership.
|
it can clean the reverse membership index and Socket.IO room membership.
|
||||||
*/
|
*/
|
||||||
|
require('../dockingService').disconnect(record);
|
||||||
rovers.delete(id);
|
rovers.delete(id);
|
||||||
removeRoverDrivers(id, record);
|
removeRoverDrivers(id, record);
|
||||||
stopDockGuard(id);
|
stopDockGuard(id);
|
||||||
|
|||||||
@@ -174,6 +174,8 @@ function createRoverLifecycle(deps) {
|
|||||||
|
|
||||||
function canLeaveCurrentRover(socket, options = {}) {
|
function canLeaveCurrentRover(socket, options = {}) {
|
||||||
const currentId = getPrimaryRoverForSocket(socket.id);
|
const currentId = getPrimaryRoverForSocket(socket.id);
|
||||||
|
// Admins, including lockdown admins, can always leave their current rover.
|
||||||
|
if (isAdmin(socket)) return { ok: true, currentId };
|
||||||
/*
|
/*
|
||||||
Leaving a rover is only risky when this socket is the last person attached
|
Leaving a rover is only risky when this socket is the last person attached
|
||||||
to an undocked rover. The same rule is used for rover-to-rover switching
|
to an undocked rover. The same rule is used for rover-to-rover switching
|
||||||
|
|||||||
@@ -389,6 +389,10 @@ function createSensorPipeline(deps) {
|
|||||||
|
|
||||||
function evaluateDockGuard(record, sensors) {
|
function evaluateDockGuard(record, sensors) {
|
||||||
if (!record || !sensors) return;
|
if (!record || !sensors) return;
|
||||||
|
if (sensors.docking?.phase === 'undocking' || sensors.docking?.phase === 'assisting') {
|
||||||
|
stopDockGuard(record.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const state = getDockGuardState(record.id);
|
const state = getDockGuardState(record.id);
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
const docked = Boolean(sensors?.chargingSources?.homeBase);
|
||||||
@@ -564,6 +568,10 @@ function createSensorPipeline(deps) {
|
|||||||
};
|
};
|
||||||
record.lastSensor = { raw: frame, decoded };
|
record.lastSensor = { raw: frame, decoded };
|
||||||
}
|
}
|
||||||
|
if (decoded) {
|
||||||
|
decoded = { ...decoded, docking: require('../dockingService').processTelemetry(record, decoded) };
|
||||||
|
record.lastSensor = { raw: frame, decoded };
|
||||||
|
}
|
||||||
/*
|
/*
|
||||||
Rover manager remains responsible only for decoding and routing sensor
|
Rover manager remains responsible only for decoding and routing sensor
|
||||||
frames. The dedicated service receives the completed sensor object after
|
frames. The dedicated service receives the completed sensor object after
|
||||||
@@ -577,6 +585,7 @@ function createSensorPipeline(deps) {
|
|||||||
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
||||||
? processOvercurrentTelemetry(roverId, decoded)
|
? processOvercurrentTelemetry(roverId, decoded)
|
||||||
: null;
|
: null;
|
||||||
|
record.lastOvercurrentProtection = overcurrentProtection;
|
||||||
updateMovement(record, decoded);
|
updateMovement(record, decoded);
|
||||||
const hasDockInfo = decoded?.chargingSources != null;
|
const hasDockInfo = decoded?.chargingSources != null;
|
||||||
if (hasDockInfo) {
|
if (hasDockInfo) {
|
||||||
@@ -589,6 +598,10 @@ function createSensorPipeline(deps) {
|
|||||||
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
||||||
handlePrivateButtonHold(record, decoded);
|
handlePrivateButtonHold(record, decoded);
|
||||||
evaluatePrivateSafety(record, decoded);
|
evaluatePrivateSafety(record, decoded);
|
||||||
|
if (decoded) {
|
||||||
|
decoded = { ...decoded, docking: require('../dockingService').snapshot(record) };
|
||||||
|
record.lastSensor = { raw: frame, decoded };
|
||||||
|
}
|
||||||
io.to(record.room).volatile.emit('sensorFrame', {
|
io.to(record.room).volatile.emit('sensorFrame', {
|
||||||
roverId,
|
roverId,
|
||||||
frame,
|
frame,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Restricted Container Lifecycle Controller
|
// Restricted Container Lifecycle Controller
|
||||||
// Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API.
|
// Purpose: Pulls and replaces only the fixed MultiRover application container through Docker's local API.
|
||||||
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts no caller-selected targets.
|
// Scope: Runs as the private lifecycle Compose service; it exposes no TCP listener and accepts tags only within the deployment-configured repository.
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
@@ -9,6 +9,12 @@ const Docker = require('dockerode');
|
|||||||
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
const docker = new Docker({ socketPath: '/var/run/docker.sock' });
|
||||||
const TARGET_CONTAINER_NAME = 'multirover';
|
const TARGET_CONTAINER_NAME = 'multirover';
|
||||||
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
|
const TARGET_IMAGE = process.env.MULTIROVER_TARGET_IMAGE || 'ghcr.io/legop3/multiroombarover:latest';
|
||||||
|
// A registry port is not a tag separator. Strip a digest before splitting the tag.
|
||||||
|
const configuredName = TARGET_IMAGE.split('@')[0];
|
||||||
|
const tagSeparator = configuredName.lastIndexOf(':');
|
||||||
|
const hasConfiguredTag = tagSeparator > configuredName.lastIndexOf('/');
|
||||||
|
const IMAGE_REPOSITORY = hasConfiguredTag ? configuredName.slice(0, tagSeparator) : configuredName;
|
||||||
|
const DEFAULT_TAG = TARGET_IMAGE.includes('@') ? null : (hasConfiguredTag ? configuredName.slice(tagSeparator + 1) : 'latest');
|
||||||
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
const SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||||
// Status belongs beside the controller's private socket instead of in the
|
// Status belongs beside the controller's private socket instead of in the
|
||||||
// application's data volume. The controller runs as root for Docker access;
|
// application's data volume. The controller runs as root for Docker access;
|
||||||
@@ -20,6 +26,32 @@ const HEALTH_POLL_MS = 1000;
|
|||||||
|
|
||||||
let operationRunning = false;
|
let operationRunning = false;
|
||||||
let status = readStatus();
|
let status = readStatus();
|
||||||
|
// A repository change in Compose invalidates the previous repository's selection.
|
||||||
|
if (status.selectedRepository !== IMAGE_REPOSITORY) {
|
||||||
|
status.selectedRepository = IMAGE_REPOSITORY;
|
||||||
|
status.selectedTag = DEFAULT_TAG;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedImage() {
|
||||||
|
return status.selectedTag ? `${IMAGE_REPOSITORY}:${status.selectedTag}` : TARGET_IMAGE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicStatus() {
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
configuredImage: TARGET_IMAGE,
|
||||||
|
repository: IMAGE_REPOSITORY,
|
||||||
|
targetImage: selectedImage(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestedImage(tag) {
|
||||||
|
if (tag === null) return { imageReference: selectedImage(), tag: status.selectedTag };
|
||||||
|
if (!/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(tag)) {
|
||||||
|
throw new Error('Enter a valid container tag (1–128 letters, numbers, underscores, periods, or hyphens).');
|
||||||
|
}
|
||||||
|
return { imageReference: `${IMAGE_REPOSITORY}:${tag}`, tag };
|
||||||
|
}
|
||||||
|
|
||||||
function readStatus() {
|
function readStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -52,11 +84,12 @@ function readStatus() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function writeStatus(patch) {
|
function writeStatus(patch) {
|
||||||
status = { ...status, ...patch };
|
const nextStatus = { ...status, ...patch };
|
||||||
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
|
fs.mkdirSync(path.dirname(STATUS_PATH), { recursive: true });
|
||||||
const temporaryPath = `${STATUS_PATH}.tmp`;
|
const temporaryPath = `${STATUS_PATH}.tmp`;
|
||||||
fs.writeFileSync(temporaryPath, `${JSON.stringify(status, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
fs.writeFileSync(temporaryPath, `${JSON.stringify(nextStatus, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 });
|
||||||
fs.renameSync(temporaryPath, STATUS_PATH);
|
fs.renameSync(temporaryPath, STATUS_PATH);
|
||||||
|
status = nextStatus;
|
||||||
return status;
|
return status;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,23 +101,23 @@ async function inspectTargetContainer() {
|
|||||||
return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
|
return docker.getContainer(TARGET_CONTAINER_NAME).inspect();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inspectLocalTargetImage() {
|
async function inspectLocalTargetImage(imageReference = selectedImage()) {
|
||||||
try {
|
try {
|
||||||
return await docker.getImage(TARGET_IMAGE).inspect();
|
return await docker.getImage(imageReference).inspect();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error.statusCode === 404) return null;
|
if (error.statusCode === 404) return null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function pullTargetImage() {
|
async function pullTargetImage(imageReference = selectedImage()) {
|
||||||
const stream = await new Promise((resolve, reject) => {
|
const stream = await new Promise((resolve, reject) => {
|
||||||
docker.pull(TARGET_IMAGE, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
|
docker.pull(imageReference, (error, pullStream) => (error ? reject(error) : resolve(pullStream)));
|
||||||
});
|
});
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
|
docker.modem.followProgress(stream, (error) => (error ? reject(error) : resolve()));
|
||||||
});
|
});
|
||||||
const image = await inspectLocalTargetImage();
|
const image = await inspectLocalTargetImage(imageReference);
|
||||||
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
|
if (!image) throw new Error('Docker finished pulling but the target image is unavailable.');
|
||||||
return image;
|
return image;
|
||||||
}
|
}
|
||||||
@@ -193,13 +226,14 @@ async function refreshImageStatus() {
|
|||||||
]);
|
]);
|
||||||
return writeStatus({
|
return writeStatus({
|
||||||
runningImage: container.Image,
|
runningImage: container.Image,
|
||||||
|
runningImageReference: container.Config?.Image || null,
|
||||||
availableImage: image?.Id || null,
|
availableImage: image?.Id || null,
|
||||||
updateAvailable: image ? container.Image !== image.Id : null,
|
updateAvailable: image ? container.Image !== image.Id : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCheck() {
|
async function runCheck() {
|
||||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…` });
|
writeStatus({ message: `Pulling ${selectedImage()}…` });
|
||||||
await pullTargetImage();
|
await pullTargetImage();
|
||||||
const current = await refreshImageStatus();
|
const current = await refreshImageStatus();
|
||||||
return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
|
return current.updateAvailable ? 'An application update is available.' : 'The application is current.';
|
||||||
@@ -213,9 +247,9 @@ async function runRestart() {
|
|||||||
return 'The application container restarted successfully.';
|
return 'The application container restarted successfully.';
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runUpdate() {
|
async function runUpdate({ imageReference, tag }) {
|
||||||
writeStatus({ message: `Pulling ${TARGET_IMAGE}…`, rollback: null });
|
writeStatus({ message: `Pulling ${imageReference}…`, rollback: null });
|
||||||
const targetImage = await pullTargetImage();
|
const targetImage = await pullTargetImage(imageReference);
|
||||||
const previousContainer = await inspectTargetContainer();
|
const previousContainer = await inspectTargetContainer();
|
||||||
const previousImage = previousContainer.Image;
|
const previousImage = previousContainer.Image;
|
||||||
let replacementStarted = false;
|
let replacementStarted = false;
|
||||||
@@ -228,11 +262,15 @@ async function runUpdate() {
|
|||||||
updateAvailable: previousImage !== targetImage.Id,
|
updateAvailable: previousImage !== targetImage.Id,
|
||||||
});
|
});
|
||||||
replacementStarted = true;
|
replacementStarted = true;
|
||||||
await replaceContainer({ template: previousContainer, imageReference: TARGET_IMAGE });
|
await replaceContainer({ template: previousContainer, imageReference: targetImage.Id });
|
||||||
writeStatus({ message: 'Waiting for the updated application to become healthy…' });
|
writeStatus({ message: 'Waiting for the updated application to become healthy…' });
|
||||||
const healthy = await waitForHealthyContainer();
|
const healthy = await waitForHealthyContainer();
|
||||||
writeStatus({
|
writeStatus({
|
||||||
|
// Commit the tag only once the replacement passes its health check.
|
||||||
|
selectedRepository: IMAGE_REPOSITORY,
|
||||||
|
selectedTag: tag,
|
||||||
runningImage: healthy.Image,
|
runningImage: healthy.Image,
|
||||||
|
runningImageReference: healthy.Config?.Image || null,
|
||||||
availableImage: targetImage.Id,
|
availableImage: targetImage.Id,
|
||||||
updateAvailable: false,
|
updateAvailable: false,
|
||||||
});
|
});
|
||||||
@@ -284,7 +322,7 @@ function queueOperation(operation, runner) {
|
|||||||
operationRunning = false;
|
operationRunning = false;
|
||||||
}
|
}
|
||||||
}, 500);
|
}, 500);
|
||||||
return status;
|
return publicStatus();
|
||||||
}
|
}
|
||||||
|
|
||||||
function sendJson(response, statusCode, value) {
|
function sendJson(response, statusCode, value) {
|
||||||
@@ -294,10 +332,11 @@ function sendJson(response, statusCode, value) {
|
|||||||
|
|
||||||
const server = http.createServer((request, response) => {
|
const server = http.createServer((request, response) => {
|
||||||
try {
|
try {
|
||||||
|
const url = new URL(request.url, 'http://localhost');
|
||||||
if (request.method === 'GET' && request.url === '/status') {
|
if (request.method === 'GET' && request.url === '/status') {
|
||||||
refreshImageStatus()
|
refreshImageStatus()
|
||||||
.catch(() => status)
|
.catch(() => status)
|
||||||
.then((current) => sendJson(response, 200, { available: true, ...current }));
|
.then(() => sendJson(response, 200, { available: true, ...publicStatus() }));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && request.url === '/check') {
|
if (request.method === 'POST' && request.url === '/check') {
|
||||||
@@ -308,8 +347,10 @@ const server = http.createServer((request, response) => {
|
|||||||
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
|
sendJson(response, 202, { available: true, ...queueOperation('restart', runRestart) });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (request.method === 'POST' && request.url === '/update') {
|
if (request.method === 'POST' && url.pathname === '/update') {
|
||||||
sendJson(response, 202, { available: true, ...queueOperation('update', runUpdate) });
|
const target = requestedImage(url.searchParams.get('tag'));
|
||||||
|
queueOperation('update', () => runUpdate(target));
|
||||||
|
sendJson(response, 202, { available: true, ...publicStatus() });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sendJson(response, 404, { error: 'Lifecycle operation not found.' });
|
sendJson(response, 404, { error: 'Lifecycle operation not found.' });
|
||||||
|
|||||||
@@ -88,14 +88,14 @@ io.on('connection', (socket) => {
|
|||||||
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
.catch((error) => cb({ error: error.message, code: error.code || null }));
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('server:updateApplication', (_payload = {}, cb = () => {}) => {
|
socket.on('server:updateApplication', (payload = {}, cb = () => {}) => {
|
||||||
Promise.resolve()
|
Promise.resolve()
|
||||||
.then(() => requireRecentPassword(socket))
|
.then(() => requireRecentPassword(socket))
|
||||||
.then(() => lifecycleClient.updateApplication())
|
.then(() => lifecycleClient.updateApplication(payload?.tag))
|
||||||
.then((lifecycle) => {
|
.then((lifecycle) => {
|
||||||
// Wait for controller acceptance so rejected updates do not kick users.
|
// Wait for controller acceptance so rejected updates do not kick users.
|
||||||
notifyDriversOfRestart(actorFor(socket));
|
notifyDriversOfRestart(actorFor(socket));
|
||||||
database.recordAuditEvent(actorFor(socket), 'application.update-requested', {});
|
database.recordAuditEvent(actorFor(socket), 'application.update-requested', { tag: payload?.tag ?? lifecycle.selectedTag });
|
||||||
io.emit('server:restarting', { reason: 'application-update' });
|
io.emit('server:restarting', { reason: 'application-update' });
|
||||||
cb({ success: true, lifecycle });
|
cb({ success: true, lifecycle });
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Container Lifecycle Client
|
// Container Lifecycle Client
|
||||||
// Purpose: Sends fixed lifecycle requests from the application to the private controller socket.
|
// Purpose: Sends fixed lifecycle requests from the application to the private controller socket.
|
||||||
// Scope: This client cannot select images, containers, or Docker arguments; the controller owns those constants.
|
// Scope: This client can request a tag; the controller owns the repository, container, and Docker arguments.
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
|
|
||||||
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
const LIFECYCLE_SOCKET_PATH = process.env.MULTIROVER_LIFECYCLE_SOCKET || '/run/multirover/lifecycle.sock';
|
||||||
@@ -68,5 +68,11 @@ module.exports = {
|
|||||||
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
|
checkForUpdate: () => requestLifecycleController({ method: 'POST', path: '/check' }),
|
||||||
getLifecycleStatus,
|
getLifecycleStatus,
|
||||||
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
|
restartApplication: () => requestLifecycleController({ method: 'POST', path: '/restart' }),
|
||||||
updateApplication: () => requestLifecycleController({ method: 'POST', path: '/update' }),
|
updateApplication: (tag) => {
|
||||||
|
if (tag !== undefined && typeof tag !== 'string') throw new Error('Container tag must be a string.');
|
||||||
|
return requestLifecycleController({
|
||||||
|
method: 'POST',
|
||||||
|
path: tag === undefined ? '/update' : `/update?tag=${encodeURIComponent(tag)}`,
|
||||||
|
});
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const { managerEvents } = roverManager;
|
|||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
||||||
const { buildRoverTurn } = require('../turnService/display');
|
const { buildRoverTurn } = require('../turnService/display');
|
||||||
|
const { getRoomAudioStreams, roomAudioEvents } = require('../roomAudioService');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||||
const {
|
const {
|
||||||
getPublicState: getPtzCameraState,
|
getPublicState: getPtzCameraState,
|
||||||
@@ -186,6 +187,7 @@ function buildSession(socket) {
|
|||||||
socketId: socket?.id || null,
|
socketId: socket?.id || null,
|
||||||
role: getRole(socket),
|
role: getRole(socket),
|
||||||
operatingMode: getOperatingMode(socket),
|
operatingMode: getOperatingMode(socket),
|
||||||
|
canLeaveRover: roverManager.canLeaveCurrentRover(socket).ok,
|
||||||
mode: getMode(),
|
mode: getMode(),
|
||||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||||
@@ -214,6 +216,7 @@ function buildSession(socket) {
|
|||||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||||
},
|
},
|
||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
|
roomAudioStreams: getRoomAudioStreams(socket),
|
||||||
ptzCamera: getPtzCameraState(socket),
|
ptzCamera: getPtzCameraState(socket),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
homeAssistantActivities: getActivityState(),
|
homeAssistantActivities: getActivityState(),
|
||||||
@@ -261,10 +264,14 @@ function buildSession(socket) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache only the published decision so sensor frames send updates on transitions.
|
||||||
|
const leavePermissionBySocket = new WeakMap();
|
||||||
|
|
||||||
function syncSocket(socket) {
|
function syncSocket(socket) {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
const payload = buildSession(socket);
|
const payload = buildSession(socket);
|
||||||
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
logger.info('Syncing session', socket.id, payload.role, payload.assignment);
|
||||||
|
leavePermissionBySocket.set(socket, payload.canLeaveRover);
|
||||||
socket.emit('session:sync', payload);
|
socket.emit('session:sync', payload);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,6 +292,15 @@ io.on('connection', (socket) => {
|
|||||||
syncSocket(socket);
|
syncSocket(socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
managerEvents.on('sensor', ({ roverId }) => {
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
const decision = roverManager.canLeaveCurrentRover(socket);
|
||||||
|
if (decision.currentId !== roverId) return;
|
||||||
|
if (leavePermissionBySocket.get(socket) === decision.ok) return;
|
||||||
|
syncSocket(socket);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
operatingModeEvents.on('change', () => syncAll());
|
operatingModeEvents.on('change', () => syncAll());
|
||||||
|
|
||||||
roleEvents.on('change', ({ socket }) => {
|
roleEvents.on('change', ({ socket }) => {
|
||||||
@@ -363,13 +379,13 @@ privateRoverAccessRequestEvents.on('change', (event = {}) => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
managerEvents.on('driver', ({ socketId }) => {
|
// Update the changed driver and anyone whose departure permission depends on them.
|
||||||
if (!socketId) return;
|
managerEvents.on('driver', ({ socketId, roverId }) => {
|
||||||
const socket = io.sockets.sockets.get(socketId);
|
io.sockets.sockets.forEach((socket) => {
|
||||||
if (socket) {
|
if (socket.id === socketId || roverManager.isDriver(roverId, socket)) {
|
||||||
logger.info('Driver assignment change; syncing session', socketId);
|
|
||||||
syncSocket(socket);
|
syncSocket(socket);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
turnEvents.on('activeDriver', () => {
|
turnEvents.on('activeDriver', () => {
|
||||||
@@ -407,6 +423,8 @@ turnEvents.on('queue', (event = {}) => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
roomAudioEvents.on('update', () => syncAll());
|
||||||
|
|
||||||
roomCameraEvents.on('update', () => {
|
roomCameraEvents.on('update', () => {
|
||||||
logger.info('Room camera change detected; syncing all clients');
|
logger.info('Room camera change detected; syncing all clients');
|
||||||
syncAll();
|
syncAll();
|
||||||
@@ -459,7 +477,7 @@ balanceBoardEvents.on('change', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
replayEvents.on('update', () => {
|
replayEvents.on('update', () => {
|
||||||
logger.info('Replay cooldown updated; syncing all clients');
|
logger.info('Replay state updated; syncing all clients');
|
||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -397,6 +397,7 @@ function advanceTurn(roverId) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (queue.queue.length === 0) {
|
if (queue.queue.length === 0) {
|
||||||
|
queue.current = null;
|
||||||
stopRover(roverId);
|
stopRover(roverId);
|
||||||
clearTurnTimer(roverId, queue);
|
clearTurnTimer(roverId, queue);
|
||||||
setActiveDriver(roverId, null);
|
setActiveDriver(roverId, null);
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ function registerVideoAuthRoute(deps) {
|
|||||||
logAdminEvent,
|
logAdminEvent,
|
||||||
extractStreamInfoFromBody,
|
extractStreamInfoFromBody,
|
||||||
canAccessStream,
|
canAccessStream,
|
||||||
|
trackRoomAudioSession,
|
||||||
} = deps;
|
} = deps;
|
||||||
|
|
||||||
app.post('/mediamtx/auth', (req, res) => {
|
app.post('/mediamtx/auth', (req, res) => {
|
||||||
@@ -36,7 +37,8 @@ function registerVideoAuthRoute(deps) {
|
|||||||
const isLoopback = ip === '127.0.0.1' || ip === '::1';
|
const isLoopback = ip === '127.0.0.1' || ip === '::1';
|
||||||
const isRoverForwardAudioRead = action === 'read'
|
const isRoverForwardAudioRead = action === 'read'
|
||||||
&& isRtspProtocol
|
&& isRtspProtocol
|
||||||
&& streamInfo?.id?.endsWith('-fwd');
|
&& streamInfo?.type === 'rover'
|
||||||
|
&& streamInfo.id.endsWith('-fwd');
|
||||||
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do
|
// Replay and snapshot workers read MediaMTX through loopback RTSP. They do
|
||||||
// not represent a browser session. Rovers likewise read their dedicated
|
// not represent a browser session. Rovers likewise read their dedicated
|
||||||
// -fwd speaker feed without browser credentials; every other non-loopback
|
// -fwd speaker feed without browser credentials; every other non-loopback
|
||||||
@@ -50,7 +52,7 @@ function registerVideoAuthRoute(deps) {
|
|||||||
callback for RTSP, so explicitly admit that publish protocol while leaving WHEP reads
|
callback for RTSP, so explicitly admit that publish protocol while leaving WHEP reads
|
||||||
under the existing session and role checks below.
|
under the existing session and role checks below.
|
||||||
*/
|
*/
|
||||||
if (action === 'publish' && isRtspProtocol) {
|
if (action === 'publish' && isRtspProtocol && (streamInfo?.type !== 'roomAudio' || isLoopback)) {
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -62,7 +64,9 @@ function registerVideoAuthRoute(deps) {
|
|||||||
const info = videoSessions.getSession(sessionId);
|
const info = videoSessions.getSession(sessionId);
|
||||||
const streamTypeMatches =
|
const streamTypeMatches =
|
||||||
info &&
|
info &&
|
||||||
(info.sourceType === streamInfo.type || (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
|
(info.sourceType === streamInfo.type
|
||||||
|
|| (info.sourceType === 'roomMic' && streamInfo.type === 'roomAudio')
|
||||||
|
|| (info.sourceType === 'roverMic' && streamInfo.type === 'rover'));
|
||||||
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) {
|
if (!info || !streamTypeMatches || info.sourceId !== streamInfo.id) {
|
||||||
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
|
logger.warn('invalid session %s for stream %s:%s', sessionId, streamInfo.type, streamInfo.id);
|
||||||
return res.status(401).end();
|
return res.status(401).end();
|
||||||
@@ -78,6 +82,7 @@ function registerVideoAuthRoute(deps) {
|
|||||||
return res.status(401).end();
|
return res.status(401).end();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (streamInfo.type === 'roomAudio' && protocol === 'webrtc') trackRoomAudioSession(body.id, sessionId);
|
||||||
return res.status(200).end();
|
return res.status(200).end();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
const { app } = require('../../globals/http');
|
const { app } = require('../../globals/http');
|
||||||
const io = require('../../globals/io');
|
const io = require('../../globals/io');
|
||||||
const logger = require('../../globals/logger').child('videoAuth');
|
const logger = require('../../globals/logger').child('videoAuth');
|
||||||
|
const roomAudioService = require('../roomAudioService');
|
||||||
const videoSessions = require('../videoSessions');
|
const videoSessions = require('../videoSessions');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
const { isAdmin, isLockdownAdmin, getRole } = require('../roleService');
|
||||||
@@ -28,6 +29,7 @@ const { canAccessStream } = createVideoAuthPolicy({
|
|||||||
turnService,
|
turnService,
|
||||||
roverManager,
|
roverManager,
|
||||||
ptzCameraService,
|
ptzCameraService,
|
||||||
|
roomAudioService,
|
||||||
getSocketIp,
|
getSocketIp,
|
||||||
isLocalNetwork,
|
isLocalNetwork,
|
||||||
io,
|
io,
|
||||||
@@ -42,6 +44,7 @@ registerVideoAuthRoute({
|
|||||||
logAdminEvent,
|
logAdminEvent,
|
||||||
extractStreamInfoFromBody,
|
extractStreamInfoFromBody,
|
||||||
canAccessStream,
|
canAccessStream,
|
||||||
|
trackRoomAudioSession: roomAudioService.trackMediaSession,
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = {};
|
module.exports = {};
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function createVideoAuthPolicy(deps) {
|
|||||||
turnService,
|
turnService,
|
||||||
roverManager,
|
roverManager,
|
||||||
ptzCameraService,
|
ptzCameraService,
|
||||||
|
roomAudioService,
|
||||||
getSocketIp,
|
getSocketIp,
|
||||||
isLocalNetwork,
|
isLocalNetwork,
|
||||||
io,
|
io,
|
||||||
@@ -58,6 +59,11 @@ function createVideoAuthPolicy(deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function canAccessStream({ socket, streamInfo, action, sourceType }) {
|
function canAccessStream({ socket, streamInfo, action, sourceType }) {
|
||||||
|
if (streamInfo.type === 'roomAudio') {
|
||||||
|
return action === 'publish'
|
||||||
|
? sourceType === 'roomMic' && roomAudioService.canPublish(socket, streamInfo.id)
|
||||||
|
: action === 'read' && sourceType === 'roomAudio' && roomAudioService.canListen(socket) && roomAudioService.hasStream(streamInfo.id);
|
||||||
|
}
|
||||||
if (!canView(socket)) {
|
if (!canView(socket)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ function extractStreamInfo(path) {
|
|||||||
return { type: 'rover', id: rawId, baseId };
|
return { type: 'rover', id: rawId, baseId };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (remaining.length === 2 && remaining[0] === 'room-audio') return { type: 'roomAudio', id: remaining[1] };
|
||||||
|
|
||||||
if (remaining.length === 2 && remaining[0] === 'room') {
|
if (remaining.length === 2 && remaining[0] === 'room') {
|
||||||
return { type: 'room', id: remaining[1] || '' };
|
return { type: 'room', id: remaining[1] || '' };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ const {
|
|||||||
|
|
||||||
function buildWhepUrlForSource(source) {
|
function buildWhepUrlForSource(source) {
|
||||||
const segments = [];
|
const segments = [];
|
||||||
if (source.type === 'room') {
|
if (source.type === 'roomAudio') {
|
||||||
|
segments.push('room-audio', encodeURIComponent(source.id));
|
||||||
|
} else if (source.type === 'room') {
|
||||||
segments.push('room', encodeURIComponent(source.id));
|
segments.push('room', encodeURIComponent(source.id));
|
||||||
} else if (source.type === 'ptz') {
|
} else if (source.type === 'ptz') {
|
||||||
/*
|
/*
|
||||||
@@ -145,6 +147,9 @@ io.on('connection', (socket) => {
|
|||||||
*/
|
*/
|
||||||
throw new Error('Live video is limited to this rover queue');
|
throw new Error('Live video is limited to this rover queue');
|
||||||
}
|
}
|
||||||
|
} else if (target.type === 'roomAudio') {
|
||||||
|
const roomAudio = require('../roomAudioService');
|
||||||
|
if (!roomAudio.canListen(socket) || !roomAudio.hasStream(target.id)) throw new Error('Room audio unavailable');
|
||||||
} else if (target.type === 'room') {
|
} else if (target.type === 'room') {
|
||||||
throw new Error('Room cameras now use the snapshot feed');
|
throw new Error('Room cameras now use the snapshot feed');
|
||||||
} else if (target.type === 'ptz') {
|
} else if (target.type === 'ptz') {
|
||||||
|
|||||||
@@ -3,20 +3,9 @@
|
|||||||
research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker
|
research how midis can be played easier with drag and drop, auto selection based on which playback mode would fit, more useful and clear toggles, sample midi files to analyze how they would play on a single tone system, better live playback to see what notes are actually playing from each source and what simply wont play on the speaker
|
||||||
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
|
and make sure each toggle and counter actually has a purpose besides debugging or something more useful to the user, note skipped is just for debugging
|
||||||
```
|
```
|
||||||
1. add new discord channel: live status
|
1. add admin ui for VIP and private requests instead of only through discord
|
||||||
1. one concise message which contains the status of all the rovers
|
2. make roverd self update checkout to main branch
|
||||||
2. bot clears the channel, then sends a new message
|
3. fix this:
|
||||||
3. has a fancy big embed that shows way more stuff
|
|
||||||
4. also has a small text copy at the top (readable on smartwatch lol)
|
|
||||||
1. ```
|
|
||||||
freaky: docked
|
|
||||||
wall-e: user1 driving
|
|
||||||
bweeble: NEEDS HELP
|
|
||||||
```
|
|
||||||
2. setting to disable replay popups in spectator settings menu
|
|
||||||
3. add admin ui for VIP and private requests instead of only through discord
|
|
||||||
4. make roverd self update checkout to main branch
|
|
||||||
5. fix this:
|
|
||||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
@@ -30,8 +19,12 @@ Jun 18 15:14:18 roombaserver.local node[216731]: Node.js v22.20.0
|
|||||||
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE
|
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Main process exited, code=exited, status=1/FAILURE
|
||||||
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'.
|
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Failed with result 'exit-code'.
|
||||||
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.`
|
Jun 18 15:14:18 roombaserver.local systemd[1]: multirover.service: Consumed 55min 11.511s CPU time, 711M memory peak.`
|
||||||
|
1. redo new user experience, at the very least make it a fullscreen thing instead of a popup
|
||||||
|
2. roverd settings and installer rework (see discord forum post)
|
||||||
|
3. global quiet mode with its fingers in everything. activity controls optional, session status for theater, another global volume set, etc.
|
||||||
|
|
||||||
# relative pipe dreams:
|
# relative pipe dreams:
|
||||||
|
1. convert MRR into a platform that could work with non-roomba rovers...
|
||||||
1. VPS video forwarding
|
1. VPS video forwarding
|
||||||
1. get forwarding working with the VPS for in-queue users and spectators
|
1. get forwarding working with the VPS for in-queue users and spectators
|
||||||
2. bandwidth testing
|
2. bandwidth testing
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,9 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "VITE_ROVERD_URL=http://192.168.0.86:8080 vite",
|
"predev": "node scripts/prepareMediaMtx.js",
|
||||||
|
"dev": "VITE_ROVERD_URL=https://rover.otter.land vite",
|
||||||
|
"prebuild": "node scripts/prepareMediaMtx.js",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
"test": "node --test src/controls/inputs/*.test.js src/components/GamepadMappingSettings/*.test.js",
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { createHash } from 'node:crypto';
|
||||||
|
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||||
|
|
||||||
|
const version = '1.15.3'; // Matches the Dockerfile's default MediaMTX version.
|
||||||
|
const baseUrl = `https://raw.githubusercontent.com/bluenviron/mediamtx/v${version}/`;
|
||||||
|
const output = new URL('../.cache/mediamtx/publisher.js', import.meta.url);
|
||||||
|
const moduleHash = 'e903555223f196aca86d7adce5cf4437824435291bf9b47f284aa767fffabaaa';
|
||||||
|
const hash = (content) => createHash('sha256').update(content).digest('hex');
|
||||||
|
|
||||||
|
async function download(path, checksum) {
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, { signal: AbortSignal.timeout(30000) });
|
||||||
|
if (!response.ok) throw new Error(`MediaMTX download failed: ${path} (${response.status})`);
|
||||||
|
const content = await response.text();
|
||||||
|
if (hash(content) !== checksum) throw new Error(`MediaMTX checksum mismatch: ${path}`);
|
||||||
|
return content;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cached;
|
||||||
|
try {
|
||||||
|
cached = await readFile(output, 'utf8');
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code !== 'ENOENT') throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cached && hash(cached) === moduleHash) {
|
||||||
|
console.info(`Using cached MediaMTX publisher v${version}`);
|
||||||
|
} else {
|
||||||
|
const [source, license] = await Promise.all([
|
||||||
|
download('internal/servers/webrtc/publisher.js', '6b4448939b45c30507bce59acaf434e3133e6efac3bd77d7e20fb01462958509'),
|
||||||
|
download('LICENSE', 'ecae73b0a23185a35a1222edc0aae5245bb9739420bad0125b0c42e935301a80'),
|
||||||
|
]);
|
||||||
|
// Keep upstream behavior and its license; only adapt the browser-global export for Vite.
|
||||||
|
const module = `// MediaMTX v${version} publisher client, matching the Dockerfile default.\n`
|
||||||
|
+ `// Source: ${baseUrl}internal/servers/webrtc/publisher.js\n`
|
||||||
|
+ '// Only modification: export the class as an ES module for Vite.\n'
|
||||||
|
+ `/*\n${license}\n*/\n`
|
||||||
|
+ source.replace('window.MediaMTXWebRTCPublisher = MediaMTXWebRTCPublisher;', 'export default MediaMTXWebRTCPublisher;');
|
||||||
|
if (hash(module) !== moduleHash) throw new Error('Generated MediaMTX module checksum mismatch');
|
||||||
|
await mkdir(new URL('.', output), { recursive: true });
|
||||||
|
// Publish the verified module atomically so an interrupted download cannot poison the cache.
|
||||||
|
const temporary = new URL(`${output.href}.${process.pid}.tmp`);
|
||||||
|
await writeFile(temporary, module);
|
||||||
|
await rename(temporary, output);
|
||||||
|
console.info(`Downloaded and verified MediaMTX publisher v${version}`);
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ export const deleteAdministrator = (socket, id) => emitAdminRequest(socket, 'adm
|
|||||||
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
|
export const restartApplication = (socket) => emitAdminRequest(socket, 'server:restartApplication');
|
||||||
export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
|
export const getApplicationLifecycleStatus = (socket) => emitAdminRequest(socket, 'server:lifecycleStatus');
|
||||||
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
|
export const checkForApplicationUpdate = (socket) => emitAdminRequest(socket, 'server:checkForUpdate');
|
||||||
export const updateApplication = (socket) => emitAdminRequest(socket, 'server:updateApplication');
|
export const updateApplication = (socket, tag) => emitAdminRequest(socket, 'server:updateApplication', { tag });
|
||||||
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
|
export const getBackupRestoreStatus = (socket) => emitAdminRequest(socket, 'backupRestore:status');
|
||||||
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
|
export const createFullBackup = (socket) => emitAdminRequest(socket, 'backupRestore:createBackup');
|
||||||
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
|
export const createRestoreUpload = (socket) => emitAdminRequest(socket, 'backupRestore:createRestoreUpload');
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
|||||||
const [restartRequested, setRestartRequested] = useState(false);
|
const [restartRequested, setRestartRequested] = useState(false);
|
||||||
const [lifecycle, setLifecycle] = useState(null);
|
const [lifecycle, setLifecycle] = useState(null);
|
||||||
const [lifecycleError, setLifecycleError] = useState('');
|
const [lifecycleError, setLifecycleError] = useState('');
|
||||||
|
const [containerTag, setContainerTag] = useState(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
@@ -90,10 +91,13 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function update() {
|
async function update() {
|
||||||
if (!window.confirm('Pull the configured MultiRover image and replace the application container now? The page will reconnect automatically.')) return;
|
const tag = containerTag === null ? undefined : containerTag.trim();
|
||||||
|
const target = tag === undefined ? lifecycle.targetImage : `${lifecycle.repository}:${tag}`;
|
||||||
|
if (!window.confirm(`Pull ${target} and replace the application container now? The page will reconnect automatically.`)) return;
|
||||||
try {
|
try {
|
||||||
const response = await runSensitive(() => updateApplication(socket));
|
const response = await runSensitive(() => updateApplication(socket, tag));
|
||||||
setLifecycle(response.lifecycle);
|
setLifecycle(response.lifecycle);
|
||||||
|
setContainerTag(null);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLifecycleError(error.message);
|
setLifecycleError(error.message);
|
||||||
}
|
}
|
||||||
@@ -121,15 +125,36 @@ export default function AdminOverview({ snapshot, socket, runSensitive, onSnapsh
|
|||||||
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="Application container" meta={lifecycleAvailable ? lifecycle?.state : 'controller unavailable'} bodyClassName="space-y-1 p-1 text-sm">
|
||||||
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
|
<div className="surface max-w-4xl space-y-0.5 p-1 text-slate-300">
|
||||||
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
|
<p>{lifecycleAvailable ? updateSummary : 'Container updates are unavailable until the lifecycle service is running.'}</p>
|
||||||
|
{lifecycleAvailable ? (
|
||||||
|
<div className="space-y-0.5 break-all">
|
||||||
|
<p>Repository: {lifecycle.repository}</p>
|
||||||
|
<p>Selected image: {lifecycle.targetImage}</p>
|
||||||
|
<p>Running image: {lifecycle.runningImage}</p>
|
||||||
|
<p>Compose image: {lifecycle.configuredImage}</p>
|
||||||
|
{lifecycle.targetImage !== lifecycle.configuredImage ? <p className="text-amber-300">A manual Compose deployment may restore the Compose image.</p> : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
|
{lifecycle?.message ? <p className="text-slate-400">{lifecycle.message}</p> : null}
|
||||||
{lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null}
|
{lifecycle?.rollback ? <p>Rollback: {lifecycle.rollback.status}{lifecycle.rollback.error ? ` — ${lifecycle.rollback.error}` : ''}</p> : null}
|
||||||
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
|
{lifecycleError ? <p className="text-red-300">{lifecycleError}</p> : null}
|
||||||
</div>
|
</div>
|
||||||
|
<label className="flex max-w-md flex-col gap-0.5">
|
||||||
|
<span>Container tag</span>
|
||||||
|
<input
|
||||||
|
className="field-input w-full"
|
||||||
|
value={containerTag ?? lifecycle?.selectedTag ?? ''}
|
||||||
|
placeholder="Enter a tag"
|
||||||
|
maxLength={128}
|
||||||
|
disabled={!lifecycleAvailable || lifecycleBusy}
|
||||||
|
onChange={(event) => setContainerTag(event.target.value)}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">Update and restart applies this tag. Update checks use the saved selected image.</span>
|
||||||
|
</label>
|
||||||
<div className="flex flex-wrap gap-0.5">
|
<div className="flex flex-wrap gap-0.5">
|
||||||
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
|
<button type="button" className="button-dark" disabled={!lifecycleAvailable || lifecycleBusy} onClick={checkForUpdate}>
|
||||||
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
|
{lifecycle?.operation === 'check' && lifecycleBusy ? 'Checking for update…' : 'Check for update'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy} onClick={update}>
|
<button type="button" className="button-danger" disabled={!lifecycleAvailable || lifecycleBusy || (containerTag !== null && !/^[a-zA-Z0-9_][a-zA-Z0-9_.-]{0,127}$/.test(containerTag.trim()))} onClick={update}>
|
||||||
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
|
{lifecycle?.operation === 'update' && lifecycleBusy ? 'Updating application…' : 'Update and restart'}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
|
<button type="button" className="button-danger" disabled={restartRequested || lifecycleBusy} onClick={restart}>
|
||||||
|
|||||||
@@ -59,20 +59,6 @@ function BarcodeScanToast({ payload }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function UndockedExitWarningToast() {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className="w-[min(32rem,80vw)] rounded-md bg-amber-950 px-4 py-3 text-left text-amber-50"
|
|
||||||
role="alert"
|
|
||||||
>
|
|
||||||
<p className="text-xl font-bold leading-tight text-amber-100">Please dock your rover</p>
|
|
||||||
<p className="mt-1 text-base font-medium leading-snug text-amber-50/90">
|
|
||||||
Your rover is still undocked. Please dock it before leaving the page.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function AlertContent({ alert }) {
|
export default function AlertContent({ alert }) {
|
||||||
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
if (alert.kind === 'buttonbox-active' && alert.payload) {
|
||||||
const payload = alert.payload;
|
const payload = alert.payload;
|
||||||
@@ -112,9 +98,6 @@ export default function AlertContent({ alert }) {
|
|||||||
if (alert.kind === 'barcode-scan' && alert.payload) {
|
if (alert.kind === 'barcode-scan' && alert.payload) {
|
||||||
return <BarcodeScanToast payload={alert.payload} />;
|
return <BarcodeScanToast payload={alert.payload} />;
|
||||||
}
|
}
|
||||||
if (alert.kind === 'undocked-exit-warning') {
|
|
||||||
return <UndockedExitWarningToast />;
|
|
||||||
}
|
|
||||||
const color = typeof alert.color === 'string' ? alert.color.trim() : '';
|
const color = typeof alert.color === 'string' ? alert.color.trim() : '';
|
||||||
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
|
const backgroundColor = /^#?[0-9a-f]{6}$/i.test(color) ? `#${color.replace('#', '')}` : '#2196f3';
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user