mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-15 11:12:16 -05:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8cb7cc5bdc | ||
|
|
34936ab05c | ||
|
|
7231809140 | ||
|
|
8ca714cf1a | ||
|
|
ffd34de56c | ||
|
|
846c9dae55 |
-10
@@ -97,15 +97,6 @@ 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 dnf install -y --setopt=install_weak_deps=False python3 python3-pip \
|
|
||||||
&& dnf clean all
|
|
||||||
COPY server/src/services/roverLearningService/workers/requirements.txt /tmp/learning-requirements.txt
|
|
||||||
RUN python3 -m venv /opt/rover-learning \
|
|
||||||
&& /opt/rover-learning/bin/pip install --no-cache-dir -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
|
||||||
@@ -157,7 +148,6 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -1,788 +0,0 @@
|
|||||||
# Rover learning service implementation tracker
|
|
||||||
|
|
||||||
## Resume here
|
|
||||||
|
|
||||||
Latest change: one ongoing learner, gated Latest/Previous checkpoints, and
|
|
||||||
admin-created permanent snapshots replace automatic permanent publications.
|
|
||||||
|
|
||||||
Status: policy format 2 now predicts continuous wheel speeds plus accessory events.
|
|
||||||
Copyable diagnostics and main-card wheel/action displays with a session threshold
|
|
||||||
slider are implemented. Older model files remain stored but cannot run in this format.
|
|
||||||
Recording, bounded dataset/training, permanent named models, live inference,
|
|
||||||
checked autonomous command submission, and the verified VIP card are coded.
|
|
||||||
Human commands do not stop or pause autonomy (explicit latest user requirement).
|
|
||||||
Focused local checks passed; actual recording/training, live autonomous behavior,
|
|
||||||
concurrent server performance, and browser appearance remain unverified.
|
|
||||||
The standalone CPU benchmark was run successfully on the actual server.
|
|
||||||
|
|
||||||
This document preserves the conversation's decisions across compactions and
|
|
||||||
usage cutoffs. Keep the checklist and handoff section current during authorized
|
|
||||||
implementation. Do not treat proposals or open questions as user decisions.
|
|
||||||
|
|
||||||
The user explicitly authorized feature implementation with: "okay cool. begin
|
|
||||||
implementation, use the md to track. IPI". Continue the agreed implementation
|
|
||||||
without requesting IPI again. Follow the current
|
|
||||||
AGENTS.md instructions: targeted investigation, minimal edits, no new tests unless
|
|
||||||
requested, proportional direct verification, and no development processes left
|
|
||||||
running. This development machine is not the actual rover server.
|
|
||||||
|
|
||||||
The user declined the manual server checklist and explicitly asked to continue.
|
|
||||||
Do not block further work or repeatedly ask them to do it. Preserve the unverified
|
|
||||||
status and have the reader reject unusable recordings automatically.
|
|
||||||
|
|
||||||
Latest increment: internal card gating, dock lead-in/tail capture, live prediction
|
|
||||||
diagnostics, ongoing retained-data sampling, and gated model publication are coded.
|
|
||||||
See the final handoff section for settings and the new training lineage.
|
|
||||||
|
|
||||||
Next work: verify the integrated feature against actual recordings and the real
|
|
||||||
camera/rover pipeline when that environment is available; inspect the VIP card in
|
|
||||||
the running browser. Do not invent successful driving results or block further
|
|
||||||
implementation on the previously declined manual checklist.
|
|
||||||
|
|
||||||
## Intended product
|
|
||||||
|
|
||||||
Learn from ordinary human rover use, then let a user select a saved model and
|
|
||||||
press Start to have it roam and operate the rover's controls. No destination or
|
|
||||||
structured route is required. Users should not need dedicated demonstration
|
|
||||||
sessions before collection becomes useful. Build the complete collection and
|
|
||||||
training pipeline; do not reduce the intended product to a single-route demo.
|
|
||||||
|
|
||||||
Chat is out of scope. The user explicitly dropped it to keep the model compact.
|
|
||||||
This is a learned control policy, not a language model generating instructions.
|
|
||||||
|
|
||||||
Learning useful behavior is an experimental objective, not a guaranteed result
|
|
||||||
of accumulating recordings. Autonomous performance must be observed; low training
|
|
||||||
loss or fast inference alone does not establish competent driving.
|
|
||||||
|
|
||||||
## Agreed requirements
|
|
||||||
|
|
||||||
- One self-contained server service, provisionally `roverLearningService`.
|
|
||||||
- Minimal integration elsewhere: configuration/startup, command recording hook,
|
|
||||||
checked command submission hook, and the necessary UI connection.
|
|
||||||
- The service owns its video recorder processes. Do not move this feature into
|
|
||||||
replay, rover management, turn management, or unrelated services.
|
|
||||||
- Configuration defaults to disabled. Disabled means no active feature runtime.
|
|
||||||
- Record every rover control input, video, and all available rover sensors.
|
|
||||||
- Model inputs use recent control history/current state, useful sensors, and
|
|
||||||
video. Model outputs can use the rover's supported operating controls, not just
|
|
||||||
wheel speeds. Available hardware/control capabilities must be accounted for.
|
|
||||||
- Record broadly; choosing training features must not discard raw source data
|
|
||||||
prematurely within the configured retention window.
|
|
||||||
- Retain a reasonable bounded rolling training dataset, with ongoing background
|
|
||||||
training. The model architecture/parameter count remains bounded as it learns.
|
|
||||||
- Periodically publish immutable named model versions. Two random words plus a
|
|
||||||
unique identifier/sequence is the proposed naming scheme.
|
|
||||||
- Keep every published model permanently. No automatic model pruning.
|
|
||||||
- Users can choose which compatible model controls their rover.
|
|
||||||
- Add the UI card at the top of the VIP tab, using the existing verified-feature
|
|
||||||
mechanism. The card is absent when the feature is disabled.
|
|
||||||
- Model control must use the same permissions and eligibility rules as manual
|
|
||||||
rover control. Verified feature access does not grant rover control.
|
|
||||||
- Favorites are client-side saved model IDs only. No server favorite/pin state,
|
|
||||||
endpoints, or retention behavior tied to favorites.
|
|
||||||
- All server information needed to populate the card arrives in one coherent
|
|
||||||
state object. Action commands remain separate from that state object.
|
|
||||||
|
|
||||||
Earlier suggestions to prune old model versions or retain only server-pinned
|
|
||||||
favorites are superseded. Rolling retention applies to training data, not the
|
|
||||||
published model catalog.
|
|
||||||
|
|
||||||
## Architecture and containment
|
|
||||||
|
|
||||||
Proposed internal ownership (not a requirement to scaffold empty directories):
|
|
||||||
|
|
||||||
```text
|
|
||||||
server/src/services/roverLearningService/
|
|
||||||
configuration.js Schema/defaults; no runtime side effects
|
|
||||||
index.js Small enable/disable lifecycle entry point
|
|
||||||
runtime.js Enabled runtime coordination
|
|
||||||
recording/ Commands, sensors, video processes, synchronization
|
|
||||||
dataset/ Rolling storage, training sampling, retention
|
|
||||||
training/ Training scheduling and checkpoint production
|
|
||||||
models/ Permanent catalog, names, metadata, publication
|
|
||||||
driving/ Inference sessions and action scheduling
|
|
||||||
workers/ Python model/training implementation
|
|
||||||
socketGateway.js Full card state and authorized user actions
|
|
||||||
```
|
|
||||||
|
|
||||||
Persistent video, datasets, checkpoints, and catalog metadata belong under the
|
|
||||||
application's existing data-path convention, not the service source directory.
|
|
||||||
The agreed root is `<data>/rover-learning/`, normally `/data/rover-learning/` in
|
|
||||||
the deployed container (`SERVER_DATA_DIR`), or `server/data/rover-learning/` with
|
|
||||||
the default development path. Intended layout:
|
|
||||||
|
|
||||||
```text
|
|
||||||
rover-learning/
|
|
||||||
recordings/ Rolling video/events/session metadata (implemented)
|
|
||||||
models/ Permanent UUID directories: weights.pt and model.json
|
|
||||||
training/ resume-v3.pt, schedule-v3.json, and current job.json
|
|
||||||
```
|
|
||||||
|
|
||||||
Training/model directories are created only when service and training are enabled.
|
|
||||||
Session/model manifests and a small scheduling ledger are sufficient at present;
|
|
||||||
the previously proposed catalog.sqlite is not needed or created.
|
|
||||||
Python compute and video processing must not block the Node event loop. Worker
|
|
||||||
dependencies, deployment packaging, and process shutdown must be included in the
|
|
||||||
implementation plan; installing Python packages at server startup is not assumed.
|
|
||||||
|
|
||||||
The outside command system should know only about optional observation and
|
|
||||||
checked submission. It should know nothing about model architecture, datasets,
|
|
||||||
training, model names, or video recorders.
|
|
||||||
|
|
||||||
### Existing integration findings (recheck before edits)
|
|
||||||
|
|
||||||
- `server/index.js` explicitly loads services.
|
|
||||||
- `server/src/configuration/definition.js` assembles service-owned configuration
|
|
||||||
fragments. `configuration/index.js` provides live reload handlers.
|
|
||||||
- `server/src/services/commandService/index.js` exports `issueCommand()` and
|
|
||||||
`commandEvents`. Existing `observation` events intentionally omit payloads;
|
|
||||||
they are analytics events and insufficient as training examples.
|
|
||||||
- `issueCommand()` is a low-level send function. Many authorization, turn,
|
|
||||||
cooldown, private-drive safety, and overcurrent checks occur in the browser
|
|
||||||
command handler before calling it. Direct autonomous calls must not bypass
|
|
||||||
those checks.
|
|
||||||
- `roverManager.managerEvents` already emits decoded `sensor` events and rover
|
|
||||||
lifecycle events. The sensor event is not necessarily every raw telemetry field;
|
|
||||||
inventory what is available before claiming complete sensor capture.
|
|
||||||
- `fleetReportService` demonstrates subscriptions and configuration lifecycle,
|
|
||||||
but its persistent storage/lifecycle is not automatically the desired disabled
|
|
||||||
behavior for this new service.
|
|
||||||
- `replayEngineV2` demonstrates FFmpeg capture and RTSP/TCP source handling. Reuse
|
|
||||||
applicable media conventions without making this feature depend on replay
|
|
||||||
being enabled or sharing replay's retention ownership.
|
|
||||||
|
|
||||||
### Command recording
|
|
||||||
|
|
||||||
Proposed optional observation contract includes timestamp, rover, operator/source,
|
|
||||||
requested command payload, dispatched payload, outcome, and command ID where
|
|
||||||
available. Requested and dispatched values can differ due to safety processing.
|
|
||||||
Distinguish dispatched/acknowledged commands from measured physical execution.
|
|
||||||
|
|
||||||
Record human, autonomous, and service-generated commands with distinct provenance.
|
|
||||||
Autonomous predictions are not automatically correct human training labels.
|
|
||||||
Include rejected/ignored outcomes where relevant; do not present them as executed
|
|
||||||
actions. Inventory control paths outside the common browser command handler so
|
|
||||||
camera/accessory controls are not silently omitted.
|
|
||||||
|
|
||||||
The hook only enqueues bounded work. Recording failures or disk pressure must not
|
|
||||||
block/fail manual command delivery. Preserve the existing payload-free analytics
|
|
||||||
event contract instead of putting detailed recordings into general logging.
|
|
||||||
Requested payload snapshots must precede any in-place command transformation.
|
|
||||||
|
|
||||||
### Checked command sending and ownership
|
|
||||||
|
|
||||||
Proposed implementation: extract a reusable checked submission path from the
|
|
||||||
current browser command handler, preserving existing manual behavior. Autonomy
|
|
||||||
submits on behalf of its initiating user's actual control context. Do not invent
|
|
||||||
an admin identity or send directly to the rover WebSocket.
|
|
||||||
|
|
||||||
Recheck permissions server-side for actions and continued control. Losing the
|
|
||||||
turn/assignment/connection or otherwise losing eligibility stops the autonomous
|
|
||||||
session. A safe stop must still be possible after ordinary permission is revoked.
|
|
||||||
Explicit user decision: incoming human commands do not stop or pause autonomy.
|
|
||||||
Both pass through the existing checked command path; subsequent model commands
|
|
||||||
can replace human commands. Stop remains explicit, with permission/disconnect,
|
|
||||||
stale input, and worker failure stops. Model-influenced sessions are excluded
|
|
||||||
from human demonstration recording, including manual inputs during autonomy.
|
|
||||||
|
|
||||||
Model actions describe operating controls, not arbitrary server maintenance.
|
|
||||||
Inventory reboot/update/raw commands and capability restrictions explicitly rather
|
|
||||||
than allowing unrestricted command dictionaries from model outputs.
|
|
||||||
|
|
||||||
### Disabled lifecycle
|
|
||||||
|
|
||||||
Only configuration metadata and a small lifecycle handler remain registered while
|
|
||||||
disabled, to support live enablement. Lazy-load runtime modules on enable.
|
|
||||||
|
|
||||||
When disabled there are no feature recording subscriptions, command observers,
|
|
||||||
video/Python workers, runtime timers, dataset scans, open feature databases,
|
|
||||||
created directories, inference/training, or feature socket handlers. The existing
|
|
||||||
config/feature mechanism hides the card without starting this runtime.
|
|
||||||
|
|
||||||
On disable: stop autonomous motion, detach observers, stop and await owned child
|
|
||||||
processes, flush/close storage appropriately, remove socket handlers/timers, and
|
|
||||||
release runtime state. Preserve all saved data/models. Serialize enable/disable
|
|
||||||
transitions so overlapping reloads cannot leave duplicate workers or listeners.
|
|
||||||
|
|
||||||
## Data and model design
|
|
||||||
|
|
||||||
The benchmark architecture is a representative workload, not a selected final
|
|
||||||
policy. It uses four RGB frames at 160x120, 32 sensor and 32 control/state values
|
|
||||||
per frame, a spatial CNN and GRU, eight continuous outputs, and sixteen button
|
|
||||||
logits. These counts are placeholders, not the actual rover control schema.
|
|
||||||
|
|
||||||
- Past controls/current state are inputs; the human's next action is a training
|
|
||||||
target. Avoid future-action leakage into model input.
|
|
||||||
- Maintain temporal coherence. Independent guesses can hesitate or oscillate;
|
|
||||||
multiple human intentions must not simply average into meaningless controls.
|
|
||||||
- Continuous controls and discrete button/toggle events require different output
|
|
||||||
semantics. Do not retrigger a one-shot action every inference tick.
|
|
||||||
- Include camera pose/state when available, and supported control capabilities.
|
|
||||||
- Preserve synchronized timestamps and sequence boundaries. Server receive time
|
|
||||||
does not prove which frame the remote driver saw. Exact displayed-frame timing
|
|
||||||
may need a small frontend timing hook; that is not yet designed or authorized as
|
|
||||||
a settled integration requirement.
|
|
||||||
- Human takeovers/corrections are valuable demonstrations. Do not indiscriminately
|
|
||||||
discard playful behavior: the goal includes human-like unstructured roaming.
|
|
||||||
- An action timeout/stale-video stop and existing hardware safety remain outside
|
|
||||||
the model's learned decision-making.
|
|
||||||
|
|
||||||
Training proposal: shuffle windows from retained sessions, including recent and
|
|
||||||
older examples. Publish after a configured amount of training work, potentially
|
|
||||||
with a minimum elapsed interval. Steps/examples are less ambiguous than epochs
|
|
||||||
over a continuously changing dataset. Continue training independently of active
|
|
||||||
inference; published models are immutable and do not change beneath a session.
|
|
||||||
|
|
||||||
Background work should be resource-limited and lower priority. Pausing after
|
|
||||||
sufficient reuse of unchanged data is proposed; continuous repeated passes are
|
|
||||||
not automatically useful. A small reserve of older valuable sessions was suggested
|
|
||||||
to reduce forgetting but is not a settled retention requirement.
|
|
||||||
|
|
||||||
Training readers must not lose files mid-batch to retention. Publish checkpoints
|
|
||||||
atomically so clients cannot select partially written models. Store sufficient
|
|
||||||
architecture, feature/action schema, normalization, and training metadata to load
|
|
||||||
each permanent model correctly or report it as incompatible.
|
|
||||||
|
|
||||||
## Model catalog and UI mechanics
|
|
||||||
|
|
||||||
Proposed model names: `curious-otter-0042`, `amber-finch-0043`. Names are display
|
|
||||||
labels; stable unique IDs are used for selection and local favorites. Record
|
|
||||||
creation time, architecture/schema identity, training progress, and dataset
|
|
||||||
provenance/summary. Never assume a newer model is better.
|
|
||||||
|
|
||||||
Card scope: model selection with local favorites, Start/Stop, current session
|
|
||||||
status, and compact training status. Reuse current VIP appearance and existing
|
|
||||||
settings persistence rather than inventing a new storage layer. UI implementation
|
|
||||||
locations have not been inspected yet.
|
|
||||||
|
|
||||||
Illustrative single state object (not a finalized protocol):
|
|
||||||
|
|
||||||
```js
|
|
||||||
{
|
|
||||||
available: true,
|
|
||||||
roverId,
|
|
||||||
control: { canStart, canStop, reason },
|
|
||||||
session: { status, modelId, startedBy, startedAt },
|
|
||||||
models: [{ id, name, createdAt, trainingSteps, compatible }],
|
|
||||||
training: { status, examplesProcessed, latestModelId }
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Derive per-user permissions on the server and recheck on execution. State updates
|
|
||||||
should be coherent and event-driven/coalesced, not broadcast at camera frequency.
|
|
||||||
The permanent catalog can grow; avoid blocking command handling while assembling
|
|
||||||
or delivering card state. Any future pagination must preserve the user's single
|
|
||||||
card-state contract rather than silently introducing independent UI data feeds.
|
|
||||||
|
|
||||||
Selection semantics proposed earlier: stop before switching an active model,
|
|
||||||
reset temporal state, and resume only explicitly. Confirm exact behavior as part
|
|
||||||
of UI design. Client favorites do not affect server retention or permissions.
|
|
||||||
|
|
||||||
## Hardware and measured feasibility
|
|
||||||
|
|
||||||
Actual server: Fedora 43, two Xeon E5-2620 v4 processors, 16 physical cores / 32
|
|
||||||
threads, AVX2, about 125 GiB RAM with 118 GiB available in the initial sample.
|
|
||||||
The server only runs the rover server. Approximately 80% CPU idle was measured
|
|
||||||
under normal working load, not on an otherwise idle test machine.
|
|
||||||
|
|
||||||
The relevant benchmark is the custom vision/control workload, not the earlier
|
|
||||||
Qwen text benchmark. Server run: Python 3.14.7, PyTorch 2.14.0+cpu, FP32,
|
|
||||||
488,008 parameters, four RGB frames at 160x120. Training batch size 16.
|
|
||||||
|
|
||||||
| Mode | Threads | Rover/training batch | p50 ms | p95 ms | Max ms | Examples/s | CPU cores used | Peak MiB |
|
|
||||||
|---|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
||||||
| Inference | 4 | 1 | 5.8 | 8.0 | 16.5 | 165.43 | 3.97 | 321 |
|
|
||||||
| Inference | 4 | 2 | 10.4 | 13.6 | 24.1 | 187.39 | 3.96 | 334 |
|
|
||||||
| Inference | 4 | 4 | 18.7 | 25.1 | 34.1 | 207.08 | 3.96 | 357 |
|
|
||||||
| Inference | 8 | 1 | 4.4 | 5.9 | 12.9 | 219.98 | 7.95 | 318 |
|
|
||||||
| Inference | 8 | 2 | 7.5 | 9.2 | 13.2 | 272.28 | 7.95 | 330 |
|
|
||||||
| Inference | 8 | 4 | 15.3 | 17.5 | 26.0 | 263.88 | 7.81 | 359 |
|
|
||||||
| Inference | 16 | 1 | 4.9 | 7.3 | 23.1 | 191.94 | 15.77 | 318 |
|
|
||||||
| Inference | 16 | 2 | 7.5 | 10.9 | 21.0 | 257.54 | 15.76 | 333 |
|
|
||||||
| Inference | 16 | 4 | 11.6 | 17.9 | 33.6 | 319.84 | 14.98 | 359 |
|
|
||||||
| Training | 4 | 16 | 210.7 | 227.4 | 245.5 | 75.36 | 3.79 | 655 |
|
|
||||||
| Training | 8 | 16 | 141.2 | 172.8 | 176.5 | 108.57 | 7.09 | 673 |
|
|
||||||
| Training | 16 | 16 | 121.6 | 228.9 | 263.4 | 112.37 | 13.29 | 669 |
|
|
||||||
|
|
||||||
Inference latency is per entire batch, not per rover within it. CPU measurements
|
|
||||||
are saturation runs, not paced 10 Hz operation. Eight threads is a promising
|
|
||||||
starting point, not a finalized resource allocation. At 10 Hz there are 36,000
|
|
||||||
training windows per recorded rover-hour; one pass takes about 5.5 compute-only
|
|
||||||
minutes at eight threads. Dataset hours and repeat passes multiply that cost.
|
|
||||||
|
|
||||||
Limits: synthetic data/random weights, no decoding/resizing/network/dataset I/O,
|
|
||||||
no concurrent inference and training, and no measured autonomous driving quality.
|
|
||||||
The target of 10 decisions/second is provisional. A larger model or higher image
|
|
||||||
resolution requires renewed measurement. These results do not mandate a GPU.
|
|
||||||
|
|
||||||
Approximate storage for this architecture: 1.9 MiB FP32 inference weights; 5.6 MiB
|
|
||||||
for weights plus Adam optimizer state, before metadata/serialization overhead.
|
|
||||||
One hundred inference versions are about 186 MiB. Dataset video dominates storage.
|
|
||||||
Published inference artifacts and resumable training state need not be identical.
|
|
||||||
|
|
||||||
## Open implementation decisions
|
|
||||||
|
|
||||||
- Exact operating-control catalog, continuous/event action encoding, capability
|
|
||||||
masking, selected sensors, model architecture/history, and update rate.
|
|
||||||
- Dataset disk budget, segment format, sampling strategy, session quality markers,
|
|
||||||
and whether to retain a reserved historical subset.
|
|
||||||
- Time alignment between video observed by the driver and command generation.
|
|
||||||
- Python packaging, IPC, process supervision, CPU limits, and worker failure policy.
|
|
||||||
- Checkpoint cadence/names, resume-state retention, evaluation metadata, and how
|
|
||||||
new versions become selectable. Do not add an unrequested admin approval flow.
|
|
||||||
- Exact ownership/takeover behavior, disconnect/turn-loss handling, model switch
|
|
||||||
behavior, and which user may stop an active session under existing permissions.
|
|
||||||
- Card fields and existing client persistence mechanism; current UI paths still
|
|
||||||
need targeted inspection.
|
|
||||||
|
|
||||||
## Implementation checklist
|
|
||||||
|
|
||||||
- [x] Discuss scope: autonomous roaming, full rover controls, no chat.
|
|
||||||
- [x] Add standalone CPU benchmark and verify its execution locally.
|
|
||||||
- [x] Obtain and interpret results from the actual server.
|
|
||||||
- [x] Record containment, default-disabled behavior, VIP placement, permissions,
|
|
||||||
permanent model retention, client favorites, and single card-state requirement.
|
|
||||||
- [x] Obtain IPI for feature implementation.
|
|
||||||
- [ ] Inspect remaining UI/control paths and settle concrete contracts.
|
|
||||||
- [x] Add service-owned config and dormant lifecycle integration.
|
|
||||||
- [x] Add optional detailed command observer and shared checked submission path.
|
|
||||||
- [ ] Implement timestamped recording, video worker ownership, and bounded dataset.
|
|
||||||
Code is implemented for common commands, raw/decoded rover sensor frames,
|
|
||||||
host stats, and existing rover state events. Live video/timing/retention
|
|
||||||
verification and full control-path inventory remain; do not mark complete yet.
|
|
||||||
- [x] Implement feature/action encoding and training worker with resource limits.
|
|
||||||
Runtime import/dependency checks passed; training on actual recordings is still
|
|
||||||
unverified. The current schema limits and next inference work are described below.
|
|
||||||
- [x] Implement atomic checkpoint publication and permanent model catalog.
|
|
||||||
Code complete; no actual-data published checkpoint has been produced locally.
|
|
||||||
- [x] Implement model inference, ownership, explicit stops, and worker cleanup.
|
|
||||||
Human input does not stop autonomy; live execution remains unverified.
|
|
||||||
- [x] Implement service socket gateway with complete per-user card state.
|
|
||||||
- [x] Implement verified VIP card at top, model selection, local favorites, controls,
|
|
||||||
and expandable diagnostics. Browser appearance remains unverified.
|
|
||||||
- [ ] Verify disabled startup and enable/disable cleanup directly.
|
|
||||||
- [ ] Verify manual-control behavior remains intact and autonomous permission loss
|
|
||||||
stops motion using an appropriate authorized runtime.
|
|
||||||
- [ ] Measure real video pipeline and concurrent training/inference on the server.
|
|
||||||
- [ ] Evaluate autonomous behavior with supervised rover runs before claiming
|
|
||||||
useful driving performance.
|
|
||||||
|
|
||||||
Verification must follow user policy: no new/expanded tests unless explicitly
|
|
||||||
requested; use existing small relevant checks or actual affected runtime. Do not
|
|
||||||
automatically run full suites/builds. Hardware/server checks must be handed to
|
|
||||||
the user when the development environment cannot perform them.
|
|
||||||
|
|
||||||
## Handoff log
|
|
||||||
|
|
||||||
### First implementation increment
|
|
||||||
|
|
||||||
Implemented files:
|
|
||||||
|
|
||||||
- `server/src/services/roverLearningService/configuration.js`: default disabled;
|
|
||||||
recording budget 20 GiB, minimum free space 2 GiB, session duration 300 seconds.
|
|
||||||
These are initial implementation defaults, adjustable in the existing admin
|
|
||||||
configuration editor, not measured final production sizing.
|
|
||||||
- `server/src/services/roverLearningService/index.js`: serialized lifecycle bridge;
|
|
||||||
lazy runtime require; no recording side effects while disabled.
|
|
||||||
- `server/src/services/roverLearningService/runtime.js`: enabled-only subscriptions,
|
|
||||||
recorder ownership, bounded startup/rotation event buffering, retention scheduling,
|
|
||||||
disconnect/rotation handling, and signal/disable cleanup.
|
|
||||||
- `server/src/services/roverLearningService/recording.js`: per-session metadata,
|
|
||||||
bounded NDJSON output, and FFmpeg RTSP/TCP video packet copy into Matroska.
|
|
||||||
No camera re-encoding, Python process, training, or autonomous commands yet.
|
|
||||||
- `server/src/services/roverLearningService/storage.js`: oldest-first recording
|
|
||||||
retention, free-space enforcement, and interrupted-session recovery.
|
|
||||||
- `server/src/services/commandService/recording.js`: optional failure-isolated
|
|
||||||
request/dispatch/result/ack observer with payload snapshots and IDs. Source is
|
|
||||||
`client` or `server`; browser automation is not falsely classified as proven
|
|
||||||
intentional human action. Async safety timers do not retain client attribution.
|
|
||||||
|
|
||||||
External changes are limited to startup, configuration definition, command-hook
|
|
||||||
wiring, and adding the new feature to an existing configuration test's expected
|
|
||||||
feature list (required by the feature addition). No new tests were created.
|
|
||||||
Raw sensor capture reads `lastSensor.raw` during the existing synchronous sensor
|
|
||||||
event; rover manager/sensor pipeline did not need modification.
|
|
||||||
|
|
||||||
Recording format:
|
|
||||||
|
|
||||||
```text
|
|
||||||
recordings/<unix-ms>-<uuid>/
|
|
||||||
session.json Schema version, rover/session identity, timing description,
|
|
||||||
start/end/reason/error, loss count, video exit information
|
|
||||||
events.ndjson Unix ms + process monotonic ns, kind, data
|
|
||||||
video.mkv Original video codec; receiver wall-clock PTS retained
|
|
||||||
```
|
|
||||||
|
|
||||||
Request/dispatch/ack IDs let later training join requested and applied commands.
|
|
||||||
The recorded `source` is provenance, not proof of a correct demonstration.
|
|
||||||
Queued events preserve their observation timestamp; file order around a new
|
|
||||||
session's snapshot is not necessarily chronological. Sort by timestamps when
|
|
||||||
constructing training windows. Rotations can have video gaps; do not train across
|
|
||||||
session boundaries as if footage were continuous.
|
|
||||||
|
|
||||||
Limits and follow-up:
|
|
||||||
|
|
||||||
- Retention is checked every 15 seconds and is a soft disk budget, not a filesystem
|
|
||||||
quota. Active files are closed before becoming eligible for pruning. Low free
|
|
||||||
space pauses capture. Models/training paths are never traversed by retention.
|
|
||||||
- Record only rovers with a connected human driver who currently passes the
|
|
||||||
existing control checks, including deterrence. Membership is required even for
|
|
||||||
admins so an admin login alone does not record unattended rovers. Preserve pauses
|
|
||||||
within an eligible human-control session. Driver/turn/mode/permission changes
|
|
||||||
reconcile recording immediately; telemetry buffering uses the same eligibility.
|
|
||||||
This supersedes the initial all-connected-rovers policy at the user's request.
|
|
||||||
Autonomous sessions will need explicit exclusion/provenance when implemented;
|
|
||||||
there are currently no autonomous controllers.
|
|
||||||
- Each event stream has a 2 MiB pending-write bound; startup/rotation buffering has
|
|
||||||
a shared 4 MiB bound. Session loss counters/capture-gap events identify overload.
|
|
||||||
- Video timestamps represent packet receipt at this server, not the frame a human
|
|
||||||
browser displayed. Actual FFmpeg output PTS still needs inspection on the server.
|
|
||||||
- Enabled recorders are stopped on disable and awaited; process-exit hooks kill
|
|
||||||
surviving children. Existing MediaMTX shutdown can exit the whole Node process
|
|
||||||
before async metadata flush finishes; next enable marks those sessions interrupted.
|
|
||||||
- Storage leases for concurrent training reads are pending with dataset sampling.
|
|
||||||
- Safe command submission extraction, autonomous provenance, model selection,
|
|
||||||
client favorites, and UI remain pending. Training/catalog code follows below.
|
|
||||||
|
|
||||||
Verification completed:
|
|
||||||
|
|
||||||
- `node --check` on new recording/lifecycle modules and modified command service:
|
|
||||||
passed (repeat only for subsequently changed modules as needed).
|
|
||||||
- `node --test server/src/configuration/configuration.test.js`: 14 passed, 1 failed.
|
|
||||||
The failing pre-existing default-values assertion omits
|
|
||||||
`$.discord.channels.liveStatus`; the current HEAD already defines it as empty.
|
|
||||||
New configuration validation, descriptions, normalization, and feature flags
|
|
||||||
passed. Leave the unrelated Discord assertion untouched.
|
|
||||||
- Direct disabled service startup with a disposable `SERVER_DATA_DIR` returned
|
|
||||||
`{"runtimeLoaded":false,"storageCreated":false}` and exited normally. Temporary
|
|
||||||
configuration storage was removed. No development service was left running.
|
|
||||||
- Local FFmpeg help confirms the RTSP transport and timeout options used.
|
|
||||||
- No full build, full lint, new tests, live server startup, or rover hardware run.
|
|
||||||
|
|
||||||
Optional server verification reference (user declined doing this checklist;
|
|
||||||
do not require it before continuing implementation):
|
|
||||||
|
|
||||||
1. Deploy normally, initially disabled; confirm no `rover-learning` directory or
|
|
||||||
learning FFmpeg workers are created by a fresh disabled startup.
|
|
||||||
2. Enable `roverLearning.enabled` through the existing admin configuration. An
|
|
||||||
online unattended rover must not produce recordings. Take human control and
|
|
||||||
confirm session files appear beneath the actual data mount; pauses should keep
|
|
||||||
recording. Release control/disconnect the last eligible driver and confirm
|
|
||||||
capture stops. Repeat across turn changes and mode/permission changes.
|
|
||||||
3. Drive, move the camera, and operate accessories. Check that event records contain
|
|
||||||
requested and dispatched payloads with matching IDs, sensor frames, and host stats.
|
|
||||||
4. For a completed session, inspect video with:
|
|
||||||
`ffprobe -v error -select_streams v:0 -read_intervals '%+#3' -show_entries packet=pts_time,dts_time -of json /actual/session/video.mkv`.
|
|
||||||
Compare packet timestamps (seconds) with event timestamps (milliseconds).
|
|
||||||
Report FFmpeg/session errors rather than assuming timestamp alignment is correct.
|
|
||||||
5. Disable: confirm owned FFmpeg processes exit, session metadata closes, no further
|
|
||||||
writes occur, and saved recordings remain. Re-enable to check clean restart.
|
|
||||||
6. Verify rotation and retention with an intentionally chosen small recording
|
|
||||||
budget, and confirm manual driving remains responsive during recording.
|
|
||||||
|
|
||||||
Actual server capture/timing and live cleanup remain unverified. These steps are
|
|
||||||
a reference for future runtime investigation, not a pending request to the user.
|
|
||||||
|
|
||||||
### Dataset/training/checkpoint increment
|
|
||||||
|
|
||||||
The user supplied IPI, then asked to continue after a rate-limit interruption.
|
|
||||||
Authorization remains in effect. Implemented:
|
|
||||||
|
|
||||||
- `workers/policy.py`: fixed-size spatial CNN/GRU with 64 action-shape slots and
|
|
||||||
up to 8 numeric output fields per shape. Four 160x120 RGB frames, spaced roughly
|
|
||||||
300 ms apart, with 32 selected sensors plus missing-value masks and previous
|
|
||||||
command values/ages/known flags. About 805k parameters (larger than the benchmark).
|
|
||||||
- Numeric controls are normalized with `2/pi * atan(value/scale)`; the model
|
|
||||||
produces per-shape event logits and numeric values for the next 100 ms.
|
|
||||||
Command types supported: drive, motors, servo, headlight, laser, horn, peripheral,
|
|
||||||
song, raw. Strings/bools/arrays remain exact template constants; variable-size
|
|
||||||
songs/raw OI payloads are discrete demonstrations, not generated arbitrary bytes.
|
|
||||||
Maintenance commands, audio/text and sensor-stream setup are not action targets.
|
|
||||||
All recordings remain intact until normal dataset retention, regardless of
|
|
||||||
whether the trainer can use their commands.
|
|
||||||
- The schema grows only into unused fixed slots and is saved with each model.
|
|
||||||
Overflow (64 shapes/8 numeric fields/4096-byte commands) skips the affected
|
|
||||||
recording with a reason instead of silently truncating controls. This is a
|
|
||||||
concrete initial encoding limit, not a promise to represent unlimited hardware
|
|
||||||
or arbitrary song libraries. Revisit it if real recordings exhaust capacity.
|
|
||||||
- `workers/dataset.py`: bounded event loading (64 MiB/session), PyAV decoding,
|
|
||||||
absolute timestamp checks, causal sensor/action history, accepted-client-command
|
|
||||||
targets, and masks for observed action slots. Actual measured wheel speeds are
|
|
||||||
among the selected sensor inputs. Missing/stale sensors, video gaps, clock shifts,
|
|
||||||
incomplete recordings, data loss, and ambiguous conflicting actions are excluded.
|
|
||||||
Server-generated control overrides are history/boundaries, not demonstrations.
|
|
||||||
- Validation is automatic and cannot establish what frame a browser displayed.
|
|
||||||
A session is only accepted after its video finishes decoding; samples from a
|
|
||||||
subsequently corrupt session are discarded. Commands/sensors at future times
|
|
||||||
do not enter observation history.
|
|
||||||
- `workers/train.py`: finite CPU jobs, lower OS priority, shuffled bounded reservoir
|
|
||||||
sampling, sparse-event classification plus masked numeric regression, gradient
|
|
||||||
clipping, finite-loss checks, and AdamW resume state. Never sends rover commands.
|
|
||||||
- `training.js`: one owned worker, recording leases acquired within serialized
|
|
||||||
maintenance, bounded stdout/stderr, interval and wall-clock limits, shutdown,
|
|
||||||
persistent per-recording visit/rejection ledger, and progress state for the
|
|
||||||
future card. Retention protects leased recordings. Disk pressure cancels the
|
|
||||||
training job before files become eligible for pruning again.
|
|
||||||
- Atomic `training/resume.pt` includes model and optimizer state, schema, cumulative
|
|
||||||
steps/examples, and publication progress. Permanent model directories are
|
|
||||||
atomically renamed into visibility with weights and metadata together.
|
|
||||||
- `models.js`: reads the permanent metadata catalog. Each model has a UUID, two-word
|
|
||||||
generated name plus UUID suffix, creation time, steps/examples, schema, source
|
|
||||||
session IDs, trained rover IDs, training loss, parameter count, weight hash/size,
|
|
||||||
and `evaluation: unevaluated`. No model pruning, favorites, or server pin state.
|
|
||||||
- Dockerfile packages a CPU-only Python venv at `/opt/rover-learning` during image
|
|
||||||
build. Pinned requirements: torch 2.14.0+cpu, NumPy 2.5.3, PyAV 18.1.0. No runtime
|
|
||||||
downloads/installers. A disabled service does not load or run these dependencies.
|
|
||||||
|
|
||||||
Training defaults beneath `roverLearning.training`:
|
|
||||||
|
|
||||||
| Setting | Default |
|
|
||||||
|---|---:|
|
|
||||||
| enabled (still gated by outer service enabled=false) | true |
|
|
||||||
| threads | 8 |
|
|
||||||
| batchSize | 16 |
|
|
||||||
| maxSamples / minimumSamples | 1024 / 128 |
|
|
||||||
| passesPerJob | 2 |
|
|
||||||
| minimumDrivingMinutes / newDrivingMinutesPerModel | 30 / 10 |
|
|
||||||
| maxStepsPerJob | 200 |
|
|
||||||
| checkpointEverySteps | 1000 |
|
|
||||||
| intervalSeconds | 60 |
|
|
||||||
| maxJobSeconds | 600 |
|
|
||||||
|
|
||||||
Defaults tuned to the user's dual E5-2620 v4 server benchmark: eight training
|
|
||||||
threads improved throughput substantially over four, while sixteen gave little
|
|
||||||
additional benefit. The larger sample pool and shorter interval are starting
|
|
||||||
choices for its available resources, not measured learning-quality improvements.
|
|
||||||
Inference remains four threads per rover. Existing explicit configuration values
|
|
||||||
override these defaults; this change does not rewrite deployed configuration.
|
|
||||||
|
|
||||||
At most four completed retained sessions are selected per job. Samples are spread
|
|
||||||
over each full video via reservoir sampling, not always taken from its beginning.
|
|
||||||
The prior three-visit limit is removed. Retained usable training sessions remain
|
|
||||||
eligible for repeated sampling; finite jobs still yield between runs.
|
|
||||||
Candidate publication happens at job boundaries after data, step, and held-out gates;
|
|
||||||
it does not automatically deploy a model. No all-no-action sample pool is trained.
|
|
||||||
|
|
||||||
Container rebuild is needed to include Python packages. For a non-container
|
|
||||||
deployment, install `workers/requirements.txt` into a dedicated venv and set
|
|
||||||
`training.python` to its Python path. The default path is container-specific.
|
|
||||||
|
|
||||||
Verification performed in this increment:
|
|
||||||
|
|
||||||
- Installed the actual pinned requirements in `/tmp/rover-bench-check-env`.
|
|
||||||
- Ran the production worker's `--check-dependencies`: PyTorch/PyAV/NumPy imports,
|
|
||||||
dataset imports, policy construction, and specification output succeeded.
|
|
||||||
- Four existing focused configuration tests passed (defaults, descriptions,
|
|
||||||
generated feature paths, normalization). The unrelated prior Discord default
|
|
||||||
assertion was not rerun or changed.
|
|
||||||
- Node syntax checks on scheduler/runtime/catalog passed.
|
|
||||||
- No new tests, fake recording fixtures, or throwaway test programs were created.
|
|
||||||
- No real recordings are available locally. Actual decoding/alignment, optimizer
|
|
||||||
execution against recordings, checkpoint publication/resume, and concurrent
|
|
||||||
server performance are not claimed verified. No full container build was run.
|
|
||||||
|
|
||||||
The following controller/UI increment implements those remaining code paths.
|
|
||||||
Real-data and live-hardware verification still remain before claiming usable autonomy. The initial model is an experimental imitation policy, not a demonstrated
|
|
||||||
competent driver or a final architecture choice.
|
|
||||||
|
|
||||||
When resuming after implementation begins, append concrete changed files, commands
|
|
||||||
run/results, unresolved failures, decisions approved by the user, and the next
|
|
||||||
bounded task here. Do not mark a checkbox complete merely because code exists;
|
|
||||||
record verification limits and outstanding server/hardware work explicitly.
|
|
||||||
|
|
||||||
|
|
||||||
### Controller and VIP card increment
|
|
||||||
|
|
||||||
- `commandService/index.js` exports the existing checked handler as `submitCommand`;
|
|
||||||
browser behavior remains in the same handler. Recording distinguishes model
|
|
||||||
requests from human requests without changing command payloads.
|
|
||||||
- `driving.js` owns per-rover CPU workers under the initiating live socket's
|
|
||||||
permissions. Explicit Stop, permission/connection loss, stale predictions, or
|
|
||||||
worker failure stop the session and send zero drive/motors and horn stop.
|
|
||||||
Human commands deliberately neither stop nor pause the model.
|
|
||||||
- `workers/infer.py` consumes live RTSP/TCP video plus sensor/command observations,
|
|
||||||
uses the training history format, validates weights/specification, resolves
|
|
||||||
competing command families, and latches discrete actions. It never writes to
|
|
||||||
the rover directly. Driving defaults: four CPU threads, 0.7 action threshold,
|
|
||||||
2000ms stale limit, with a 30-second worker startup allowance.
|
|
||||||
- `capabilities.js` matches recorded hardware control profiles against the target
|
|
||||||
rover. Recordings, resumable training state, and published manifests preserve
|
|
||||||
those profiles; models without a matching profile are not selectable for Start.
|
|
||||||
- `runtime.js` excludes autonomous sessions from human demonstrations, manages
|
|
||||||
controller shutdown, and supplies bounded recent activity and storage state.
|
|
||||||
- `socketGateway.js` emits one complete authorized card object every second to
|
|
||||||
subscribers, refreshes the permanent catalog every 15 seconds, and accepts
|
|
||||||
checked Start/Stop actions. No server-side favorites are stored.
|
|
||||||
- `VipRoverLearningCard.jsx` is at the top of the verified VIP tab, gated by the
|
|
||||||
service feature flag. It uses existing CardFrame, field/button/surface styles;
|
|
||||||
favorites use browser settings. Its expandable section includes storage,
|
|
||||||
recording sessions, dataset rejection reasons, training progress/resource
|
|
||||||
limits, model size/loss/control count, inference timing, last action, and events.
|
|
||||||
- Verification: focused ESLint on the new card and VipPanel passed; four existing
|
|
||||||
configuration tests passed. The live worker's actual `--help` entry point loaded
|
|
||||||
PyTorch/PyAV/NumPy successfully. Shared-handler diff reviewed with whitespace
|
|
||||||
ignored: extraction preserves the previous command handler logic.
|
|
||||||
- No real dataset, checkpoint, camera stream, or rover was available locally;
|
|
||||||
end-to-end inference/training and browser rendering remain unverified. No new
|
|
||||||
tests or background processes were added for verification, and no full build
|
|
||||||
or full test suite was run.
|
|
||||||
|
|
||||||
|
|
||||||
### Dock capture, live diagnostics, and publication correction
|
|
||||||
|
|
||||||
Authorized by IPI after the user reported idle dock recording, many premature
|
|
||||||
models, silent controllers, and training stopping after a few jobs.
|
|
||||||
|
|
||||||
- VipPanel now renders only `<VipRoverLearningCard />`; the card owns feature and
|
|
||||||
verification gating, rover selection context, and grid wrapper. The user's
|
|
||||||
pre-existing card edit was preserved.
|
|
||||||
- Human docked sessions maintain disposable packet-copy video segments and a
|
|
||||||
bounded five-second event buffer. Segments are keyframe-aligned (target two
|
|
||||||
seconds); old closed segments are pruned, with an extra edge segment retained
|
|
||||||
for decoding. Undocking promotes the buffer and sets trainingStartedAt to five
|
|
||||||
seconds earlier. After docking, recording closes after five seconds (250ms
|
|
||||||
polling granularity) and returns to buffering. Never-undocked buffers are
|
|
||||||
deleted on close/recovery and never offered to training. Disk retention still
|
|
||||||
applies. The reader handles chronological segments and filters the lead-in
|
|
||||||
boundary; no RTSP restart is needed to promote a dock buffer.
|
|
||||||
- The service still excludes autonomous sessions and still requires an eligible
|
|
||||||
human. Human input does not stop or pause autonomy.
|
|
||||||
- Expanded card diagnostics opt into complete volatile snapshots at 10Hz;
|
|
||||||
collapsed cards receive 1Hz. Predictions include all known command shapes,
|
|
||||||
scores, decoded controls, below-threshold/competing/latched reasons, and actual
|
|
||||||
submission results. "Predicting no new action" is separate from worker running.
|
|
||||||
- Removed roundsPerSession from configuration and scheduling. Completed usable
|
|
||||||
recordings remain eligible indefinitely within rolling retention. Jobs remain
|
|
||||||
finite, lower-priority, and separated by the configured interval. Waiting and
|
|
||||||
publication reasons are visible in the card.
|
|
||||||
- Default publication gates: 1000 optimizer steps, 30 distinct usable training
|
|
||||||
minutes, and 10 additional distinct minutes since the previous publication.
|
|
||||||
Usable minutes count enumerated aligned 100ms windows once per training session,
|
|
||||||
not repeated optimizer exposures; only a reservoir is used in each job.
|
|
||||||
- Entire sessions are deterministically partitioned 80/20 by session-ID hash.
|
|
||||||
Held-out sessions never supply gradient updates or new command vocabulary.
|
|
||||||
Evaluation requires at least 128 windows and 32 positive events, precision and
|
|
||||||
recall >=0.5 at the configured action threshold, and normalized numeric MSE
|
|
||||||
<=0.1 with numeric targets present. These are initial imitation gates, not proof
|
|
||||||
of autonomous competence. Evaluation uses the current job's held-out reservoir;
|
|
||||||
jobs with no usable held-out cohort cannot publish.
|
|
||||||
- Fresh `training/resume-v2.pt` prevents old training exposure contaminating the
|
|
||||||
new validation split. Existing published models and old resume.pt are preserved.
|
|
||||||
New checkpoints retain optimizer progress even when publication is blocked.
|
|
||||||
- Deployment config: migration 3 automatically removes the obsolete
|
|
||||||
`training.roundsPerSession` key from saved revisions before startup validation. Set `checkpointEverySteps: 1000`, `minimumDrivingMinutes: 30`,
|
|
||||||
and `newDrivingMinutesPerModel: 10`; saved values override changed defaults.
|
|
||||||
- Verification: four focused configuration tests passed, modified Node entry
|
|
||||||
points passed syntax checks, targeted VIP ESLint passed, and the production
|
|
||||||
training dependency entry point loaded the policy and segmented dataset reader.
|
|
||||||
No fixtures/test files, full builds, or background servers were created.
|
|
||||||
- Still unverified on this development machine: live RTSP segment timestamps and
|
|
||||||
dock transitions, actual optimizer/evaluation/publication runs on recordings,
|
|
||||||
browser appearance/live updates, and real autonomous behavior. Do not claim
|
|
||||||
these checks passed or that the existing event-based policy now drives well.
|
|
||||||
|
|
||||||
|
|
||||||
### Startup regression repair
|
|
||||||
|
|
||||||
The actual server crash reported CONFIG_VALIDATION_FAILED for the removed
|
|
||||||
roverLearning.training.roundsPerSession field. Added configuration database
|
|
||||||
migration 3 to remove only that obsolete key from all saved revisions before
|
|
||||||
startup validation. Other settings remain intact; normal default filling adds
|
|
||||||
new fields. No manual database/config edits are required. Existing focused
|
|
||||||
migration and normalization tests passed (2/2); production restart is unverified.
|
|
||||||
|
|
||||||
|
|
||||||
### Continuous wheel policy and copyable diagnostics (latest IPI)
|
|
||||||
|
|
||||||
Requirements: learn solely from ordinary use. Never require prescribed routes,
|
|
||||||
exercises, or demonstrations. Keep eight training threads until actual timings
|
|
||||||
justify changes. No artificial model-size increase. Manual controls still do not
|
|
||||||
stop or pause the controller. Keep card layout in the existing feature-card stack.
|
|
||||||
|
|
||||||
Implemented:
|
|
||||||
- Policy format 2 adds an independent two-value tanh wheel head (805,234 total
|
|
||||||
parameters), scaled to +/-500 mm/s. Accessory event/value heads remain separate;
|
|
||||||
drive command shapes remain causal history inputs but are masked as event targets.
|
|
||||||
- Dataset reconstructs accepted human wheel intent at frame time +100ms, using
|
|
||||||
dispatched wheel values, explicit zero stops, and the recorded hardware limit
|
|
||||||
when available. Inspection of roverd dispatch found persistent wheel commands,
|
|
||||||
not a short wheel-command expiry timer. Permission boundaries, server overrides,
|
|
||||||
failed/ambiguous drive commands and raw/mode/system changes invalidate wheel
|
|
||||||
intent until another accepted human drive. Uncertain windows are excluded.
|
|
||||||
Raw OI controls are consequently not reliable trainable events in this revision;
|
|
||||||
their effects require dedicated decoding before relaxing that exclusion.
|
|
||||||
- Four bounded training reservoirs preserve stopped, forward, reverse and turn
|
|
||||||
examples without requiring humans to behave differently. Held-out reservoirs
|
|
||||||
remain uniformly sampled and entire sessions retain deterministic partitioning.
|
|
||||||
- Loss reports separate wheel, accessory-event and accessory-value components.
|
|
||||||
Held-out wheel MAE is compared with stopped-wheel and previous-command baselines,
|
|
||||||
with separate error on actual wheel changes. Publication needs >=128 evaluation
|
|
||||||
windows, >=32 moving and >=32 changed windows, improvement over stopped and
|
|
||||||
changed-window persistence baselines, and overall error no worse than persistence.
|
|
||||||
Accessory precision/recall gates remain when events exist; absent accessory
|
|
||||||
events must not produce false positives. These remain experimental imitation
|
|
||||||
criteria, not proof of autonomous competence.
|
|
||||||
- Existing footage can be reused. New learning state is resume-v3.pt and
|
|
||||||
schedule-v3.json, preventing old-shape weights or stale rejection ledgers from
|
|
||||||
being reused. Existing published files remain untouched and visibly marked as
|
|
||||||
older format/incompatible. New compatible models are required to start driving.
|
|
||||||
- Live inference emits a wheel command every decision, independent of the accessory
|
|
||||||
threshold. Server clamps wheel values to hardware limits through the unchanged
|
|
||||||
shared authorization path. Start/Stop and manual-input behavior are preserved.
|
|
||||||
- Main card shows centered signed wheel bars, accessory score/status bars and a
|
|
||||||
session-local threshold slider (0.05..0.99). Only users with current rover control
|
|
||||||
can adjust it. Worker receives settings over its existing stdin channel; desired
|
|
||||||
and applied thresholds are separately observable. Active controllers get 10Hz
|
|
||||||
snapshots even when details are collapsed.
|
|
||||||
- Copy diagnostics exports compact reportVersion=1 JSON from the same complete
|
|
||||||
card object. It includes application version, service-source SHA256 fingerprint,
|
|
||||||
policy version, settings, host load/memory, dataset timing/availability counters,
|
|
||||||
separate losses, baseline evaluation, decode/train/evaluation/checkpoint timing,
|
|
||||||
CPU seconds and peak RSS, publication blockers, 20 job summaries and up to 30s
|
|
||||||
of controller history sampled at 2Hz (top eight action scores per entry).
|
|
||||||
- Reports use an explicit allowlist: no socket IDs, nicknames, rover IDs, filesystem
|
|
||||||
paths, raw video, credential-bearing URLs or raw command templates. Errors are
|
|
||||||
categorized rather than copying potentially sensitive arbitrary exception text.
|
|
||||||
Browser video latency and actual physical command execution are explicitly
|
|
||||||
unavailable. Diagnostics payloads are sent only while details are expanded;
|
|
||||||
active action visualization remains live in the main card.
|
|
||||||
- Clipboard failure exposes a selectable compact report instead of silently failing.
|
|
||||||
- No recorded dataset exists under this checkout's server/data/rover-learning.
|
|
||||||
Production worker dependency/entry-point checks succeeded; policy construction
|
|
||||||
reports 805,234 parameters. Targeted card ESLint and Node syntax checks passed.
|
|
||||||
Actual optimizer execution, held-out results, RTSP inference, clipboard/slider
|
|
||||||
behavior and browser appearance remain unverified here. No new tests, fake data,
|
|
||||||
or background development processes were created.
|
|
||||||
|
|
||||||
Next: use real diagnostic reports to assess timing, sample rejection, target
|
|
||||||
coverage and baseline performance. Do not infer competence from lower training
|
|
||||||
loss or increase architecture size without that evidence. If training was disabled
|
|
||||||
through the configuration UI, the operator must re-enable it to train this policy.
|
|
||||||
|
|
||||||
|
|
||||||
### Ongoing learner and administrator snapshots
|
|
||||||
|
|
||||||
Authorized by the latest IPI. Training continues from the same resume-v3.pt;
|
|
||||||
no new training reset or configuration migration is required.
|
|
||||||
|
|
||||||
- Each completed training job exports immutable weights/metadata beneath
|
|
||||||
training/checkpoints/<uuid>. A serialized Node catalog atomically updates
|
|
||||||
index.json with candidate, latest, and previous pointers after worker exit.
|
|
||||||
- Existing data/step/evaluation gates govern Latest promotion. Previous retains
|
|
||||||
the former Latest. Candidate tracks the latest completed learner even when
|
|
||||||
those gates fail. No automatic permanent named-model creation remains.
|
|
||||||
- The model picker exposes Latest and Previous alongside existing permanent
|
|
||||||
snapshots. Running sessions acquire a checkpoint lease and keep their original
|
|
||||||
weights; promotion does not reload them. Retention removes superseded automatic
|
|
||||||
checkpoints, with leased ones removed after their running session closes.
|
|
||||||
- Admin-only Save learner snapshot copies the candidate atomically to a new
|
|
||||||
permanent two-word/UUID model directory, carrying its evaluation and source
|
|
||||||
checkpoint ID. Candidates without a passing evaluation are labeled experimental.
|
|
||||||
Non-admin requests are rejected server-side. Normal rover-control permissions
|
|
||||||
still apply when starting any model. Existing models/favorites remain intact.
|
|
||||||
- Source fingerprints and controller diagnostics include the pinned checkpoint ID.
|
|
||||||
Configuration field names are unchanged; descriptions now refer to Latest
|
|
||||||
promotion rather than permanent publication. Permanent snapshots are never pruned.
|
|
||||||
- Focused Node syntax and Python worker entry-point checks passed. Targeted card
|
|
||||||
lint is run for the UI change. End-to-end training/promotion, snapshot copying,
|
|
||||||
active-session pinning and browser behavior remain unverified without live data.
|
|
||||||
@@ -68,7 +68,6 @@ 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.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
import{r as g,R as a}from"./index-5EWtcenj.js";const d="64px",u={},k=({src:s,light:t,oEmbedUrl:o,onClickPreview:n,playIcon:b,previewTabIndex:_,previewAriaLabel:p})=>{const[f,r]=g.useState(null);g.useEffect(()=>{!s||!t||!o||x({src:s,light:t,oEmbedUrl:o})},[s,t,o]);const x=async({src:e,light:i,oEmbedUrl:E})=>{if(a.isValidElement(i))return;if(typeof i=="string"){r(i);return}if(u[e]){r(u[e]);return}r(null);const m=await(await fetch(E.replace("{url}",e))).json();if(m.thumbnail_url){const h=m.thumbnail_url.replace("height=100","height=480").replace("-d_295x166","-d_640");r(h),u[e]=h}},I=e=>{(e.key==="Enter"||e.key===" ")&&n?.(e)},v=e=>{n?.(e)},l=a.isValidElement(t),y={display:"flex",alignItems:"center",justifyContent:"center"},c={preview:{width:"100%",height:"100%",backgroundImage:f&&!l?`url(${f})`:void 0,backgroundSize:"cover",backgroundPosition:"center",cursor:"pointer",...y},shadow:{background:"radial-gradient(rgb(0, 0, 0, 0.3), rgba(0, 0, 0, 0) 60%)",borderRadius:d,width:d,height:d,position:l?"absolute":void 0,...y},playIcon:{borderStyle:"solid",borderWidth:"16px 0 16px 26px",borderColor:"transparent transparent transparent white",marginLeft:"7px"}},w=a.createElement("div",{style:c.shadow,className:"react-player__shadow"},a.createElement("div",{style:c.playIcon,className:"react-player__play-icon"}));return a.createElement("div",{style:c.preview,className:"react-player__preview",tabIndex:_,onClick:v,onKeyDown:I,...p?{"aria-label":p}:{}},l?t:null,b||w)};var C=k;export{C as default};
|
||||||
|
//# sourceMappingURL=Preview-CKTJqKe-.js.map
|
||||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1,2 @@
|
|||||||
|
import{e as F,r as s,j as e,S as P,C as c,L as q}from"./index-5EWtcenj.js";import{o as $,p as D,q as I}from"./api-B8GGCEeo.js";function R(){const l=F(),[a,m]=s.useState(null),[n,b]=s.useState(""),[d,C]=s.useState(""),[p,N]=s.useState(""),[r,S]=s.useState(""),[f,v]=s.useState(""),[o,w]=s.useState(null),[x,h]=s.useState(!1),[g,i]=s.useState("");s.useEffect(()=>{$(l).then(t=>m(t.required)).catch(t=>i(t.message))},[l]);async function j(t){h(!0),i("");try{await t(),m(!1),i("Setup completed. You can now open the administration application and log in.")}catch(u){const E=Array.isArray(u.validationErrors)?` ${u.validationErrors.map(y=>`${y.path}: ${y.message}`).join("; ")}`:"";i(`${u.message}${E}`)}finally{h(!1)}}function k(t){if(t.preventDefault(),r!==f){i("Passwords do not match.");return}j(()=>D(l,{setupCode:n,username:d,discordId:p,password:r}))}function A(t){t.preventDefault(),o&&j(async()=>I(l,{setupCode:n,fileName:o.name,yaml:await o.text()}))}return e.jsxs("div",{className:"min-h-screen bg-neutral-950 p-1 text-slate-100",children:[e.jsx(P,{}),e.jsxs("main",{className:"mx-auto flex min-h-screen w-full max-w-3xl flex-col justify-center gap-0.5",children:[e.jsxs(c,{title:"MultiRover setup",meta:a===null?"checking":a?"required":"complete",bodyClassName:"space-y-0.5 p-1 text-sm",children:[a?e.jsx("p",{children:"Enter the one-time code from setup-code.txt in the server data folder, then create the first lockdown administrator or import an existing configuration."}):null,a===!1?e.jsx(q,{className:"button-dark inline-block",to:"/admin",children:"Open administration"}):null,g?e.jsx("p",{className:"surface p-1 text-sm text-slate-200",children:g}):null]}),a?e.jsxs(e.Fragment,{children:[e.jsxs(c,{title:"Setup authorization",bodyClassName:"p-1",children:[e.jsx("label",{className:"block text-xs font-semibold text-slate-200",children:"One-time setup code"}),e.jsx("input",{className:"field-input mt-0.5 w-full font-mono",value:n,onChange:t=>b(t.target.value)})]}),e.jsx(c,{title:"Create first administrator",bodyClassName:"p-1",children:e.jsxs("form",{className:"grid gap-0.5 md:grid-cols-2",onSubmit:k,children:[e.jsx("input",{className:"field-input",placeholder:"Username",value:d,onChange:t=>C(t.target.value)}),e.jsx("input",{className:"field-input",placeholder:"Discord id (optional)",value:p,onChange:t=>N(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Password",value:r,onChange:t=>S(t.target.value)}),e.jsx("input",{className:"field-input",type:"password",placeholder:"Confirm password",value:f,onChange:t=>v(t.target.value)}),e.jsx("button",{className:"button-dark md:col-span-2",type:"submit",disabled:x||!n||!d||!r,children:"Create lockdown administrator"})]})}),e.jsxs(c,{title:"Import configuration file",bodyClassName:"space-y-0.5 p-1 text-sm",children:[e.jsx("p",{className:"text-xs text-slate-400",children:"Choose an existing YAML configuration explicitly. The server validates and imports it once, and its secrets are never displayed back in the browser."}),e.jsxs("form",{className:"flex flex-col gap-0.5 md:flex-row",onSubmit:A,children:[e.jsx("input",{className:"field-input flex-1",type:"file",accept:".yaml,.yml,text/yaml",onChange:t=>w(t.target.files?.[0]||null)}),e.jsx("button",{className:"button-dark",type:"submit",disabled:x||!n||!o,children:"Import selected YAML"})]})]})]}):null]})]})}export{R as default};
|
||||||
|
//# sourceMappingURL=SetupApp-DN7XBkOe.js.map
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+22
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+20
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+23
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import{R as W}from"./index-5EWtcenj.js";const $=["abort","canplay","canplaythrough","durationchange","emptied","encrypted","ended","error","loadeddata","loadedmetadata","loadstart","pause","play","playing","progress","ratechange","seeked","seeking","stalled","suspend","timeupdate","volumechange","waiting","waitingforkey","resize","enterpictureinpicture","leavepictureinpicture","webkitbeginfullscreen","webkitendfullscreen","webkitpresentationmodechanged"],A=globalThis.document?.createElement("template");A&&(A.innerHTML=`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
line-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
video,
|
||||||
|
audio {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
min-width: 100%;
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot>
|
||||||
|
`);const N=(i,{tag:t,is:s})=>{const d=globalThis.document?.createElement(t,{is:s}),f=d?S(d):[];return class g extends i{static Events=$;static template=A;static skipAttributes=[];static#t;static get observedAttributes(){return g.#l(),[...d?.constructor?.observedAttributes??[],"autopictureinpicture","disablepictureinpicture","disableremoteplayback","autoplay","controls","controlslist","crossorigin","loop","muted","playsinline","poster","preload","src"]}static#l(){if(this.#t)return;this.#t=!0;const e=new Set(this.observedAttributes);e.delete("muted");for(let o of f){if(o in this.prototype)continue;if(typeof d[o]=="function")this.prototype[o]=function(...r){this.#e();const a=()=>this.call?this.call(o,...r):this.nativeEl[o].apply(this.nativeEl,r);return this.loadComplete&&!this.isLoaded?this.loadComplete.then(a):a()};else{let r={get(){this.#e();let a=o.toLowerCase();if(e.has(a)){const p=this.getAttribute(a);return p===null?!1:p===""?!0:p}return this.get?.(o)??this.nativeEl?.[o]??this.#s[o]}};o!==o.toUpperCase()&&(r.set=async function(a){this.#e();let p=o.toLowerCase();if(e.has(p)){a===!0||a===!1||a==null?this.toggleAttribute(p,!!a):this.setAttribute(p,a);return}if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.set){this.set(o,a);return}this.nativeEl[o]=a}),Object.defineProperty(this.prototype,o,r)}}}#o;#n;#r=!1;#i=!1;#a;#s;constructor(){super(),this.shadowRoot||(this.attachShadow({mode:"open"}),this.shadowRoot.append(this.constructor.template.content.cloneNode(!0))),this.load!==g.prototype.load&&(this.loadComplete=new O)}get loadComplete(){return this.#n}set loadComplete(e){this.#i=!1,this.#n=e,e?.then(()=>{this.#i=!0})}get isLoaded(){return this.#i}get nativeEl(){return this.#a??this.shadowRoot.querySelector(t)??this.querySelector(t)}set nativeEl(e){this.#a=e}get defaultMuted(){return this.hasAttribute("muted")}set defaultMuted(e){this.toggleAttribute("muted",!!e)}get src(){return this.getAttribute("src")}set src(e){this.setAttribute("src",`${e}`)}get preload(){return this.getAttribute("preload")??this.nativeEl?.preload}set preload(e){this.setAttribute("preload",`${e}`)}async#e(){if(this.#o)return;this.#o=!0,this.#u(),this.#c();for(let u of f)this.#d(u);const e=new Map,o=this.shadowRoot.querySelector("slot:not([name])");o?.addEventListener("slotchange",()=>{const u=new Map(e);o.assignedElements().filter(r=>["track","source"].includes(r.localName)).forEach(async r=>{u.delete(r);let a=e.get(r);a||(a=r.cloneNode(),e.set(r,a)),this.loadComplete&&!this.isLoaded&&await this.loadComplete,this.nativeEl.append?.(a)}),u.forEach(r=>r.remove())});for(let u of this.constructor.Events)this.shadowRoot.addEventListener?.(u,r=>{r.target===this.nativeEl&&this.dispatchEvent(new CustomEvent(r.type,{detail:r.detail}))},!0)}#d(e){if(Object.prototype.hasOwnProperty.call(this,e)){const o=this[e];delete this[e],this[e]=o}}#u(){const e=document.createElement(t,{is:s});e.muted=this.hasAttribute("muted");for(let{name:o,value:u}of this.attributes)e.setAttribute(o,u);this.#s={};for(let o of S(e))this.#s[o]=e[o];e.removeAttribute("src"),e.load()}async#c(){if(this.loadComplete&&!this.isLoaded&&await this.loadComplete,!this.nativeEl){const e=document.createElement(t,{is:s});e.part=t,this.shadowRoot.append(e)}this.nativeEl.muted=this.hasAttribute("muted")}attributeChangedCallback(e,o,u){this.#e(),e==="src"&&this.load!==g.prototype.load&&this.#h(),this.#p(e,o,u)}async#h(){this.#r&&(this.loadComplete=new O),this.#r=!0,await Promise.resolve(),await this.load(),this.loadComplete?.resolve(),await this.loadComplete}async#p(e,o,u){this.loadComplete&&!this.isLoaded&&await this.loadComplete,!["id","class",...this.constructor.skipAttributes].includes(e)&&(u===null?this.nativeEl.removeAttribute?.(e):this.nativeEl.setAttribute?.(e,u))}connectedCallback(){this.#e()}}};function S(i){let t=[];for(let s=Object.getPrototypeOf(i);s&&s!==HTMLElement.prototype;s=Object.getPrototypeOf(s))t.push(...Object.getOwnPropertyNames(s));return t}class O extends Promise{constructor(t=()=>{}){let s,d;super((f,c)=>{t(f,c),s=f,d=c}),this.resolve=s,this.reject=d}}const B=globalThis.document?N(HTMLElement,{tag:"video"}):class{};globalThis.document&&N(HTMLElement,{tag:"audio"});var H,R;const L=(H=globalThis.document)==null?void 0:H.createElement("template");L&&(L.innerHTML=`
|
||||||
|
<div class="wistia_embed"></div>
|
||||||
|
`);const P=(R=globalThis.document)==null?void 0:R.createElement("template");P&&(P.innerHTML=`
|
||||||
|
<style>
|
||||||
|
:host {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 300px;
|
||||||
|
min-height: 150px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
::slotted(.wistia_embed) {
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<slot></slot>
|
||||||
|
`);function D(i,t){if(Object.hasOwn(i,t)){const s=i[t];delete i[t],i[t]=s}}class q extends B{static template=P;static skipAttributes=["src"];#t=null;constructor(){super(),D(this,"config")}get config(){return this.#t}set config(t){this.#t=t}get nativeEl(){var t;return((t=this.api)==null?void 0:t.elem())??this.querySelector("video")}async load(){var t;if((t=this.querySelector(".wistia_embed"))==null||t.remove(),!this.src)return;await new Promise(o=>setTimeout(o,50));const s=/(?:wistia\.com|wi\.st)\/(?:medias|embed)\/(.*)$/i,d=this.src.match(s)[1],f={autoPlay:this.autoplay,silentAutoPlay:this.autoplay?"allow":void 0,preload:this.preload??"metadata",playsinline:this.playsInline,endVideoBehavior:this.loop?"loop":void 0,chromeless:!this.controls,playButton:this.controls,muted:this.defaultMuted,...this.#t??{}};this.append(L.content.cloneNode(!0));const c=this.querySelector(".wistia_embed");c.id||(c.id=z(d)),c.classList.add(`wistia_async_${d}`),await F("https://fast.wistia.com/assets/external/E-v1.js","Wistia"),this.api=await new Promise(o=>{globalThis._wq.push({id:c.id,onReady:o,options:f})})}async attributeChangedCallback(t,s,d){if(t==="controls"){switch(await this.loadComplete,t){case"controls":this.api.bigPlayButtonEnabled(this.controls),this.controls?this.api.releaseChromeless():this.api.requestChromeless();break}return}super.attributeChangedCallback(t,s,d)}get duration(){var t;return(t=this.api)==null?void 0:t.duration()}async play(){return await this.loadComplete,this.api.play(),new Promise(t=>this.addEventListener("playing",t))}}const T={};async function F(i,t){if(T[i])return T[i];if(self[t])return self[t];const s=new Promise((d,f)=>{const c=document.createElement("script");c.defer=!0,c.src=i,c.onload=()=>d(self[t]),c.onerror=f,document.head.append(c)});return T[i]=s,s}let U=0;function z(i){const t=++U;return`${i}${t}`}globalThis.customElements&&!globalThis.customElements.get("wistia-video")&&globalThis.customElements.define("wistia-video",q);var V=q,G=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),J={className:"class",htmlFor:"for"};function I(i){return i.toLowerCase()}function j(i){if(typeof i=="boolean")return i?"":void 0;if(typeof i!="function"&&!(typeof i=="object"&&i!==null))return i}function K({react:i,tagName:t,elementClass:s,events:d,displayName:f,defaultProps:c,toAttributeName:e=I,toAttributeValue:o=j}){const u=Number.parseInt(i.version)>=19,r=i.forwardRef((a,p)=>{var M,k;const m=i.useRef(null),E=i.useRef(new Map),C={},_={},v={},w={};for(const[n,l]of Object.entries(a)){if(G.has(n)){v[n]=l;continue}const h=e(J[n]??n);if(s.prototype&&n in s.prototype&&!(n in(((M=globalThis.HTMLElement)==null?void 0:M.prototype)??{}))&&!((k=s.observedAttributes)!=null&&k.some(b=>b===h))){w[n]=l;continue}if(n.startsWith("on")){C[n]=l;continue}const y=o(l);if(h&&y!=null&&(_[h]=String(y),u||(v[h]=y)),h&&u){const b=j(l);y!==b?v[h]=y:v[h]=l}}if(typeof window<"u"){for(const n in C){const l=C[n],h=n.endsWith("Capture"),y=(d?.[n]??n.slice(2).toLowerCase()).slice(0,h?-7:void 0);i.useLayoutEffect(()=>{const b=m?.current;if(!(!b||typeof l!="function"))return b.addEventListener(y,l,h),()=>{b.removeEventListener(y,l,h)}},[m?.current,l])}i.useLayoutEffect(()=>{if(m.current===null)return;const n=new Map;for(const l in w)x(m.current,l,w[l]),E.current.delete(l),n.set(l,w[l]);for(const[l,h]of E.current)x(m.current,l,void 0);E.current=n})}if(typeof window>"u"&&s?.getTemplateHTML&&s?.shadowRootOptions){const{mode:n,delegatesFocus:l}=s.shadowRootOptions,h=i.createElement("template",{shadowrootmode:n,shadowrootdelegatesfocus:l,dangerouslySetInnerHTML:{__html:s.getTemplateHTML(_,a)},key:"ce-la-react-ssr-template-shadow-root"});v.children=[h,v.children]}return i.createElement(t,{...c,...v,ref:i.useCallback(n=>{m.current=n,typeof p=="function"?p(n):p!==null&&(p.current=n)},[p])},v.children)});return r.displayName=f??s.name,r}function x(i,t,s){var d;i[t]=s,s==null&&t in(((d=globalThis.HTMLElement)==null?void 0:d.prototype)??{})&&i.removeAttribute(t)}var X=K({react:W,tagName:"wistia-video",elementClass:V,toAttributeName(i){return i==="muted"?"":i==="defaultMuted"?"muted":I(i)}});export{X as default};
|
||||||
|
//# sourceMappingURL=react-DxdYv-AY.js.map
|
||||||
File diff suppressed because one or more lines are too long
+18
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+7
@@ -0,0 +1,7 @@
|
|||||||
|
import{_ as N,R as A}from"./index-5EWtcenj.js";import{M as D,C as S}from"./mixin-BY5xW4uI.js";class P extends D(S){static shadowRootOptions={...S.shadowRootOptions};static getTemplateHTML=i=>{const{src:n,...o}=i;return S.getTemplateHTML(o)};#t;attributeChangedCallback(i,n,o){i!=="src"&&super.attributeChangedCallback(i,n,o),i==="src"&&n!=o&&this.load()}async _initThumbnails(i){const n=async(d,m)=>{const v=[],g=i.timescale||1,h=i.startNumber||1,E=i.presentationTimeOffset?i.presentationTimeOffset/g:0,_=i.segmentDuration;for(let l=0;l<d;l++){const T=C({thIndex:l,thduration:m,ttiles:d,tduration:_,startNumber:h,pto:E}),w=T+m,M=new Promise((f,y)=>{this.api.provideThumbnail(T,({url:a,width:s,height:c,x:p,y:b})=>{try{const R=new VTTCue(T,w,`${a}#xywh=${p},${b},${s},${c}`);f(R)}catch(R){y(R)}})});v.push(M)}return await Promise.all(v).catch(l=>console.error("Error processing thumbnails",l))},{totalThumbnails:o,thumbnailDuration:e}=$(i),u=await n(o,e);let r=this.nativeEl.querySelector('track[label="thumbnails"]');if(!r){r=O(),this.nativeEl.appendChild(r);const d=x(u);r.src=d,r.dispatchEvent(new Event("change"))}}async load(){if(this.#t){this.api.attachSource(this.src);return}this.#t=!0;const i=await N(()=>import("./dash.all.min-C9gxBrL1.js"),[]);this.api=i.MediaPlayer().create(),this.api.initialize(this.nativeEl,this.src,this.autoplay),this.api.on(i.MediaPlayer.events.STREAM_INITIALIZED,()=>{const n=this.api.getRepresentationsByType("video");let o=this.videoTracks.getTrackById("main");o||(o=this.addVideoTrack("main"),o.id="main",o.selected=!0),n.forEach(e=>{const u=e.bandwidth??e.bitrate??(Number.isFinite(e.bitrateInKbit)?e.bitrateInKbit*1e3:void 0),r=o.addRendition(e.id,e.width,e.height,e.mimeType??e.codec,u);r.id=e.id}),this.videoRenditions.addEventListener("change",()=>{const e=this.videoRenditions[this.videoRenditions.selectedIndex];e?.id?(this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!1}}}}),this.api.setRepresentationForTypeById("video",e.id,!0)):this.api.updateSettings({streaming:{abr:{autoSwitchBitrate:{video:!0}}}})}),this.api.isDynamic()||this.api.getRepresentationsByType("image").forEach(async(u,r)=>{r>0||this._initThumbnails(u)})})}}function $(t){var i,n;const o=t.essentialProperties[0],[e,u]=o.value.split("x").map(Number),r=e*u,d=((n=(i=t.adaptation)==null?void 0:i.period)==null?void 0:n.duration)||null,m=t.segmentDuration,v=t.timescale||1,h=m/v/r;return{totalThumbnails:d!=null?Math.ceil(d/h):Math.ceil(m/h),thumbnailDuration:h}}function C({thIndex:t,tduration:i,thduration:n,ttiles:o,startNumber:e,pto:u}){const r=Math.floor(t/o)+e,d=t%o+1,m=(r-1)*i-u,v=(d-1)*n;return m+v}function O(){const t=document.createElement("track");return t.kind="metadata",t.label="thumbnails",t.srclang="en",t.mode="hidden",t.default=!0,t}function x(t){let i=`WEBVTT
|
||||||
|
|
||||||
|
`;for(const e of t)i+=`${o(e.startTime)} --> ${o(e.endTime)}
|
||||||
|
`,i+=`${e.text}
|
||||||
|
|
||||||
|
`;const n=new Blob([i],{type:"text/vtt"});return URL.createObjectURL(n);function o(e){const u=String(Math.floor(e/3600)).padStart(2,"0"),r=String(Math.floor(e%3600/60)).padStart(2,"0"),d=(e%60).toFixed(3).padStart(6,"0");return`${u}:${r}:${d}`}}globalThis.customElements&&!globalThis.customElements.get("dash-video")&&globalThis.customElements.define("dash-video",P);var B=P,H=new Set(["style","children","ref","key","suppressContentEditableWarning","suppressHydrationWarning","dangerouslySetInnerHTML"]),F={className:"class",htmlFor:"for"};function I(t){return t.toLowerCase()}function k(t){if(typeof t=="boolean")return t?"":void 0;if(typeof t!="function"&&!(typeof t=="object"&&t!==null))return t}function W({react:t,tagName:i,elementClass:n,events:o,displayName:e,defaultProps:u,toAttributeName:r=I,toAttributeValue:d=k}){const m=Number.parseInt(t.version)>=19,v=t.forwardRef((g,h)=>{var E,_;const l=t.useRef(null),T=t.useRef(new Map),w={},M={},f={},y={};for(const[a,s]of Object.entries(g)){if(H.has(a)){f[a]=s;continue}const c=r(F[a]??a);if(n.prototype&&a in n.prototype&&!(a in(((E=globalThis.HTMLElement)==null?void 0:E.prototype)??{}))&&!((_=n.observedAttributes)!=null&&_.some(b=>b===c))){y[a]=s;continue}if(a.startsWith("on")){w[a]=s;continue}const p=d(s);if(c&&p!=null&&(M[c]=String(p),m||(f[c]=p)),c&&m){const b=k(s);p!==b?f[c]=p:f[c]=s}}if(typeof window<"u"){for(const a in w){const s=w[a],c=a.endsWith("Capture"),p=(o?.[a]??a.slice(2).toLowerCase()).slice(0,c?-7:void 0);t.useLayoutEffect(()=>{const b=l?.current;if(!(!b||typeof s!="function"))return b.addEventListener(p,s,c),()=>{b.removeEventListener(p,s,c)}},[l?.current,s])}t.useLayoutEffect(()=>{if(l.current===null)return;const a=new Map;for(const s in y)L(l.current,s,y[s]),T.current.delete(s),a.set(s,y[s]);for(const[s,c]of T.current)L(l.current,s,void 0);T.current=a})}if(typeof window>"u"&&n?.getTemplateHTML&&n?.shadowRootOptions){const{mode:a,delegatesFocus:s}=n.shadowRootOptions,c=t.createElement("template",{shadowrootmode:a,shadowrootdelegatesfocus:s,dangerouslySetInnerHTML:{__html:n.getTemplateHTML(M,g)},key:"ce-la-react-ssr-template-shadow-root"});f.children=[c,f.children]}return t.createElement(i,{...u,...f,ref:t.useCallback(a=>{l.current=a,typeof h=="function"?h(a):h!==null&&(h.current=a)},[h])},f.children)});return v.displayName=e??n.name,v}function L(t,i,n){var o;t[i]=n,n==null&&i in(((o=globalThis.HTMLElement)==null?void 0:o.prototype)??{})&&t.removeAttribute(i)}var V=W({react:A,tagName:"dash-video",elementClass:B,toAttributeName(t){return t==="muted"?"":t==="defaultMuted"?"muted":I(t)}});export{V as default};
|
||||||
|
//# sourceMappingURL=react-gdx3v4jU.js.map
|
||||||
File diff suppressed because one or more lines are too long
@@ -184,7 +184,6 @@ 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'] },
|
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ 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
|
||||||
@@ -56,7 +55,6 @@ const definitions = [
|
|||||||
sessionConfiguration.socials,
|
sessionConfiguration.socials,
|
||||||
sessionConfiguration.driverAd,
|
sessionConfiguration.driverAd,
|
||||||
fleetReports,
|
fleetReports,
|
||||||
roverLearning,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const defaultConfig = Object.fromEntries(
|
const defaultConfig = Object.fromEntries(
|
||||||
|
|||||||
@@ -72,18 +72,6 @@ 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();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function applySchemaMigrations(db) {
|
function applySchemaMigrations(db) {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ const logger = require('../../globals/logger').child('assignment');
|
|||||||
const { MODES, getMode, modeEvents } = require('../modeManager');
|
const { MODES, getMode, modeEvents } = require('../modeManager');
|
||||||
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
const { roleEvents, getRole, isAdmin, isLockdownAdmin } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
const { compareRoversForAssignment } = require('./roverRanking');
|
const { compareRoversForAssignment } = require('./roverRanking');
|
||||||
|
|
||||||
const socketRefs = new Map(); // socketId -> socket
|
const socketRefs = new Map(); // socketId -> socket
|
||||||
@@ -131,7 +132,7 @@ roverManager.managerEvents.on('switch', ({ socketId, roverId }) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function assignSocket(socket, options = {}) {
|
function assignSocket(socket, options = {}) {
|
||||||
if (!socket || isAdmin(socket) || getRole(socket) !== 'user') {
|
if (!socket || getOperatingMode(socket) !== 'rover' || isAdmin(socket) || getRole(socket) !== 'user') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// avoid double assignment
|
// avoid double assignment
|
||||||
@@ -311,13 +312,22 @@ function describeAssignment(socketId) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
assignmentEvents,
|
assignmentEvents,
|
||||||
|
resumeAssignment: assignSocket,
|
||||||
|
releaseForOperatingMode: (socket) => {
|
||||||
|
// Include direct admin control as well as automatic assignment ownership.
|
||||||
|
const roverIds = new Set(roverManager.getRoversForSocket(socket.id));
|
||||||
|
const assigned = assignments.get(socket.id);
|
||||||
|
if (assigned) roverIds.add(assigned);
|
||||||
|
waiting.delete(socket.id);
|
||||||
|
roverIds.forEach((roverId) => forceRelease(roverId, socket.id));
|
||||||
|
},
|
||||||
describeAssignment,
|
describeAssignment,
|
||||||
forceRelease,
|
forceRelease,
|
||||||
forceReleaseWithNotice,
|
forceReleaseWithNotice,
|
||||||
rerollAssignments,
|
rerollAssignments,
|
||||||
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
getAssignedRover: (socketId) => assignments.get(socketId) || null,
|
||||||
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
moveAssignment: (socket, roverId, { releasePrevious = true } = {}) => {
|
||||||
if (!socket || !roverId) return;
|
if (!socket || !roverId || getOperatingMode(socket) !== 'rover') return;
|
||||||
const previous = assignments.get(socket.id);
|
const previous = assignments.get(socket.id);
|
||||||
if (previous && previous !== roverId && releasePrevious) {
|
if (previous && previous !== roverId && releasePrevious) {
|
||||||
roverManager.releaseControl(previous, socket);
|
roverManager.releaseControl(previous, socket);
|
||||||
|
|||||||
@@ -2,20 +2,14 @@
|
|||||||
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
// Purpose: Builds normalized chat message/typing payloads and rover context snapshots.
|
||||||
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
// Scope: Encapsulates chat DTO construction and rover/driver metadata extraction.
|
||||||
const { v4: uuidv4 } = require('uuid');
|
const { v4: uuidv4 } = require('uuid');
|
||||||
const io = require('../../globals/io');
|
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
const { describeAssignment } = require('../assignmentService');
|
const { describeAssignment } = require('../assignmentService');
|
||||||
const { getNickname } = require('../nicknameService');
|
const { getNickname } = require('../nicknameService');
|
||||||
const ptzCameraService = require('../ptzCameraService');
|
const ptzCameraService = require('../ptzCameraService');
|
||||||
|
|
||||||
function resolvePtzChatTarget(socketId) {
|
|
||||||
return ptzCameraService.getChatTargetForSocket(socketId) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function resolveRoverId(socketId) {
|
function resolveRoverId(socketId) {
|
||||||
const ptzTarget = resolvePtzChatTarget(socketId);
|
|
||||||
if (ptzTarget?.roverId) return ptzTarget.roverId;
|
|
||||||
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
const primary = roverManager.getPrimaryRoverForSocket(socketId);
|
||||||
if (primary) return primary;
|
if (primary) return primary;
|
||||||
const assignment = describeAssignment(socketId);
|
const assignment = describeAssignment(socketId);
|
||||||
@@ -24,54 +18,22 @@ function resolveRoverId(socketId) {
|
|||||||
|
|
||||||
function resolveRoverColor(roverId) {
|
function resolveRoverColor(roverId) {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
||||||
return ptzCameraService.getPublicState()?.color || null;
|
|
||||||
}
|
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
return record?.meta?.color || null;
|
return record?.meta?.color || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveRoverName(roverId) {
|
function resolveRoverName(roverId) {
|
||||||
if (!roverId) return null;
|
if (!roverId) return null;
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
|
||||||
return ptzCameraService.getPublicState()?.name || null;
|
|
||||||
}
|
|
||||||
const record = roverManager.rovers.get(String(roverId));
|
const record = roverManager.rovers.get(String(roverId));
|
||||||
return record?.meta?.name || null;
|
return record?.meta?.name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPtzChatTargetId(roverId) {
|
|
||||||
/*
|
|
||||||
PTZ is intentionally treated as a virtual rover for chat identity only. It
|
|
||||||
does not live in roverManager.rovers because movement, video authorization,
|
|
||||||
and queue ownership are PTZ-service concerns, but chat needs one stable
|
|
||||||
"rover-like" id so the existing web UI, Discord bridge, and AI transcript
|
|
||||||
code can all render the same badge without learning PTZ internals.
|
|
||||||
*/
|
|
||||||
return Boolean(roverId) && String(roverId) === ptzCameraService.PTZ_CAMERA_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPublicChatTargetId(roverId, socket = null) {
|
function isPublicChatTargetId(roverId, socket = null) {
|
||||||
if (!roverId) return false;
|
return Boolean(roverId) && roverManager.canReplayRoverId(roverId, socket) === true;
|
||||||
/*
|
|
||||||
Normal rovers remain governed by the existing replay visibility rule, which
|
|
||||||
is also the rule chat historically used to avoid exposing closed private
|
|
||||||
rover activity. PTZ gets an explicit allow-list entry here because it is a
|
|
||||||
public chat target that deliberately pretends to be a rover, even though it
|
|
||||||
is not a roverManager record.
|
|
||||||
*/
|
|
||||||
if (isPtzChatTargetId(roverId)) return true;
|
|
||||||
return roverManager.canReplayRoverId(roverId, socket) === true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPrivateClosedRoverId(roverId) {
|
function isPrivateClosedRoverId(roverId) {
|
||||||
if (!roverId) return false;
|
return Boolean(roverId) && !isPublicChatTargetId(roverId);
|
||||||
/*
|
|
||||||
PTZ uses the existing rover badge fields so chat rows can reuse RoverLabel,
|
|
||||||
but it is not a private rover. Let PTZ-badged messages broadcast normally
|
|
||||||
instead of falling into the closed-private rover path for unknown ids.
|
|
||||||
*/
|
|
||||||
return !isPublicChatTargetId(roverId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeProfileImageUrl(value) {
|
function normalizeProfileImageUrl(value) {
|
||||||
@@ -170,6 +132,8 @@ function buildMessage(socket, text, meta = {}) {
|
|||||||
socketId: socket?.id || null,
|
socketId: socket?.id || null,
|
||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
|
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||||
|
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||||
roverId,
|
roverId,
|
||||||
roverName,
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
@@ -215,6 +179,8 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
socketId,
|
socketId,
|
||||||
nickname: meta.nickname || getNickname(socket) || null,
|
nickname: meta.nickname || getNickname(socket) || null,
|
||||||
role: meta.role || getRole(socket),
|
role: meta.role || getRole(socket),
|
||||||
|
operatingMode: socket ? getOperatingMode(socket) : null,
|
||||||
|
operatingModeDisplay: socket ? ptzCameraService.getOperatingModeDisplay(socket.id) : null,
|
||||||
roverId,
|
roverId,
|
||||||
roverName,
|
roverName,
|
||||||
roverColor,
|
roverColor,
|
||||||
@@ -231,7 +197,6 @@ function buildTypingPayload(socket, meta = {}) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
resolveRoverId,
|
resolveRoverId,
|
||||||
isPtzChatTargetId,
|
|
||||||
isPublicChatTargetId,
|
isPublicChatTargetId,
|
||||||
isPrivateClosedRoverId,
|
isPrivateClosedRoverId,
|
||||||
buildRoverCtxSnapshot,
|
buildRoverCtxSnapshot,
|
||||||
|
|||||||
@@ -19,13 +19,6 @@ const { getLastAccessNoticeAt, setLastAccessNoticeAt } = require('./state');
|
|||||||
|
|
||||||
function playTypingNote(roverId, note, socketId) {
|
function playTypingNote(roverId, note, socketId) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
/*
|
|
||||||
PTZ borrows the roverId field for chat badges, but it has no rover command
|
|
||||||
channel. Skipping the song command here keeps PTZ chat from producing noisy
|
|
||||||
"unknown rover" command attempts while still allowing the message itself to
|
|
||||||
behave like rover chat everywhere else.
|
|
||||||
*/
|
|
||||||
if (String(roverId) === ptzCameraService.PTZ_CAMERA_ID) return;
|
|
||||||
try {
|
try {
|
||||||
issueCommand(roverId, {
|
issueCommand(roverId, {
|
||||||
type: 'song',
|
type: 'song',
|
||||||
@@ -90,8 +83,8 @@ function maybeSendAccessNotice(message, sendSystemMessage) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function maybeSpeak(socket, message, ttsOptions) {
|
function maybeSpeak(socket, message, ttsOptions) {
|
||||||
if (!ttsOptions || !message?.roverId) return;
|
if (!ttsOptions || !message) return;
|
||||||
if (String(message.roverId) === ptzCameraService.PTZ_CAMERA_ID) {
|
if (message.operatingMode === 'ptz') {
|
||||||
/*
|
/*
|
||||||
PTZ has no rover websocket, but it does have a real speaker behind the
|
PTZ has no rover websocket, but it does have a real speaker behind the
|
||||||
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
Reolink/neolink path. Keep PTZ routing here so chat remains the single
|
||||||
@@ -107,6 +100,7 @@ function maybeSpeak(socket, message, ttsOptions) {
|
|||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!message.roverId) return;
|
||||||
const record = roverManager.rovers.get(message.roverId);
|
const record = roverManager.rovers.get(message.roverId);
|
||||||
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
const ttsEnabled = Boolean(record?.meta?.audio?.ttsEnabled);
|
||||||
if (!ttsEnabled) return;
|
if (!ttsEnabled) return;
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ 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
|
||||||
@@ -97,7 +96,6 @@ function issueCommand(roverId, payload) {
|
|||||||
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;
|
||||||
@@ -132,7 +130,6 @@ 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,
|
||||||
@@ -258,12 +255,10 @@ module.exports = {
|
|||||||
getRecentDriveActivity,
|
getRecentDriveActivity,
|
||||||
setDriveCooldown,
|
setDriveCooldown,
|
||||||
commandEvents,
|
commandEvents,
|
||||||
subscribeCommandRecording,
|
|
||||||
submitCommand,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Shared authorization/safety boundary for browser and service-owned control.
|
io.on('connection', (socket) => {
|
||||||
function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
function handleCommand({ roverId, type, data } = {}, cb) {
|
||||||
const reply = typeof cb === 'function' ? cb : () => {};
|
const reply = typeof cb === 'function' ? cb : () => {};
|
||||||
try {
|
try {
|
||||||
if (!roverId) {
|
if (!roverId) {
|
||||||
@@ -395,12 +390,10 @@ function submitCommand(socket, { roverId, type, data } = {}, cb) {
|
|||||||
});
|
});
|
||||||
reply({ error: err.message });
|
reply({ error: err.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
socket.on('command', handleCommand);
|
||||||
const recordedCommand = (request, reply) => recordCommandRequest(socket, request, reply, (message, cb) => submitCommand(socket, message, cb));
|
socket.on('command:issue', handleCommand);
|
||||||
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 : () => {};
|
||||||
|
|||||||
@@ -1,55 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -59,12 +59,6 @@ function createChatBridgeHandlers(deps) {
|
|||||||
function handleChatBridgeOutbound(event) {
|
function handleChatBridgeOutbound(event) {
|
||||||
const payload = event?.payload;
|
const payload = event?.payload;
|
||||||
if (!payload) return;
|
if (!payload) return;
|
||||||
/*
|
|
||||||
Outbound bridge filtering must use chat visibility, not rover replay
|
|
||||||
visibility. PTZ deliberately uses roverId: "ptz-camera" so the existing
|
|
||||||
chat badge path can be reused, but that id is not a roverManager rover and
|
|
||||||
would be dropped by canReplayRoverId().
|
|
||||||
*/
|
|
||||||
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
if (payload?.roverId && !isPublicChatTargetId(payload.roverId)) return;
|
||||||
const guildConfigs = listGuildConfigs();
|
const guildConfigs = listGuildConfigs();
|
||||||
if (!guildConfigs.length) return;
|
if (!guildConfigs.length) return;
|
||||||
|
|||||||
@@ -24,14 +24,8 @@ function formatWebhookUsername(payload) {
|
|||||||
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
const origin = payload.discordGuildName ? ` (From: ${payload.discordGuildName})` : '';
|
||||||
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
return `${name}${origin}${botTag}${spectatorTag}${adminTag}`;
|
||||||
}
|
}
|
||||||
/*
|
const tagLabel = payload.operatingModeDisplay?.name || payload.roverName || payload.roverId;
|
||||||
The chat payload already carries the resolved display name for rover-like
|
const roverTag = tagLabel ? ` [${tagLabel}]` : '';
|
||||||
targets. Prefer that name so PTZ, which is intentionally pretending to be a
|
|
||||||
rover in chat, shows up as "PTZ Camera" instead of the internal id
|
|
||||||
"ptz-camera"; fall back to the id for older payloads or missing metadata.
|
|
||||||
*/
|
|
||||||
const roverTagLabel = payload.roverName || payload.roverId;
|
|
||||||
const roverTag = payload.roverId ? ` [${roverTagLabel}]` : '';
|
|
||||||
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
return `${name}${botTag}${spectatorTag}${adminTag}${roverTag}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,8 @@ const { loadConfig, getFeatureFlags, registerConfigurationHandler } = require('.
|
|||||||
const { getConfiguredSocials } = require('../sessionService/configuration');
|
const { getConfiguredSocials } = require('../sessionService/configuration');
|
||||||
const { getMode, MODES } = require('../modeManager');
|
const { getMode, MODES } = require('../modeManager');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { getTurnQueues } = require('../turnService');
|
const { getTurnQueues, getActiveDrivers } = require('../turnService');
|
||||||
|
const { buildRoverTurn } = require('../turnService/display');
|
||||||
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
const { getRoomCameras, getRoomCameraState } = require('../roomCameraService');
|
||||||
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
const { getRoverSnapshotState } = require('../roverSnapshotService');
|
||||||
const { getRole } = require('../roleService');
|
const { getRole } = require('../roleService');
|
||||||
@@ -157,22 +158,6 @@ function publicRoverIdSet(roster = []) {
|
|||||||
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
|
return new Set(roster.map((rover) => String(rover?.id || '')).filter(Boolean));
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterPublicTurnQueues(turnQueues = {}, publicIds) {
|
|
||||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
|
||||||
const next = {};
|
|
||||||
/*
|
|
||||||
RoverQueuesPanel creates fallback rows for queue ids that are not present in
|
|
||||||
the roster, so the public payload must filter queues with the exact same
|
|
||||||
privacy boundary as the roster. Otherwise a closed-private rover can leak as
|
|
||||||
an orphan queue row after a private access grant assigns someone to it.
|
|
||||||
*/
|
|
||||||
Object.entries(turnQueues || {}).forEach(([roverId, info]) => {
|
|
||||||
if (!visible.has(String(roverId))) return;
|
|
||||||
next[roverId] = info;
|
|
||||||
});
|
|
||||||
return next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function filterPublicUsers(users = [], publicIds) {
|
function filterPublicUsers(users = [], publicIds) {
|
||||||
const visible = publicIds instanceof Set ? publicIds : new Set();
|
const visible = publicIds instanceof Set ? publicIds : new Set();
|
||||||
/*
|
/*
|
||||||
@@ -203,6 +188,8 @@ function buildLocalInfo() {
|
|||||||
const publicIds = publicRoverIdSet(publicRoster);
|
const publicIds = publicRoverIdSet(publicRoster);
|
||||||
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
const roster = publicRoster.map((rover) => (lockdown ? rover : addRoverSnapshotLinks(rover)));
|
||||||
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
|
const users = filterPublicUsers(Array.from(io.sockets.sockets.values()).map(buildUserEntry), publicIds);
|
||||||
|
const turnQueues = getTurnQueues();
|
||||||
|
const activeDrivers = getActiveDrivers();
|
||||||
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
const roomCameras = lockdown || !features.roomCameras ? [] : getRoomCameras().map(buildRoomCameraInfo);
|
||||||
return {
|
return {
|
||||||
instance: {
|
instance: {
|
||||||
@@ -212,8 +199,12 @@ function buildLocalInfo() {
|
|||||||
features,
|
features,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
},
|
},
|
||||||
roster,
|
// Only public roster entries reach the builder; queue metadata follows the
|
||||||
turnQueues: filterPublicTurnQueues(getTurnQueues(), publicIds),
|
// same visibility boundary instead of creating separate orphan queue rows.
|
||||||
|
roster: roster.map((rover) => ({
|
||||||
|
...rover,
|
||||||
|
turn: buildRoverTurn({ rover, mode, socketId: null, turnInfo: turnQueues[rover.id], activeDriverId: activeDrivers[rover.id], users }),
|
||||||
|
})),
|
||||||
users,
|
users,
|
||||||
roomCameras,
|
roomCameras,
|
||||||
socials: features.socials ? getConfiguredSocials(config) : [],
|
socials: features.socials ? getConfiguredSocials(config) : [],
|
||||||
@@ -349,7 +340,6 @@ function normalizeRemotePayload(entry, payload) {
|
|||||||
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
features: instance.features && typeof instance.features === 'object' ? instance.features : {},
|
||||||
},
|
},
|
||||||
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
roster: Array.isArray(payload?.roster) ? payload.roster : [],
|
||||||
turnQueues: payload?.turnQueues && typeof payload.turnQueues === 'object' ? payload.turnQueues : {},
|
|
||||||
users: Array.isArray(payload?.users) ? payload.users : [],
|
users: Array.isArray(payload?.users) ? payload.users : [],
|
||||||
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
roomCameras: Array.isArray(payload?.roomCameras) ? payload.roomCameras : [],
|
||||||
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
socials: Array.isArray(payload?.socials) ? payload.socials : [],
|
||||||
@@ -419,7 +409,6 @@ function markOffline(entry, error) {
|
|||||||
publicUrl: previous.instance?.publicUrl || entry.url,
|
publicUrl: previous.instance?.publicUrl || entry.url,
|
||||||
},
|
},
|
||||||
roster: previous.roster || [],
|
roster: previous.roster || [],
|
||||||
turnQueues: previous.turnQueues || {},
|
|
||||||
users: previous.users || [],
|
users: previous.users || [],
|
||||||
roomCameras: previous.roomCameras || [],
|
roomCameras: previous.roomCameras || [],
|
||||||
socials: previous.socials || [],
|
socials: previous.socials || [],
|
||||||
|
|||||||
@@ -370,11 +370,6 @@ function createSnapshotEngine(deps) {
|
|||||||
.filter((entry) => {
|
.filter((entry) => {
|
||||||
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
const roverId = entry?.roverId ? String(entry.roverId) : null;
|
||||||
if (!roverId) return true;
|
if (!roverId) return true;
|
||||||
/*
|
|
||||||
This is a chat transcript filter, not a physical-rover filter. PTZ
|
|
||||||
chat intentionally carries a rover-like id so transcript consumers can
|
|
||||||
render it consistently, even though roverManager cannot replay that id.
|
|
||||||
*/
|
|
||||||
return isPublicChatTargetId(roverId);
|
return isPublicChatTargetId(roverId);
|
||||||
});
|
});
|
||||||
const chatRecent = allRecentMessages
|
const chatRecent = allRecentMessages
|
||||||
@@ -430,6 +425,8 @@ function createSnapshotEngine(deps) {
|
|||||||
type: 'chat',
|
type: 'chat',
|
||||||
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
nickname: entry.nickname || entry.socketId?.slice(0, 6) || 'unknown',
|
||||||
text: entry.text || '',
|
text: entry.text || '',
|
||||||
|
operating_mode: entry.operatingMode || null,
|
||||||
|
operating_mode_name: entry.operatingModeDisplay?.name || null,
|
||||||
rover_id: roverId,
|
rover_id: roverId,
|
||||||
rover_ctx: { ...baseCtx, ...storedCtx },
|
rover_ctx: { ...baseCtx, ...storedCtx },
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
const EventEmitter = require('events');
|
||||||
|
|
||||||
|
const operatingModeEvents = new EventEmitter();
|
||||||
|
const transitions = new WeakMap();
|
||||||
|
|
||||||
|
function getOperatingMode(socket) {
|
||||||
|
// Handshake intent reserves the mode before role-driven assignment runs.
|
||||||
|
// It grants no camera permissions; entry still checks access and readiness.
|
||||||
|
return socket?.data?.operatingMode
|
||||||
|
|| (socket?.handshake?.auth?.operatingMode === 'ptz' ? 'ptz' : 'rover');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function transition(socket, operatingMode) {
|
||||||
|
// Resolve services at the action boundary to avoid a dependency cycle with
|
||||||
|
// assignment and camera policy, which both read operating mode.
|
||||||
|
const assignment = require('../assignmentService');
|
||||||
|
const roverManager = require('../roverManager');
|
||||||
|
const ptz = require('../ptzCameraService');
|
||||||
|
if (operatingMode === 'rover' && getOperatingMode(socket) === 'rover') {
|
||||||
|
return { operatingMode, state: ptz.getPublicState(socket) };
|
||||||
|
}
|
||||||
|
if (operatingMode === 'ptz') {
|
||||||
|
await ptz.prepareParticipation(socket);
|
||||||
|
if (socket.disconnected) throw new Error('Session disconnected');
|
||||||
|
ptz.checkParticipationAccess(socket);
|
||||||
|
const leave = roverManager.canLeaveCurrentRover(socket);
|
||||||
|
if (!leave.ok) throw new Error(leave.message);
|
||||||
|
|
||||||
|
// Block new rover ownership before release events notify other services.
|
||||||
|
socket.data.operatingMode = 'ptz';
|
||||||
|
assignment.releaseForOperatingMode(socket);
|
||||||
|
socket.data.ptzEntered = true;
|
||||||
|
} else {
|
||||||
|
ptz.releaseTurn(socket);
|
||||||
|
socket.data.ptzEntered = false;
|
||||||
|
socket.data.operatingMode = 'rover';
|
||||||
|
assignment.resumeAssignment(socket);
|
||||||
|
}
|
||||||
|
operatingModeEvents.emit('change', { socket, operatingMode });
|
||||||
|
return { operatingMode, state: ptz.getPublicState(socket) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function enqueueTransition(socket, action) {
|
||||||
|
// Camera initialization can yield. Serialize switches and turn release per
|
||||||
|
// socket so a late entry cannot undo a subsequent exit or release request.
|
||||||
|
const previous = transitions.get(socket) || Promise.resolve();
|
||||||
|
const next = previous.catch(() => {}).then(() => {
|
||||||
|
if (socket.disconnected) throw new Error('Session disconnected');
|
||||||
|
return action();
|
||||||
|
});
|
||||||
|
transitions.set(socket, next);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setOperatingMode(socket, operatingMode) {
|
||||||
|
if (!['rover', 'ptz'].includes(operatingMode)) {
|
||||||
|
return Promise.reject(new Error('Invalid operating mode'));
|
||||||
|
}
|
||||||
|
return enqueueTransition(socket, () => transition(socket, operatingMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
function runPtzTurnAction(socket, action) {
|
||||||
|
return enqueueTransition(socket, () => {
|
||||||
|
const ptz = require('../ptzCameraService');
|
||||||
|
if (action === 'release') return ptz.releaseTurn(socket);
|
||||||
|
if (action !== 'request') throw new Error('Invalid PTZ turn action');
|
||||||
|
if (getOperatingMode(socket) !== 'ptz' || !socket.data.ptzEntered) {
|
||||||
|
throw new Error('Enter PTZ before requesting a turn');
|
||||||
|
}
|
||||||
|
return ptz.claimTurn(socket);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getOperatingMode, setOperatingMode, runPtzTurnAction, operatingModeEvents };
|
||||||
@@ -396,11 +396,6 @@ async function runDecision(triggerReason) {
|
|||||||
.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;
|
||||||
/*
|
|
||||||
Chat context should preserve every public chat target, including the
|
|
||||||
PTZ virtual rover. Rover replay visibility alone would drop PTZ because
|
|
||||||
it is owned by ptzCameraService instead of roverManager.
|
|
||||||
*/
|
|
||||||
return isPublicChatTargetId(entry.roverId);
|
return isPublicChatTargetId(entry.roverId);
|
||||||
})
|
})
|
||||||
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
.slice(-(MAX_CHAT_CONTEXT + MAX_BOT_CONTEXT));
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
|
// Purpose: Generates server-side TTS files and sends them to the Reolink TrackMix speaker through neolink.
|
||||||
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
|
// Scope: Owns file/cache/process details for PTZ speech only; PTZ ownership, chat identity, and camera motion stay in index.js.
|
||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs');
|
|
||||||
const fsp = require('fs/promises');
|
const fsp = require('fs/promises');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { spawn } = require('child_process');
|
const { spawn } = require('child_process');
|
||||||
@@ -78,6 +77,7 @@ function createPtzAudioPlayback(deps) {
|
|||||||
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
const volume = clampNumber(audioConfig.volume, DEFAULT_VOLUME, 0, 4);
|
||||||
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
const fliteDefaultVoice = String(audioConfig.fliteDefaultVoice || 'kal').trim();
|
||||||
|
|
||||||
|
let disposed = false;
|
||||||
let playbackProc = null;
|
let playbackProc = null;
|
||||||
let playbackSeq = 0;
|
let playbackSeq = 0;
|
||||||
|
|
||||||
@@ -244,12 +244,16 @@ function createPtzAudioPlayback(deps) {
|
|||||||
}, 1200);
|
}, 1200);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function playFile(filePath, context = {}) {
|
async function playFile(filePath, context = {}, authorize = () => {}) {
|
||||||
if (!audioEnabled) throw new Error('PTZ audio disabled');
|
if (disposed || !audioEnabled) throw new Error('PTZ audio unavailable');
|
||||||
|
authorize();
|
||||||
const neolinkConfigPath = await ensureNeolinkConfig();
|
const neolinkConfigPath = await ensureNeolinkConfig();
|
||||||
const stat = await fsp.stat(filePath);
|
const stat = await fsp.stat(filePath);
|
||||||
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
|
if (!stat.isFile()) throw new Error(`PTZ TTS file is not a regular file: ${filePath}`);
|
||||||
|
|
||||||
|
// Rendering and config IO may outlive a turn or camera configuration.
|
||||||
|
if (disposed) throw new Error('PTZ audio configuration changed');
|
||||||
|
authorize();
|
||||||
stopActivePlayback('new-playback');
|
stopActivePlayback('new-playback');
|
||||||
const seq = ++playbackSeq;
|
const seq = ++playbackSeq;
|
||||||
const args = [
|
const args = [
|
||||||
@@ -302,13 +306,13 @@ function createPtzAudioPlayback(deps) {
|
|||||||
return { pid: proc.pid || null, seq };
|
return { pid: proc.pid || null, seq };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function speakText(text, ttsOptions = {}, context = {}) {
|
async function speakText(text, ttsOptions = {}, context = {}, authorize = () => {}) {
|
||||||
const rendered = await ensureTtsFile(text, ttsOptions);
|
const rendered = await ensureTtsFile(text, ttsOptions);
|
||||||
await playFile(rendered.filePath, {
|
await playFile(rendered.filePath, {
|
||||||
...context,
|
...context,
|
||||||
engine: rendered.engine,
|
engine: rendered.engine,
|
||||||
cached: rendered.cached,
|
cached: rendered.cached,
|
||||||
});
|
}, authorize);
|
||||||
return rendered;
|
return rendered;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -316,6 +320,10 @@ function createPtzAudioPlayback(deps) {
|
|||||||
getState,
|
getState,
|
||||||
speakText,
|
speakText,
|
||||||
stopActivePlayback,
|
stopActivePlayback,
|
||||||
|
dispose: () => {
|
||||||
|
disposed = true;
|
||||||
|
stopActivePlayback('configuration-change');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,98 @@
|
|||||||
|
// Owns PTZ turns only. Camera IO, permissions and operating-mode entry are supplied
|
||||||
|
// by the service, so queue transitions cannot create physical rover ownership.
|
||||||
|
function createParticipation({ io, events, emitChange, getTurnDurationMs, canParticipate, stopMotion, revokeVideo }) {
|
||||||
|
const state = { operatorSocketId: null, queue: [], deadline: null, generation: 0 };
|
||||||
|
let timer = null;
|
||||||
|
|
||||||
|
function remove(socketId) {
|
||||||
|
state.queue = state.queue.filter((id) => id !== socketId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopOperator(reason) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
const previous = state.operatorSocketId;
|
||||||
|
state.operatorSocketId = null;
|
||||||
|
state.deadline = null;
|
||||||
|
state.generation += 1;
|
||||||
|
if (!previous) return;
|
||||||
|
revokeVideo(previous);
|
||||||
|
stopMotion(reason).catch(() => {});
|
||||||
|
events.emit('operator', { socketId: previous, action: 'release', reason });
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleTurn() {
|
||||||
|
// Uncontested control has no deadline. Starting a new wait gets a full turn,
|
||||||
|
// while later queue changes must not reset an already running countdown.
|
||||||
|
if (!state.operatorSocketId || !state.queue.length) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
state.deadline = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (timer) return;
|
||||||
|
const duration = getTurnDurationMs();
|
||||||
|
state.deadline = Date.now() + duration;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
timer = null;
|
||||||
|
state.queue = state.queue.filter(eligible);
|
||||||
|
if (state.queue.length) {
|
||||||
|
// Preserve PTZ's explicit request-to-rejoin rule after a handoff.
|
||||||
|
stopOperator('turn-expired');
|
||||||
|
advance('turn-expired');
|
||||||
|
} else {
|
||||||
|
scheduleTurn();
|
||||||
|
emitChange('turn-uncontested');
|
||||||
|
}
|
||||||
|
}, duration);
|
||||||
|
}
|
||||||
|
|
||||||
|
function eligible(socketId) {
|
||||||
|
const socket = io.sockets.sockets.get(socketId);
|
||||||
|
return socket && !socket.disconnected && canParticipate(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
function advance(reason) {
|
||||||
|
state.queue = state.queue.filter(eligible);
|
||||||
|
while (!state.operatorSocketId && state.queue.length) {
|
||||||
|
const next = state.queue.shift();
|
||||||
|
if (!eligible(next)) continue;
|
||||||
|
state.operatorSocketId = next;
|
||||||
|
state.generation += 1;
|
||||||
|
events.emit('operator', { socketId: next, action: 'active' });
|
||||||
|
}
|
||||||
|
scheduleTurn();
|
||||||
|
emitChange(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function claim(socket) {
|
||||||
|
if (!eligible(socket.id)) throw new Error('PTZ participation required');
|
||||||
|
if (state.operatorSocketId === socket.id || state.queue.includes(socket.id)) return;
|
||||||
|
state.queue.push(socket.id);
|
||||||
|
advance('queue-join');
|
||||||
|
}
|
||||||
|
|
||||||
|
function release(socketId, reason = 'turn-release') {
|
||||||
|
remove(socketId);
|
||||||
|
if (state.operatorSocketId === socketId) stopOperator(reason);
|
||||||
|
advance(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reconcile() {
|
||||||
|
state.queue = state.queue.filter(eligible);
|
||||||
|
if (state.operatorSocketId && !eligible(state.operatorSocketId)) stopOperator('access-change');
|
||||||
|
advance('access-change');
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear(reason) {
|
||||||
|
state.queue = [];
|
||||||
|
stopOperator(reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
state, claim, release, reconcile, clear,
|
||||||
|
revoke: (reason) => { stopOperator(reason); advance(reason); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createParticipation };
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
// Socket transport delegates policy and lifecycle decisions to the PTZ service.
|
||||||
|
function registerSocketGateway(deps) {
|
||||||
|
const { io, isAdmin, participation, emitChange, normalizeSocketArgs,
|
||||||
|
runPtzTurnAction, acceptMotionIntent, getStatus, setSpotlight, setIr,
|
||||||
|
listPresets, gotoPreset, createPreset, removePreset, passesMode,
|
||||||
|
normalizeSnapshotIds, addSnapshotSubscription, sendSnapshotFrame,
|
||||||
|
getSnapshot, removeSnapshotSubscriptions } = deps;
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('ptzCamera:revokeOperator', (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
if (!isAdmin(socket)) return cb({ error: 'Admin required' });
|
||||||
|
participation.revoke('admin-revoke');
|
||||||
|
cb({ ok: true });
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:requestTurn', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try { cb({ ok: true, state: await runPtzTurnAction(socket, 'request') }); }
|
||||||
|
catch (err) { cb({ error: err.message }); }
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:release', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, state: await runPtzTurnAction(socket, 'release') });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
Acknowledge acceptance of the newest desired state immediately. The
|
||||||
|
serialized ONVIF pump deliberately runs independently of Socket.IO
|
||||||
|
request latency so browser heartbeats cannot accumulate while waiting
|
||||||
|
for a camera SOAP response.
|
||||||
|
*/
|
||||||
|
cb(acceptMotionIntent(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:status', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, status: await getStatus(socket) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:spotlight', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, light: await setSpotlight(socket, payload) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:ir', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, ir: await setIr(socket, payload) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:presets:list', async (firstArg, secondArg) => {
|
||||||
|
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb({ ok: true, presets: await listPresets(socket) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:preset:goto', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb(await gotoPreset(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:preset:create', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb(await createPreset(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:preset:remove', async (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
cb(await removePreset(socket, payload));
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:snapshotSubscribe', (firstArg, secondArg) => {
|
||||||
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
try {
|
||||||
|
if (!passesMode(socket)) throw new Error('Not authorized for PTZ snapshots');
|
||||||
|
const ids = normalizeSnapshotIds(payload);
|
||||||
|
addSnapshotSubscription(socket, ids);
|
||||||
|
const lastSnapshotState = getSnapshot();
|
||||||
|
if (lastSnapshotState?.frame) sendSnapshotFrame(socket, lastSnapshotState.frame, lastSnapshotState.ts);
|
||||||
|
cb({ ok: true, subscribed: ids });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.on('ptzCamera:snapshotUnsubscribe', (firstArg, secondArg) => {
|
||||||
|
const { payload } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
|
removeSnapshotSubscriptions(socket.id, normalizeSnapshotIds(payload));
|
||||||
|
});
|
||||||
|
socket.on('disconnect', () => {
|
||||||
|
participation.release(socket.id, 'disconnect');
|
||||||
|
removeSnapshotSubscriptions(socket.id);
|
||||||
|
emitChange('disconnect');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { registerSocketGateway };
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
const labels = {
|
||||||
|
...require('../turnService/labels'),
|
||||||
|
waiting: 'Someone else has control',
|
||||||
|
active: 'You control the camera',
|
||||||
|
showName: 'Show camera name',
|
||||||
|
hideName: 'Hide camera name',
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = labels;
|
||||||
@@ -68,13 +68,13 @@ function getDefaultWebSources(assignment = {}, socket = null) {
|
|||||||
replay`, follows the camera currently controlled by that socket just as it
|
replay`, follows the camera currently controlled by that socket just as it
|
||||||
follows an assigned rover below.
|
follows an assigned rover below.
|
||||||
|
|
||||||
isOperator is deliberately stricter than PTZ access or queue membership:
|
turn.ownsControl is deliberately stricter than PTZ access or queue membership:
|
||||||
spectators and users waiting for a camera turn must not silently replay a
|
spectators and users waiting for a camera turn must not silently replay a
|
||||||
camera they are not currently operating. Keeping this rule here also makes
|
camera they are not currently operating. Keeping this rule here also makes
|
||||||
every web replay entry point share the same default instead of teaching the
|
every web replay entry point share the same default instead of teaching the
|
||||||
chat-command adapter about PTZ-specific state.
|
chat-command adapter about PTZ-specific state.
|
||||||
*/
|
*/
|
||||||
if (ptzCameraService.getPublicState(socket).isOperator) {
|
if (ptzCameraService.getPublicState(socket).turn.ownsControl) {
|
||||||
const source = ptzCameraService.getReplaySource();
|
const source = ptzCameraService.getReplaySource();
|
||||||
if (!source) return [];
|
if (!source) return [];
|
||||||
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
|
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
// Match actuator configuration, not a rover's display name or changing telemetry.
|
|
||||||
const { isDeepStrictEqual } = require('node:util');
|
|
||||||
|
|
||||||
function controlProfile(meta = {}) {
|
|
||||||
return Object.fromEntries(['cameraServo', 'peripherals', 'headlight', 'laser', 'roomba', 'platform']
|
|
||||||
.map((key) => [key, meta?.[key] ?? null]));
|
|
||||||
}
|
|
||||||
|
|
||||||
function compatible(model, record) {
|
|
||||||
if (model?.specification?.version !== 2 || !record) return false;
|
|
||||||
const profile = controlProfile(record.meta);
|
|
||||||
return Array.isArray(model.controlProfiles) && model.controlProfiles.some((saved) => isDeepStrictEqual(saved, profile));
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { controlProfile, compatible };
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
// 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 predicted action probability for issuing a new control event.' }),
|
|
||||||
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'] }),
|
|
||||||
};
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
// Export an explicit allowlist, never the raw socket/session/configuration object.
|
|
||||||
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',
|
|
||||||
'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: 1, generatedAtUnixMs: state.updatedAt,
|
|
||||||
software: { applicationVersion, implementationSha256, policyVersion: 2, 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, errorCategory: errorCategory(item.error) })) },
|
|
||||||
training: { status: training.status, steps: training.steps, examples: training.examples,
|
|
||||||
loss: training.loss, losses: training.losses || null, publication: training.publication || null,
|
|
||||||
evaluation: training.evaluation || null, dataset: metrics.dataset || training.dataset || null,
|
|
||||||
resources: { decodeSeconds: metrics.decodeSeconds ?? null, trainingSeconds: metrics.trainingSeconds ?? null,
|
|
||||||
evaluationSeconds: metrics.evaluationSeconds ?? null, checkpointSeconds: metrics.checkpointSeconds ?? null, cpuSeconds: metrics.cpuSeconds ?? null,
|
|
||||||
peakRssMiB: metrics.peakRssMiB ?? null },
|
|
||||||
history: training.history || [], rejectionCategories: (training.rejectionReasons || []).map(errorCategory),
|
|
||||||
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,
|
|
||||||
stopCategory: errorCategory(controller.stopReason), history: (controller.history || []).map((item) => ({
|
|
||||||
...item, submissions: item.submissions.map((submission) => ({ type: submission.type,
|
|
||||||
wheels: submission.wheels, result: submission.result === 'issued' ? 'issued' : errorCategory(submission.result) })) })) } : null,
|
|
||||||
sensorFields: metrics.policy?.sensorFields || null,
|
|
||||||
timing: { alignment: 'server_receipt', browserVideoLatencyMs: null, wheelCommandExpiryMs: null },
|
|
||||||
events: state.activity.map(({ at, message }) => ({ atUnixMs: at, message })),
|
|
||||||
unavailable: ['browser_video_latency', 'actual_physical_command_execution', 'live_training_cpu_utilization'],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
module.exports = { report };
|
|
||||||
@@ -1,208 +0,0 @@
|
|||||||
// 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', 'motors', 'servo', 'headlight', 'laser', 'horn', 'peripheral', 'song', 'raw']);
|
|
||||||
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');
|
|
||||||
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 === 'motors') {
|
|
||||||
command.motorPwm = { main: clamp(command.motorPwm?.main, -127, 127), side: clamp(command.motorPwm?.side, -127, 127), vacuum: clamp(command.motorPwm?.vacuum, 0, 127) };
|
|
||||||
}
|
|
||||||
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 } },
|
|
||||||
{ type: 'motors', motorPwm: { main: 0, side: 0, vacuum: 0 } },
|
|
||||||
{ type: 'horn', horn: { action: 'stop' } },
|
|
||||||
]) { 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.wheelSpeeds = message.wheelSpeeds;
|
|
||||||
session.sensorPresent = message.sensorPresent;
|
|
||||||
session.submissions = [];
|
|
||||||
if (!Array.isArray(message.commands) || message.commands.length > 65) throw new Error('Invalid action batch');
|
|
||||||
session.predictionStatus = message.wheelSpeeds?.some((value) => value !== 0) ? 'Sending wheel controls' : 'Predicting stopped wheels';
|
|
||||||
// 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(), wheelSpeeds: session.wheelSpeeds,
|
|
||||||
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 }) => ({ slot, score, reason })),
|
|
||||||
submissions: session.submissions.slice(0, 16).map(({ command, result }) => ({ type: command.type, result,
|
|
||||||
wheels: command.type === 'drive' ? command.driveDirect : undefined })) });
|
|
||||||
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 };
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
// 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 }); },
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
// 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);
|
|
||||||
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;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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 = { listModels, acquireModel, adoptCheckpoint, saveSnapshot };
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
// One bounded recording session. Video packets retain receiver wall-clock PTS;
|
|
||||||
// telemetry uses the same server clock, not an assumed browser playback clock.
|
|
||||||
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 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;
|
|
||||||
// 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;
|
|
||||||
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; });
|
|
||||||
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; 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.video.result = videoResult;
|
|
||||||
await saveMetadata(directory, metadata);
|
|
||||||
if (failure) logger.warn('Recording session incomplete', { roverId, id, error: failure, 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: failure, stopping }),
|
|
||||||
// Process exit is synchronous; normal disable uses stop() and awaits close.
|
|
||||||
kill: () => { if (!childClosed) child.kill('SIGKILL'); },
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { createRecording, saveMetadata };
|
|
||||||
@@ -1,311 +0,0 @@
|
|||||||
// Enabled-only recording, training, controller, and card lifecycle.
|
|
||||||
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 { 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: details.reason || details.error || details.model || null });
|
|
||||||
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 });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 });
|
|
||||||
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 }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function start() {
|
|
||||||
await fs.mkdir(recordingsRoot, { recursive: true });
|
|
||||||
await recoverRecordings(recordingsRoot);
|
|
||||||
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 };
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
// 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 };
|
|
||||||
@@ -1,63 +0,0 @@
|
|||||||
// 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;
|
|
||||||
for (const file of await fs.readdir(directory, { withFileTypes: true })) {
|
|
||||||
if (!file.isFile()) continue;
|
|
||||||
bytes += (await fs.stat(path.join(directory, file.name))).size;
|
|
||||||
}
|
|
||||||
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 };
|
|
||||||
@@ -1,185 +0,0 @@
|
|||||||
// One owned finite worker at a time. Leases are acquired in the runtime's
|
|
||||||
// serialized maintenance pass, before retention can next inspect recordings.
|
|
||||||
const fs = require('node:fs/promises');
|
|
||||||
const path = require('node:path');
|
|
||||||
const { spawn } = require('node:child_process');
|
|
||||||
const { randomInt } = 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-v3.json'), 'utf8'));
|
|
||||||
ledger = saved.sessions;
|
|
||||||
state = { ...saved.progress, status: 'waiting' };
|
|
||||||
if (!ledger || typeof ledger !== 'object') throw new Error('Invalid training schedule');
|
|
||||||
} 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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveLedger() {
|
|
||||||
const filename = path.join(trainingRoot, 'schedule-v3.json');
|
|
||||||
await fs.writeFile(`${filename}.tmp`, JSON.stringify({ 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]];
|
|
||||||
}
|
|
||||||
const selected = eligible.slice(0, 4);
|
|
||||||
nextRun = Date.now() + config.intervalSeconds * 1000;
|
|
||||||
if (!selected.length) {
|
|
||||||
state = { ...state, status: 'waiting', reason: 'Waiting for completed usable human recordings' };
|
|
||||||
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: null, 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,
|
|
||||||
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 });
|
|
||||||
} finally {
|
|
||||||
history.push({ at: Date.now(), status: state.status, steps: state.steps, loss: state.loss,
|
|
||||||
losses: state.losses || null, evaluation: state.evaluation || 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 };
|
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
"""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 policy import SLOTS, VALUES, HEIGHT, WIDTH, 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):
|
|
||||||
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, wheels = [], [], [(stamp, None) for stamp in boundaries]
|
|
||||||
stats.update({'sensorFrames': len(sensors), 'dispatches': len(dispatches),
|
|
||||||
'failedCommands': len(failed), 'sensorPresentFraction': np.mean([row[1][32:] for row in sensors], axis=0).tolist()})
|
|
||||||
for data in dispatches:
|
|
||||||
command = data.get('command') or {}
|
|
||||||
request = requests.get(data.get('requestId'))
|
|
||||||
human = (data.get('source') == 'client' and request and data.get('commandId') in accepted
|
|
||||||
and data.get('commandId') not in failed)
|
|
||||||
stamp = data['ts']
|
|
||||||
if command.get('type') == 'drive':
|
|
||||||
direct = command.get('driveDirect') or {}
|
|
||||||
pair = [direct.get('left'), direct.get('right')]
|
|
||||||
if human and all(isinstance(v, (int, float)) and math.isfinite(v) for v in pair):
|
|
||||||
wheels.append((stamp, np.clip(np.array(pair, dtype=np.float32), -metadata.get('wheelSpeedLimit', 500), metadata.get('wheelSpeedLimit', 500)) / 500))
|
|
||||||
else:
|
|
||||||
wheels.append((stamp, None))
|
|
||||||
elif command.get('type') in ('raw', 'reset', 'reboot', 'update', 'sensorStream'):
|
|
||||||
# Raw OI can change mode or motion; do not assume the last wheel
|
|
||||||
# command survives it. A later explicit human drive resolves intent.
|
|
||||||
wheels.append((stamp, None))
|
|
||||||
boundaries.append(stamp)
|
|
||||||
encoded = encode_command(command, candidate, extend=True)
|
|
||||||
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')
|
|
||||||
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)
|
|
||||||
if not targets:
|
|
||||||
raise ValueError('No accepted client operating commands')
|
|
||||||
schema[:] = candidate
|
|
||||||
return metadata, sensors, sorted(wheels, key=lambda x: x[0]), sorted(history, key=lambda x: x[0]), sorted(targets, key=lambda x: x[0]), sorted(boundaries)
|
|
||||||
|
|
||||||
|
|
||||||
def windows(directory, schema, cancelled=lambda: False, stats=None):
|
|
||||||
directory = Path(directory)
|
|
||||||
stats = stats if stats is not None else {}
|
|
||||||
metadata, sensors, wheels, history, targets, boundaries = prepare(directory, schema, stats)
|
|
||||||
wheel_times = [row[0] for row in wheels]
|
|
||||||
stats.update({'usableWindows': 0, 'movingWindows': 0, 'stoppedWindows': 0,
|
|
||||||
'unknownWheelWindows': 0, 'staleSensorWindows': 0, 'boundaryWindows': 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)
|
|
||||||
for _, slot, _ in history:
|
|
||||||
available[slot] = float(schema[slot]['template']['type'] != 'drive')
|
|
||||||
command_values = np.zeros((SLOTS, VALUES), dtype=np.float32)
|
|
||||||
command_times = np.full(SLOTS, -np.inf)
|
|
||||||
cursor = 0
|
|
||||||
recent = collections.deque(maxlen=32)
|
|
||||||
last_sample, last_pts = -math.inf, -math.inf
|
|
||||||
def frames():
|
|
||||||
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
|
|
||||||
yield from container.decode(stream)
|
|
||||||
|
|
||||||
for frame in frames():
|
|
||||||
if cancelled():
|
|
||||||
return
|
|
||||||
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 stamp < last_pts:
|
|
||||||
raise ValueError('Invalid/nonmonotonic video timestamps')
|
|
||||||
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')
|
|
||||||
if stamp < metadata.get('trainingStartedAt', metadata['startedAt']):
|
|
||||||
continue
|
|
||||||
if stamp - last_sample < 99:
|
|
||||||
continue
|
|
||||||
last_sample = stamp
|
|
||||||
while cursor < len(history) and history[cursor][0] < stamp:
|
|
||||||
when, slot, values = history[cursor]
|
|
||||||
command_values[slot] = values
|
|
||||||
command_times[slot] = when
|
|
||||||
cursor += 1
|
|
||||||
sensor_index = bisect.bisect_right(sensor_times, stamp) - 1
|
|
||||||
if sensor_index < 0 or stamp - sensor_times[sensor_index] > 1000:
|
|
||||||
stats['staleSensorWindows'] += 1
|
|
||||||
recent.clear()
|
|
||||||
continue
|
|
||||||
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 = []
|
|
||||||
for offset in (900, 600, 300, 0):
|
|
||||||
options = [row for row in recent if row[0] <= stamp - offset + 0.01]
|
|
||||||
if not options or stamp - offset - options[-1][0] > 200:
|
|
||||||
break
|
|
||||||
chosen.append(options[-1])
|
|
||||||
if len(chosen) != 4 or stamp + 100 > metadata['endedAt']:
|
|
||||||
continue
|
|
||||||
if bisect.bisect_right(boundaries, stamp + 100) > bisect.bisect_left(boundaries, chosen[0][0]):
|
|
||||||
stats['boundaryWindows'] += 1
|
|
||||||
continue
|
|
||||||
before = bisect.bisect_left(wheel_times, stamp) - 1
|
|
||||||
after = bisect.bisect_right(wheel_times, stamp + 100) - 1
|
|
||||||
if before < 0 or after < 0 or wheels[before][1] is None or wheels[after][1] is None:
|
|
||||||
stats['unknownWheelWindows'] += 1
|
|
||||||
continue
|
|
||||||
wheel_target, previous_wheels = wheels[after][1], wheels[before][1]
|
|
||||||
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 = template['type']
|
|
||||||
if family == 'drive':
|
|
||||||
continue
|
|
||||||
if family == 'peripheral':
|
|
||||||
peripheral = template.get('peripheral') or {}
|
|
||||||
family += ':' + str(peripheral.get('id')) + ':' + str(peripheral.get('control'))
|
|
||||||
if ((actions[slot] and (not schema[slot]['fields'] 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
|
|
||||||
stats['usableWindows'] += 1
|
|
||||||
stats['movingWindows' if np.max(np.abs(wheel_target)) > .02 else 'stoppedWindows'] += 1
|
|
||||||
yield np.stack([row[1] for row in chosen]), np.stack([row[2] for row in chosen]), actions, values, available, wheel_target, previous_wheels
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
#!/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 policy import Policy, VERSION, SLOTS, VALUES, HEIGHT, WIDTH, 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()
|
|
||||||
sampled = 0
|
|
||||||
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 frame in video.decode(stream):
|
|
||||||
if closed.is_set():
|
|
||||||
return
|
|
||||||
if frame.pts is None:
|
|
||||||
continue
|
|
||||||
stamp = float(frame.pts * frame.time_base) * 1000
|
|
||||||
now = time.time() * 1000
|
|
||||||
if not math.isfinite(stamp) or abs(now - stamp) > 2000:
|
|
||||||
raise ValueError('Video time is stale or not aligned to server time')
|
|
||||||
if stamp - sampled < 99:
|
|
||||||
continue
|
|
||||||
sampled = stamp
|
|
||||||
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':
|
|
||||||
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 = []
|
|
||||||
for offset in (900, 600, 300, 0):
|
|
||||||
candidates = [entry for entry in history if entry[0] <= stamp - offset + .01]
|
|
||||||
if not candidates or stamp - offset - candidates[-1][0] > 200:
|
|
||||||
break
|
|
||||||
selected.append(candidates[-1])
|
|
||||||
if len(selected) != 4:
|
|
||||||
continue
|
|
||||||
before = time.perf_counter()
|
|
||||||
with torch.inference_mode():
|
|
||||||
logits, numeric, wheel_output = 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()
|
|
||||||
wheel_speeds = [round(value * 500) for value in wheel_output[0].tolist()]
|
|
||||||
if not all(math.isfinite(value) for value in probabilities):
|
|
||||||
raise ValueError('Nonfinite model output')
|
|
||||||
families = {}
|
|
||||||
active = set()
|
|
||||||
proposals = []
|
|
||||||
for slot, descriptor in enumerate(schema):
|
|
||||||
if descriptor['template']['type'] == 'drive':
|
|
||||||
continue
|
|
||||||
command = json.loads(json.dumps(descriptor['template']))
|
|
||||||
family = command['type']
|
|
||||||
if family == 'peripheral':
|
|
||||||
family += ':' + str(command['peripheral']['id']) + ':' + str(command['peripheral']['control'])
|
|
||||||
for index, field in enumerate(descriptor['fields']):
|
|
||||||
number = predictions[slot][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
|
|
||||||
proposals.append({'slot': slot, 'score': probabilities[slot], 'command': command,
|
|
||||||
'reason': 'Below threshold'})
|
|
||||||
if probabilities[slot] < args.threshold:
|
|
||||||
continue
|
|
||||||
active.add(slot)
|
|
||||||
discrete = command['type'] in ('headlight', 'laser', 'horn', 'song', 'raw')
|
|
||||||
if family not in families or probabilities[slot] > families[family][0]:
|
|
||||||
families[family] = (probabilities[slot], command, slot, discrete)
|
|
||||||
latched.intersection_update(active)
|
|
||||||
# Choose the winning action before latching, so a held toggle cannot
|
|
||||||
# expose a competing lower-confidence toggle on the following tick.
|
|
||||||
commands = [command for _, command, slot, discrete in families.values()
|
|
||||||
if not discrete or slot not in latched]
|
|
||||||
winners = {row[2]: row for row in families.values()}
|
|
||||||
for proposal in proposals:
|
|
||||||
slot = proposal['slot']
|
|
||||||
if slot in active:
|
|
||||||
proposal['reason'] = ('Competing action' if slot not in winners else
|
|
||||||
'Held discrete action' if winners[slot][3] and slot in latched else 'Proposed')
|
|
||||||
for _, _, slot, discrete in families.values():
|
|
||||||
if discrete:
|
|
||||||
latched.add(slot)
|
|
||||||
commands.insert(0, {'type': 'drive', 'driveDirect': dict(zip(('left', 'right'), wheel_speeds))})
|
|
||||||
print(json.dumps({'kind': 'prediction', 'frameAt': stamp, 'sensorAt': sensor_time,
|
|
||||||
'latencyMs': (time.perf_counter() - before) * 1000,
|
|
||||||
'wheelSpeeds': wheel_speeds, 'sensorPresent': sensor_vector(sensors)[32:].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)
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
"""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 copy
|
|
||||||
import json
|
|
||||||
import math
|
|
||||||
|
|
||||||
import numpy as np
|
|
||||||
import torch
|
|
||||||
from torch import nn
|
|
||||||
|
|
||||||
VERSION = 2
|
|
||||||
SLOTS = 64
|
|
||||||
VALUES = 8
|
|
||||||
FRAMES = 4
|
|
||||||
HEIGHT, WIDTH = 120, 160
|
|
||||||
OPERATING_TYPES = {"drive", "motors", "servo", "headlight", "laser", "horn", "peripheral", "song", "raw"}
|
|
||||||
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),
|
|
||||||
("wheelSpeedsMmPerSecond.left", 500), ("wheelSpeedsMmPerSecond.right", 500),
|
|
||||||
("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
|
|
||||||
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())}
|
|
||||||
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, 'motors': 255, 'servo': 180, 'peripheral': 255,
|
|
||||||
'horn': 1000, 'song': 127}.get(command['type'], 1)
|
|
||||||
fields.append({'path': list(path), 'scale': scale, 'integer': isinstance(value, int)})
|
|
||||||
values.append(normalize(value, scale))
|
|
||||||
return {'$number': len(values) - 1}
|
|
||||||
# Arrays (e.g. songs) are discrete exact demonstrations, not variable
|
|
||||||
# length regression outputs. Never reinterpret raw OI bytes as numbers.
|
|
||||||
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; recording skipped, not silently truncated')
|
|
||||||
slot = len(schema)
|
|
||||||
schema.append(descriptor)
|
|
||||||
vector = np.zeros(VALUES, dtype=np.float32)
|
|
||||||
vector[:len(values)] = values
|
|
||||||
return slot, vector
|
|
||||||
|
|
||||||
|
|
||||||
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.wheels = nn.Linear(128, 2)
|
|
||||||
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(), self.wheels(last).tanh()
|
|
||||||
|
|
||||||
|
|
||||||
def specification(schema):
|
|
||||||
return {'version': VERSION, 'architecture': 'spatial-cnn-gru-wheels-v2',
|
|
||||||
'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': 'Continuous wheels at +100ms; accessories are events in the next 100ms',
|
|
||||||
'wheelScaleMmPerSecond': 500, 'wheelOrder': ['left', 'right'],
|
|
||||||
'wheelCommandExpiryMs': None, 'unknownWheelIntent': 'Excluded until next accepted human drive',
|
|
||||||
'timing': 'Server-receipt alignment; browser display latency unmeasured'}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
--extra-index-url https://download.pytorch.org/whl/cpu
|
|
||||||
torch==2.14.0+cpu
|
|
||||||
numpy==2.5.3
|
|
||||||
av==18.1.0
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
#!/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 torch.nn import functional as functional
|
|
||||||
from dataset import windows
|
|
||||||
from policy import Policy, VERSION, SLOTS, VALUES, specification
|
|
||||||
|
|
||||||
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([]))
|
|
||||||
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)
|
|
||||||
# A fresh training lineage prevents previously trained footage leaking into validation.
|
|
||||||
resume_path = root / 'training' / 'resume-v3.pt'
|
|
||||||
schema, steps, examples, last_published = [], 0, 0, 0
|
|
||||||
trained_rovers = set()
|
|
||||||
control_profiles = []
|
|
||||||
distinct_windows = {}
|
|
||||||
published_windows = 0
|
|
||||||
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)
|
|
||||||
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 ('stopped', 'forward', 'reverse', 'turn')}
|
|
||||||
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):
|
|
||||||
seen += 1
|
|
||||||
# Keep evaluation naturally distributed. Training reserves space
|
|
||||||
# for ordinary turns/reversing so idle or straight motion cannot
|
|
||||||
# consume the entire sample budget. No scripted driving required.
|
|
||||||
if held_out:
|
|
||||||
reservoir, count, capacity = samples, seen, per_session
|
|
||||||
else:
|
|
||||||
left, right = sample[5]
|
|
||||||
category = ('stopped' if max(abs(left), abs(right)) <= .02 else
|
|
||||||
'turn' if abs(left - right) > .1 else
|
|
||||||
'reverse' if left + right < 0 else 'forward')
|
|
||||||
counts[category] += 1
|
|
||||||
reservoir, count, capacity = buckets[category], counts[category], max(1, per_session // 4)
|
|
||||||
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:
|
|
||||||
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:
|
|
||||||
if candidate_schema != schema:
|
|
||||||
dataset_stats.append({**stats, 'partition': 'heldOut', 'sampledWindows': len(samples),
|
|
||||||
'skipReason': 'Vocabulary absent from training data'})
|
|
||||||
continue # Retry after training data introduces these command shapes.
|
|
||||||
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(np.max(np.abs(sample[5])) > .02 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
|
|
||||||
train_started = time.monotonic()
|
|
||||||
numeric_mask = torch.zeros(SLOTS, VALUES)
|
|
||||||
for slot, descriptor in enumerate(schema):
|
|
||||||
numeric_mask[slot, :len(descriptor['fields'])] = 1
|
|
||||||
# Balance sparse events without assuming that every time step should fire
|
|
||||||
# every control. This is training loss, not a driving-quality evaluation.
|
|
||||||
positives = np.sum([sample[2] for sample in pool], axis=0)
|
|
||||||
opportunities = np.sum([sample[4] for sample in pool], axis=0)
|
|
||||||
positive_weight = torch.from_numpy(np.clip((opportunities - positives) / np.maximum(positives, 1), 1, 20)).float()
|
|
||||||
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, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
|
||||||
images = images.float().div_(255)
|
|
||||||
optimizer.zero_grad(set_to_none=True)
|
|
||||||
action_logits, predictions, wheel_predictions = model(images, states)
|
|
||||||
event_loss = functional.binary_cross_entropy_with_logits(action_logits, targets,
|
|
||||||
pos_weight=positive_weight, reduction='none')
|
|
||||||
event_loss = (event_loss * available).sum() / available.sum().clamp_min(1)
|
|
||||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
|
||||||
value_loss = ((predictions - values).square() * mask).sum() / mask.sum().clamp_min(1)
|
|
||||||
wheel_loss = functional.smooth_l1_loss(wheel_predictions, wheel_targets)
|
|
||||||
loss = wheel_loss + event_loss + value_loss
|
|
||||||
if not torch.isfinite(loss):
|
|
||||||
raise ValueError('Nonfinite training loss; checkpoint not saved')
|
|
||||||
loss.backward()
|
|
||||||
torch.nn.utils.clip_grad_norm_(model.parameters(), 1)
|
|
||||||
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={'wheels': wheel_loss.item(), 'events': event_loss.item(), 'accessoryValues': 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())
|
|
||||||
evaluation = {'status': 'waiting for held-out sessions', 'windows': len(validation)}
|
|
||||||
if validation and not cancelled():
|
|
||||||
model.eval()
|
|
||||||
true_positive = predicted_positive = actual_positive = 0
|
|
||||||
value_error = value_count = 0
|
|
||||||
evaluated = 0
|
|
||||||
wheel_error = stopped_error = persistence_error = 0
|
|
||||||
changed_error = changed_persistence = changed_count = moving_count = 0
|
|
||||||
with torch.inference_mode():
|
|
||||||
for offset in range(0, len(validation), config['batchSize']):
|
|
||||||
if cancelled():
|
|
||||||
break
|
|
||||||
batch = validation[offset:offset + config['batchSize']]
|
|
||||||
images, states, targets, values, available, wheel_targets, previous_wheels = [torch.from_numpy(np.stack(items)) for items in zip(*batch)]
|
|
||||||
logits, predictions, wheel_predictions = model(images.float() / 255, states)
|
|
||||||
predicted = (logits.sigmoid() >= job['actionThreshold']) & available.bool()
|
|
||||||
actual = targets.bool() & available.bool()
|
|
||||||
true_positive += (predicted & actual).sum().item()
|
|
||||||
predicted_positive += predicted.sum().item()
|
|
||||||
actual_positive += actual.sum().item()
|
|
||||||
mask = numeric_mask.unsqueeze(0) * targets.unsqueeze(-1)
|
|
||||||
value_error += ((predictions - values).square() * mask).sum().item()
|
|
||||||
value_count += mask.sum().item()
|
|
||||||
errors = (wheel_predictions - wheel_targets).abs().mean(dim=1) * 500
|
|
||||||
persistence = (previous_wheels - wheel_targets).abs().mean(dim=1) * 500
|
|
||||||
changed = (previous_wheels - wheel_targets).abs().amax(dim=1) > .02
|
|
||||||
changed_error += errors[changed].sum().item()
|
|
||||||
changed_persistence += persistence[changed].sum().item()
|
|
||||||
changed_count += changed.sum().item()
|
|
||||||
moving_count += (wheel_targets.abs().amax(dim=1) > .02).sum().item()
|
|
||||||
wheel_error += errors.sum().item()
|
|
||||||
stopped_error += wheel_targets.abs().mean(dim=1).sum().item() * 500
|
|
||||||
persistence_error += persistence.sum().item()
|
|
||||||
evaluated += len(batch)
|
|
||||||
precision = true_positive / max(1, predicted_positive)
|
|
||||||
recall = true_positive / max(1, actual_positive)
|
|
||||||
mse = value_error / max(1, value_count)
|
|
||||||
wheel_mae = wheel_error / max(1, evaluated)
|
|
||||||
stopped_mae = stopped_error / max(1, evaluated)
|
|
||||||
persistence_mae = persistence_error / max(1, evaluated)
|
|
||||||
changed_mae = changed_error / max(1, changed_count)
|
|
||||||
changed_baseline = changed_persistence / max(1, changed_count)
|
|
||||||
# Check control changes separately: copying the previous speed can look
|
|
||||||
# excellent on long straight runs without learning to steer or stop.
|
|
||||||
wheels_pass = (evaluated == len(validation) and evaluated >= 128 and moving_count >= 32
|
|
||||||
and changed_count >= 32 and wheel_mae < stopped_mae
|
|
||||||
and wheel_mae <= persistence_mae and changed_mae < changed_baseline)
|
|
||||||
accessories_pass = (predicted_positive == 0 if actual_positive == 0 else
|
|
||||||
actual_positive >= 32 and precision >= .5 and recall >= .5)
|
|
||||||
passed = wheels_pass and accessories_pass and (value_count == 0 or mse <= .1)
|
|
||||||
evaluation = {'status': 'passed imitation gate' if passed else 'imitation gate not met',
|
|
||||||
'windows': evaluated, 'precision': precision, 'recall': recall,
|
|
||||||
'numericMse': mse, 'positiveActions': actual_positive,
|
|
||||||
'wheelMaeMmPerSecond': wheel_mae, 'stoppedBaselineMaeMmPerSecond': stopped_mae,
|
|
||||||
'previousSpeedBaselineMaeMmPerSecond': persistence_mae,
|
|
||||||
'changedWheelMaeMmPerSecond': changed_mae, 'changedBaselineMaeMmPerSecond': changed_baseline,
|
|
||||||
'changedWindows': changed_count, 'movingWindows': moving_count,
|
|
||||||
'wheelsPassed': wheels_pass, 'accessoriesPassed': accessories_pass}
|
|
||||||
evaluation_seconds = time.monotonic() - evaluation_started
|
|
||||||
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,
|
|
||||||
}, resume_path)
|
|
||||||
diagnostics = {'dataset': dataset_stats, '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)
|
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
const { getOperatingMode } = require('../operatingModeService');
|
||||||
// rover Manager private access
|
// rover Manager private access
|
||||||
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
|
// Purpose: Encapsulates private-rover visibility and control-authorization policy checks.
|
||||||
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
|
// Scope: Keeps runtime behavior unchanged while isolating policy decisions from orchestration wiring.
|
||||||
@@ -80,6 +81,7 @@ function createPrivateAccessPolicy(deps) {
|
|||||||
allowClosedPrivateGrantInLockdown = false,
|
allowClosedPrivateGrantInLockdown = false,
|
||||||
allowClosedPrivateCurrentDriver = false,
|
allowClosedPrivateCurrentDriver = false,
|
||||||
} = options;
|
} = options;
|
||||||
|
if (getOperatingMode(socket) !== 'rover') return 'Leave PTZ before requesting rover control';
|
||||||
if (!record) return 'Unknown rover';
|
if (!record) return 'Unknown rover';
|
||||||
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
|
if (!allowUser && !isAdmin(socket)) return 'Only admins can request control';
|
||||||
if (record.locked && !isAdmin(socket)) return 'Rover locked';
|
if (record.locked && !isAdmin(socket)) return 'Rover locked';
|
||||||
|
|||||||
@@ -5,16 +5,18 @@ const io = require('../../globals/io');
|
|||||||
const logger = require('../../globals/logger').child('sessionService');
|
const logger = require('../../globals/logger').child('sessionService');
|
||||||
const { getFeatureFlags, configurationEvents } = require('../../configuration');
|
const { getFeatureFlags, configurationEvents } = require('../../configuration');
|
||||||
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
const { getRole, isAdmin, roleEvents } = require('../roleService');
|
||||||
|
const { getOperatingMode, setOperatingMode, operatingModeEvents } = require('../operatingModeService');
|
||||||
const { getMode, modeEvents } = require('../modeManager');
|
const { getMode, modeEvents } = require('../modeManager');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
const { managerEvents } = roverManager;
|
const { managerEvents } = roverManager;
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
const { getActiveDrivers, getTurnQueues, turnEvents } = require('../turnService');
|
const { getActiveDrivers, getTurnQueues, canDrive, turnEvents } = require('../turnService');
|
||||||
|
const { buildRoverTurn } = require('../turnService/display');
|
||||||
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
const { getRoomCameras, roomCameraEvents } = require('../roomCameraService');
|
||||||
const {
|
const {
|
||||||
getPublicState: getPtzCameraState,
|
getPublicState: getPtzCameraState,
|
||||||
getChatTargetForSocket: getPtzChatTargetForSocket,
|
getOperatingModeDisplay,
|
||||||
PTZ_CAMERA_ID,
|
getParticipantSocketIds,
|
||||||
ptzCameraEvents,
|
ptzCameraEvents,
|
||||||
} = require('../ptzCameraService');
|
} = require('../ptzCameraService');
|
||||||
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
const { getState: getHomeAssistantState, homeAssistantEvents } = require('../homeAssistantService');
|
||||||
@@ -111,18 +113,13 @@ function buildBandwidthSavingsSessionState(socket, controllableUserCount = 0) {
|
|||||||
|
|
||||||
function countControllableUsers(userEntries = []) {
|
function countControllableUsers(userEntries = []) {
|
||||||
const ids = new Set();
|
const ids = new Set();
|
||||||
|
const ptzParticipants = new Set(getParticipantSocketIds());
|
||||||
userEntries.forEach((entry) => {
|
userEntries.forEach((entry) => {
|
||||||
const role = String(entry?.role || '');
|
const role = String(entry?.role || '');
|
||||||
if (role === 'spectator') return;
|
if (role === 'spectator') return;
|
||||||
const socketId = String(entry?.socketId || '').trim();
|
const socketId = String(entry?.socketId || '').trim();
|
||||||
const roverId = String(entry?.roverId || '').trim();
|
const roverId = String(entry?.roverId || '').trim();
|
||||||
/*
|
if (socketId && (roverId || ptzParticipants.has(socketId))) ids.add(socketId);
|
||||||
buildUserEntry already maps PTZ queued/operators to the PTZ pseudo-rover
|
|
||||||
id and normal drivers to their physical rover. Counting entries after that
|
|
||||||
normalization gives the browser the same conceptual "controllable users"
|
|
||||||
count it shows in the user/queue panels without duplicating PTZ UI logic.
|
|
||||||
*/
|
|
||||||
if (socketId && roverId) ids.add(socketId);
|
|
||||||
});
|
});
|
||||||
return ids.size;
|
return ids.size;
|
||||||
}
|
}
|
||||||
@@ -146,19 +143,14 @@ function buildUserEntry(socket) {
|
|||||||
&& roverManager.isDriver(assignment.roverId, socket)
|
&& roverManager.isDriver(assignment.roverId, socket)
|
||||||
? assignment.roverId
|
? assignment.roverId
|
||||||
: null;
|
: null;
|
||||||
const ptzChatTarget = getPtzChatTargetForSocket(socket.id);
|
|
||||||
return {
|
return {
|
||||||
socketId: socket.id,
|
socketId: socket.id,
|
||||||
userId: socket?.data?.userId || null,
|
userId: socket?.data?.userId || null,
|
||||||
nickname: getNickname(socket) || null,
|
nickname: getNickname(socket) || null,
|
||||||
role,
|
role,
|
||||||
/*
|
operatingMode: getOperatingMode(socket),
|
||||||
PTZ is not inserted into the physical rover roster, but for chat and user
|
operatingModeDisplay: getOperatingModeDisplay(socket.id),
|
||||||
presence it should read like the user moved to a rover-like target. Prefer
|
roverId: verifiedPrimaryRover || verifiedAssignmentRover || null,
|
||||||
the PTZ chat target while the socket is queued or operating so presence,
|
|
||||||
queue lookup, and chat identity all agree.
|
|
||||||
*/
|
|
||||||
roverId: ptzChatTarget?.roverId || verifiedPrimaryRover || verifiedAssignmentRover || null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -171,15 +163,7 @@ function buildSession(socket) {
|
|||||||
const controllableUserCount = countControllableUsers(userEntries);
|
const controllableUserCount = countControllableUsers(userEntries);
|
||||||
const users = userEntries.map((entry) => ({
|
const users = userEntries.map((entry) => ({
|
||||||
...entry,
|
...entry,
|
||||||
/*
|
roverId: filterVisibleRoverId(socket, entry.roverId),
|
||||||
PTZ is intentionally not a roverManager record, so the normal physical
|
|
||||||
rover visibility filter would erase the user's PTZ chat target. Preserve
|
|
||||||
it here because getPtzChatTargetForSocket already applied the PTZ access
|
|
||||||
and queue/operator rules before buildUserEntry returned it.
|
|
||||||
*/
|
|
||||||
roverId: entry.roverId === PTZ_CAMERA_ID
|
|
||||||
? entry.roverId
|
|
||||||
: filterVisibleRoverId(socket, entry.roverId),
|
|
||||||
}));
|
}));
|
||||||
const roster = roverManager.getRosterForSocket(socket);
|
const roster = roverManager.getRosterForSocket(socket);
|
||||||
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
const assignment = assignmentService.describeAssignment(socket?.id || '');
|
||||||
@@ -201,6 +185,7 @@ function buildSession(socket) {
|
|||||||
return {
|
return {
|
||||||
socketId: socket?.id || null,
|
socketId: socket?.id || null,
|
||||||
role: getRole(socket),
|
role: getRole(socket),
|
||||||
|
operatingMode: getOperatingMode(socket),
|
||||||
mode: getMode(),
|
mode: getMode(),
|
||||||
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
isLocalNetwork: isLocalNetwork(getSocketIp(socket)),
|
||||||
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
bandwidthSavings: buildBandwidthSavingsSessionState(socket, controllableUserCount),
|
||||||
@@ -210,15 +195,24 @@ function buildSession(socket) {
|
|||||||
of navigation and layout without maintaining another feature registry.
|
of navigation and layout without maintaining another feature registry.
|
||||||
*/
|
*/
|
||||||
features,
|
features,
|
||||||
roster,
|
roster: roster.map((rover) => ({
|
||||||
|
...rover,
|
||||||
|
permissions: { canControl: getRole(socket) !== 'spectator' && canDrive(rover.id, socket) },
|
||||||
|
turn: buildRoverTurn({
|
||||||
|
rover,
|
||||||
|
mode: getMode(),
|
||||||
|
socketId: socket?.id,
|
||||||
|
turnInfo: turnQueues[rover.id],
|
||||||
|
activeDriverId: activeDrivers[rover.id],
|
||||||
|
users,
|
||||||
|
}),
|
||||||
|
})),
|
||||||
odometers: roverManager.getOdometersForSocket(socket),
|
odometers: roverManager.getOdometersForSocket(socket),
|
||||||
assignment: {
|
assignment: {
|
||||||
...assignment,
|
...assignment,
|
||||||
roverId: assignmentRoverId,
|
roverId: assignmentRoverId,
|
||||||
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
status: assignmentRoverId ? assignment.status : assignment.status === 'waiting' ? 'waiting' : null,
|
||||||
},
|
},
|
||||||
activeDrivers,
|
|
||||||
turnQueues,
|
|
||||||
roomCameras: getRoomCameras(),
|
roomCameras: getRoomCameras(),
|
||||||
ptzCamera: getPtzCameraState(socket),
|
ptzCamera: getPtzCameraState(socket),
|
||||||
homeAssistant: getHomeAssistantState(),
|
homeAssistant: getHomeAssistantState(),
|
||||||
@@ -280,10 +274,19 @@ function syncAll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('session:setOperatingMode', async ({ operatingMode } = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ ok: true, ...await setOperatingMode(socket, operatingMode) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
logger.info('New socket connected', socket.id);
|
logger.info('New socket connected', socket.id);
|
||||||
syncSocket(socket);
|
syncSocket(socket);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
operatingModeEvents.on('change', () => syncAll());
|
||||||
|
|
||||||
roleEvents.on('change', ({ socket }) => {
|
roleEvents.on('change', ({ socket }) => {
|
||||||
if (!socket) return;
|
if (!socket) return;
|
||||||
logger.info('Role changed; syncing session', socket.id);
|
logger.info('Role changed; syncing session', socket.id);
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
const { describeQueue } = require('./queueDisplay');
|
||||||
|
const { TURN_DURATION_MS, IDLE_TIMEOUT_MS } = require('./constants');
|
||||||
|
|
||||||
|
const labels = {
|
||||||
|
...require('./labels'),
|
||||||
|
waiting: 'Someone else is driving',
|
||||||
|
active: 'You’re driving',
|
||||||
|
showName: 'Show rover name',
|
||||||
|
hideName: 'Hide rover name',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build this from visibility-filtered session data so private rover state stays private.
|
||||||
|
function buildRoverTurn({ rover, mode, socketId, turnInfo, activeDriverId, users }) {
|
||||||
|
const queue = turnInfo?.queue || [];
|
||||||
|
// Direct ownership can arrive before queue details on load/reconnect.
|
||||||
|
const currentDriverId = activeDriverId || turnInfo?.current || null;
|
||||||
|
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
|
||||||
|
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
||||||
|
const enabled = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
||||||
|
const turnsAhead = enabled ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
||||||
|
const display = describeQueue(queue, currentDriverId, users);
|
||||||
|
return {
|
||||||
|
target: { id: rover.id, name: rover.name, color: rover.color, fallback: rover.id },
|
||||||
|
...display,
|
||||||
|
// Null distinguishes a rover that has never had a queue from an empty queue.
|
||||||
|
queue: turnInfo ? display.queue : null,
|
||||||
|
enabled,
|
||||||
|
ownsControl: Boolean(socketId && currentDriverId === socketId),
|
||||||
|
turnsAhead,
|
||||||
|
queueLength: queue.length,
|
||||||
|
deadline: turnInfo?.deadline || null,
|
||||||
|
durationMs: TURN_DURATION_MS,
|
||||||
|
idleDeadline: turnInfo?.idleDeadline || null,
|
||||||
|
idleGraceMs: IDLE_TIMEOUT_MS,
|
||||||
|
labels,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { buildRoverTurn };
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
module.exports = {
|
||||||
|
handoff: 'It’s your turn!',
|
||||||
|
idleWarning: 'Start driving or your turn will be skipped',
|
||||||
|
activeTimer: 'Your turn',
|
||||||
|
waitingTimer: 'Waiting',
|
||||||
|
untilTurn: 'until your turn',
|
||||||
|
timeRemaining: 'left',
|
||||||
|
showTimer: 'Show turn timer',
|
||||||
|
hideTimer: 'Hide turn timer',
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// Queue entries are complete display records. No browser-side user lookup or
|
||||||
|
// naming fallback is needed, including when the queue comes from another server.
|
||||||
|
function describeQueue(socketIds, currentId, users) {
|
||||||
|
const byId = new Map(users.map((user) => [user.socketId, user]));
|
||||||
|
const currentIndex = currentId ? socketIds.indexOf(currentId) : -1;
|
||||||
|
return {
|
||||||
|
queue: socketIds.map((socketId) => {
|
||||||
|
const user = byId.get(socketId);
|
||||||
|
return {
|
||||||
|
socketId,
|
||||||
|
name: user?.nickname || user?.name || socketId.slice(0, 6) || 'unknown',
|
||||||
|
role: user?.role || null,
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
currentId,
|
||||||
|
nextId: socketIds.length > 1 ? socketIds[(currentIndex + 1) % socketIds.length] : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { describeQueue };
|
||||||
@@ -157,11 +157,11 @@ function identifySocket(socket, payload = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Spectator-style pages carry identity too, but only the driver surface
|
Spectator-style pages carry identity too, but only the driver and PTZ surfaces
|
||||||
participates in duplicate-driver enforcement. This flag remains on the
|
participate in duplicate-driver enforcement. This flag remains on the
|
||||||
socket because it is connection-specific, not person-specific.
|
socket because it is connection-specific, not person-specific.
|
||||||
*/
|
*/
|
||||||
socket.data.identitySurface = payload.identitySurface === 'driver' ? 'driver' : 'passive';
|
socket.data.identitySurface = ['driver', 'ptz'].includes(payload.identitySurface) ? payload.identitySurface : 'passive';
|
||||||
|
|
||||||
const result = identifyCanonicalSocket(socket, {
|
const result = identifyCanonicalSocket(socket, {
|
||||||
...payload,
|
...payload,
|
||||||
@@ -207,7 +207,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
|
|||||||
if (
|
if (
|
||||||
!currentSocket?.id ||
|
!currentSocket?.id ||
|
||||||
!currentUserId ||
|
!currentUserId ||
|
||||||
currentSocket.data?.identitySurface !== 'driver' ||
|
!['driver', 'ptz'].includes(currentSocket.data?.identitySurface) ||
|
||||||
!enforceForCurrentSocket
|
!enforceForCurrentSocket
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
@@ -215,7 +215,7 @@ function enforceSingleDriverSocketPerIdentity(currentSocket) {
|
|||||||
|
|
||||||
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
|
const duplicates = Array.from(io.sockets.sockets.values()).filter((candidate) => {
|
||||||
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
|
if (!candidate?.id || candidate.id === currentSocket.id || candidate.disconnected) return false;
|
||||||
if (candidate?.data?.identitySurface !== 'driver') return false;
|
if (!['driver', 'ptz'].includes(candidate?.data?.identitySurface)) return false;
|
||||||
return getUserIdForSocket(candidate) === currentUserId;
|
return getUserIdForSocket(candidate) === currentUserId;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -132,11 +132,11 @@ export function ChatIdentity({ message, toolsToggle = null }) {
|
|||||||
bot
|
bot
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
{message.roverId && (
|
{(message.roverId || message.operatingModeDisplay) && (
|
||||||
<RoverLabel
|
<RoverLabel
|
||||||
roverId={message.roverId}
|
roverId={message.roverId}
|
||||||
name={message.roverName}
|
name={message.operatingModeDisplay?.name || message.roverName}
|
||||||
color={message.roverColor}
|
color={message.operatingModeDisplay?.color || message.roverColor}
|
||||||
fallback={message.roverId}
|
fallback={message.roverId}
|
||||||
className="shrink-0 text-[0.7rem]"
|
className="shrink-0 text-[0.7rem]"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -51,33 +51,19 @@ function resolveTtsSettings(settings) {
|
|||||||
|
|
||||||
function useChatComposerSessionState(allowSpectatorInput) {
|
function useChatComposerSessionState(allowSpectatorInput) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
|
|
||||||
const chatTargetId = useMemo(() => {
|
|
||||||
/*
|
|
||||||
PTZ is intentionally not a roster entry, but session users expose the
|
|
||||||
current chat target for each socket. Prefer the self user entry so the TTS
|
|
||||||
control follows PTZ queue/operation state instead of only physical rover
|
|
||||||
assignment state.
|
|
||||||
*/
|
|
||||||
const self = users.find((entry) => entry?.socketId === socketId);
|
|
||||||
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
|
|
||||||
return self?.roverId || currentRoverId || null;
|
|
||||||
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
|
|
||||||
|
|
||||||
const rover = useMemo(
|
const rover = useMemo(
|
||||||
() => roster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
() => roster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||||
[chatTargetId, roster],
|
[currentRoverId, roster],
|
||||||
);
|
);
|
||||||
const ptzTtsSupported = Boolean(
|
const ptzTtsSupported = Boolean(
|
||||||
ptz?.id &&
|
operatingMode === 'ptz' &&
|
||||||
String(chatTargetId) === String(ptz.id) &&
|
|
||||||
ptz?.audio?.enabled &&
|
ptz?.audio?.enabled &&
|
||||||
(ptz?.isOperator || ptz?.queuedPosition),
|
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -27,9 +27,8 @@ function detectSafari() {
|
|||||||
|
|
||||||
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
|
function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpenChange = null }) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const operatingMode = useSessionSelector((state) => state.session?.operatingMode || 'rover');
|
||||||
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
const currentRoverId = useSessionSelector((state) => state.session?.assignment?.roverId || null);
|
||||||
const users = useSessionSelector((state) => state.session?.users || []);
|
|
||||||
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
const roverRoster = useSessionSelector((state) => state.session?.roster || []);
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
|
const { sendMessage, onInputFocus, onInputBlur, blurChat, registerInputRef, setTypingActive } = useChatActions();
|
||||||
@@ -49,26 +48,14 @@ function HudChatInput({ compact = false, variant = 'legacy', open = true, onOpen
|
|||||||
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||||
const canChat = role !== 'spectator';
|
const canChat = role !== 'spectator';
|
||||||
const hideHudChat = role === 'spectator';
|
const hideHudChat = role === 'spectator';
|
||||||
const chatTargetId = useMemo(() => {
|
|
||||||
/*
|
|
||||||
HUD chat does not render the full composer controls, but it still sends
|
|
||||||
TTS payloads when the active target supports speech. PTZ appears only in
|
|
||||||
the session user target, not the physical rover roster, so use the same
|
|
||||||
self-target resolution as the full chat panel.
|
|
||||||
*/
|
|
||||||
const self = users.find((entry) => entry?.socketId === socketId);
|
|
||||||
if (!self?.roverId && ptz?.id && (ptz?.isOperator || ptz?.queuedPosition)) return ptz.id;
|
|
||||||
return self?.roverId || currentRoverId || null;
|
|
||||||
}, [currentRoverId, ptz?.id, ptz?.isOperator, ptz?.queuedPosition, socketId, users]);
|
|
||||||
const rover = useMemo(
|
const rover = useMemo(
|
||||||
() => roverRoster.find((entry) => String(entry.id) === String(chatTargetId)) || null,
|
() => roverRoster.find((entry) => String(entry.id) === String(currentRoverId)) || null,
|
||||||
[chatTargetId, roverRoster],
|
[currentRoverId, roverRoster],
|
||||||
);
|
);
|
||||||
const ptzTtsSupported = Boolean(
|
const ptzTtsSupported = Boolean(
|
||||||
ptz?.id &&
|
operatingMode === 'ptz' &&
|
||||||
String(chatTargetId) === String(ptz.id) &&
|
|
||||||
ptz?.audio?.enabled &&
|
ptz?.audio?.enabled &&
|
||||||
(ptz?.isOperator || ptz?.queuedPosition),
|
(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead > 0),
|
||||||
);
|
);
|
||||||
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
const ttsSupported = Boolean(rover?.audio?.ttsEnabled || ptzTtsSupported);
|
||||||
const ttsPayload = useMemo(() => {
|
const ttsPayload = useMemo(() => {
|
||||||
|
|||||||
@@ -40,10 +40,8 @@ function HudOverlay({
|
|||||||
});
|
});
|
||||||
const derivedDriverLabel = useSessionSelector((state) => {
|
const derivedDriverLabel = useSessionSelector((state) => {
|
||||||
if (!effectiveRoverId || variant !== 'spectator') return null;
|
if (!effectiveRoverId || variant !== 'spectator') return null;
|
||||||
const activeId = state.session?.activeDrivers?.[effectiveRoverId] || null;
|
const turn = state.session?.roster?.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
|
||||||
const users = state.session?.users || [];
|
return turn?.queue?.find((entry) => entry.socketId === turn.currentId)?.name || null;
|
||||||
const match = users.find((u) => String(u.socketId || '') === String(activeId || ''));
|
|
||||||
return match?.nickname || match?.name || null;
|
|
||||||
});
|
});
|
||||||
const resolvedSensors = sensors ?? null;
|
const resolvedSensors = sensors ?? null;
|
||||||
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
const resolvedLabel = label ?? rosterInfo.label ?? null;
|
||||||
|
|||||||
@@ -7,16 +7,13 @@ import SocialButton from '../../SocialButton/index.jsx';
|
|||||||
function TurnsOverlay({
|
function TurnsOverlay({
|
||||||
roverId = null,
|
roverId = null,
|
||||||
mobileHud = false,
|
mobileHud = false,
|
||||||
turnModel = null,
|
|
||||||
}) {
|
}) {
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const effectiveRoverId = turnModel?.targetId ?? roverId ?? assignedRoverId;
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
const roster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const activeDrivers = useSessionSelector((state) => state.session?.activeDrivers ?? {});
|
|
||||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||||
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
const [turnCueStartAt, setTurnCueStartAt] = useState(null);
|
||||||
@@ -30,30 +27,15 @@ function TurnsOverlay({
|
|||||||
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
const subClass = mobileHud ? 'text-xs' : 'text-sm';
|
||||||
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
const cueTimerClass = mobileHud ? 'text-[0.55rem]' : 'text-[0.75rem]';
|
||||||
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
const cuePadClass = mobileHud ? 'px-4 py-3' : 'px-6 py-4';
|
||||||
const turnInfo = turnModel ? null : effectiveRoverId ? turnQueues?.[effectiveRoverId] : null;
|
const turnInfo = roster.find((rover) => String(rover.id) === String(effectiveRoverId))?.turn;
|
||||||
const activeDriverId = turnModel
|
const activeDriverId = turnInfo?.currentId || null;
|
||||||
? turnModel.activeId || null
|
const isActiveDriver = Boolean(turnInfo?.ownsControl);
|
||||||
: effectiveRoverId
|
const isTurnsMode = mode === 'turns';
|
||||||
? activeDrivers?.[effectiveRoverId] || null
|
|
||||||
: null;
|
|
||||||
const isActiveDriver = turnModel
|
|
||||||
? Boolean(turnModel.isActive)
|
|
||||||
: Boolean(socketId && activeDriverId === socketId);
|
|
||||||
const isTurnsMode = turnModel ? Boolean(turnModel.enabled) : mode === 'turns';
|
|
||||||
const now = useSharedClock(1000, isTurnsMode);
|
const now = useSharedClock(1000, isTurnsMode);
|
||||||
const nextDriverId = useMemo(() => {
|
const nextDriverId = turnInfo?.currentId ? turnInfo.nextId : null;
|
||||||
if (turnModel) return turnModel.nextId || null;
|
const isNextDriver = Boolean(socketId && nextDriverId === socketId);
|
||||||
const queue = turnInfo?.queue || [];
|
const deadline = turnInfo?.deadline || null;
|
||||||
if (!queue.length || !turnInfo?.current || queue.length <= 1) return null;
|
const idleDeadline = turnInfo?.idleDeadline || null;
|
||||||
const idx = queue.findIndex((id) => id === turnInfo.current);
|
|
||||||
if (idx === -1) return queue[0] || null;
|
|
||||||
return queue[(idx + 1) % queue.length] || null;
|
|
||||||
}, [turnInfo, turnModel]);
|
|
||||||
const isNextDriver = turnModel
|
|
||||||
? Boolean(turnModel.isNext)
|
|
||||||
: Boolean(socketId && nextDriverId === socketId);
|
|
||||||
const deadline = turnModel ? turnModel.deadline || null : turnInfo?.deadline || null;
|
|
||||||
const idleDeadline = turnModel ? turnModel.idleDeadline || null : turnInfo?.idleDeadline || null;
|
|
||||||
const msUntilTurn = deadline ? deadline - now : null;
|
const msUntilTurn = deadline ? deadline - now : null;
|
||||||
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
const msUntilIdleSkip = idleDeadline ? idleDeadline - now : null;
|
||||||
const totalRovers = roster.length;
|
const totalRovers = roster.length;
|
||||||
@@ -69,14 +51,10 @@ function TurnsOverlay({
|
|||||||
});
|
});
|
||||||
return unique.size;
|
return unique.size;
|
||||||
}, [users]);
|
}, [users]);
|
||||||
const shouldUsePreviewByLoad = turnModel
|
const shouldUsePreviewByLoad = isTurnsMode && totalDrivers > totalRovers;
|
||||||
? Boolean(turnModel.showPreviewReason)
|
|
||||||
: isTurnsMode && totalDrivers > totalRovers;
|
|
||||||
const isPreSwitchWindow =
|
const isPreSwitchWindow =
|
||||||
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
isTurnsMode && isNextDriver && msUntilTurn != null && msUntilTurn <= 5000 && msUntilTurn > 0;
|
||||||
const showNotTurnNotice = turnModel
|
const showNotTurnNotice = isTurnsMode && !isActiveDriver;
|
||||||
? Boolean(turnModel.showNotTurnNotice ?? (isTurnsMode && !isActiveDriver))
|
|
||||||
: isTurnsMode && !isActiveDriver;
|
|
||||||
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
const showPreviewReason = showNotTurnNotice && !isPreSwitchWindow && shouldUsePreviewByLoad;
|
||||||
const turnSeconds =
|
const turnSeconds =
|
||||||
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
msUntilTurn != null && Number.isFinite(msUntilTurn) ? Math.max(0, Math.ceil(msUntilTurn / 1000)) : null;
|
||||||
@@ -105,12 +83,9 @@ function TurnsOverlay({
|
|||||||
const turnTimerFlashActive = noticeFlashActive;
|
const turnTimerFlashActive = noticeFlashActive;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
/*
|
// The legacy overlay still follows the server's turns mode; ownership
|
||||||
Rover turns and PTZ turns now arrive through the same render path. Use the
|
// and deadlines now come from the same turn object as the new HUD.
|
||||||
normalized isTurnsMode flag here instead of checking the server's rover
|
|
||||||
mode directly, otherwise PTZ can render the notice but never trigger the
|
|
||||||
"your turn" cue when camera ownership changes.
|
|
||||||
*/
|
|
||||||
if (!isTurnsMode) {
|
if (!isTurnsMode) {
|
||||||
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
lastTurnRef.current = { initialized: false, roverId: null, activeDriverId: null };
|
||||||
/*
|
/*
|
||||||
|
|||||||
@@ -6,33 +6,11 @@ import { useControlActions, useControlSelector } from '../../../../controls/inde
|
|||||||
import ControlHint from '../../../ControlHint/index.jsx';
|
import ControlHint from '../../../ControlHint/index.jsx';
|
||||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
import RoundControl from './RoundControl.jsx';
|
||||||
|
import PeripheralPod from './PeripheralPod.jsx';
|
||||||
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
|
import HornSettingsExpansion from './HornSettingsExpansion.jsx';
|
||||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
|
||||||
import usePodVisibility from './usePodVisibility.js';
|
import usePodVisibility from './usePodVisibility.js';
|
||||||
|
|
||||||
function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '' }) {
|
|
||||||
const ControlIcon = icon;
|
|
||||||
const toneClass = tone === 'horn'
|
|
||||||
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
|
||||||
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={label}
|
|
||||||
disabled={disabled}
|
|
||||||
onClick={onClick}
|
|
||||||
onPointerDown={onPointerDown}
|
|
||||||
onPointerUp={onPointerUp}
|
|
||||||
onPointerCancel={onPointerUp}
|
|
||||||
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
|
|
||||||
>
|
|
||||||
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
|
|
||||||
<KeyPill label={keyLabel} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BottomLeftPod({ roverId }) {
|
export default function BottomLeftPod({ roverId }) {
|
||||||
const [open, setOpen] = usePodVisibility('peripherals', true);
|
const [open, setOpen] = usePodVisibility('peripherals', true);
|
||||||
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
|
const [hornSettingsOpen, setHornSettingsOpen] = usePodVisibility('hornSettings', false);
|
||||||
@@ -67,8 +45,7 @@ export default function BottomLeftPod({ roverId }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{open ? (
|
<PeripheralPod open={open} onOpenChange={setOpen} label="rover controls">
|
||||||
<div className="pointer-events-auto absolute bottom-0 left-0 z-20 h-40 w-40 rounded-tr-[4.25rem] bg-black/60">
|
|
||||||
{/*
|
{/*
|
||||||
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
|
The horn's center is the origin of an invisible 76px-radius arc. Headlight and laser
|
||||||
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
|
sit at -75 and -15 degrees on either side of that arc's diagonal midpoint. Their
|
||||||
@@ -86,11 +63,7 @@ export default function BottomLeftPod({ roverId }) {
|
|||||||
mirrors the old desktop control's visible disabled state; turn
|
mirrors the old desktop control's visible disabled state; turn
|
||||||
ownership remains the other independent control restriction. */}
|
ownership remains the other independent control restriction. */}
|
||||||
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
{laser ? <RoundControl label="Laser" icon={FaCrosshairs} keyLabel={<ControlHint actionId="laserToggle" />} active={laserOn} disabled={!canControl || roomLightsLockedOn} onClick={() => setLaser(!laserOn)} className="absolute left-[5.338rem] top-[4.021rem]" /> : null}
|
||||||
<CornerPodToggle corner="bottom-left" expanded label="Hide rover controls" onClick={() => setOpen(false)} />
|
</PeripheralPod>
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<CornerPodToggle corner="bottom-left" expanded={false} label="Show rover controls" onClick={() => setOpen(true)} />
|
|
||||||
)}
|
|
||||||
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
|
{hornDevice ? <HornSettingsExpansion open={hornSettingsOpen} onOpenChange={setHornSettingsOpen} /> : null}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { FaVideo } from 'react-icons/fa';
|
|||||||
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../../../controls/index.js';
|
||||||
import ControlHint from '../../../ControlHint/index.jsx';
|
import ControlHint from '../../../ControlHint/index.jsx';
|
||||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
import ChatExpansion from './ChatExpansion.jsx';
|
import ChatExpansion from './ChatExpansion.jsx';
|
||||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||||
@@ -28,7 +28,7 @@ function pointOnArc(fraction) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function BottomRightPod({ roverId }) {
|
export default function BottomRightPod({ roverId }) {
|
||||||
const layout = useDriverLayout();
|
const layout = useLayout();
|
||||||
const [open, setOpen] = usePodVisibility('camera', true);
|
const [open, setOpen] = usePodVisibility('camera', true);
|
||||||
const camera = useControlSelector((control) => control.state.camera);
|
const camera = useControlSelector((control) => control.state.camera);
|
||||||
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
const dockAssistActive = useControlSelector((control) => Boolean(control.state.manualDockAssist?.active));
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||||
|
|
||||||
|
export default function PeripheralPod({ open, onOpenChange, label, children, className = 'h-40 w-40' }) {
|
||||||
|
return open ? (
|
||||||
|
<div className={`pointer-events-auto absolute bottom-0 left-0 z-20 rounded-tr-[4.25rem] bg-black/60 ${className}`}>
|
||||||
|
{children}
|
||||||
|
<CornerPodToggle corner="bottom-left" expanded label={`Hide ${label}`} onClick={() => onOpenChange(false)} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<CornerPodToggle corner="bottom-left" expanded={false} label={`Show ${label}`} onClick={() => onOpenChange(true)} />
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
|
|
||||||
|
export default function RoundControl({ label, icon, keyLabel, active, tone, disabled = false, onClick, onPointerDown, onPointerUp, large = false, className = '', value = null }) {
|
||||||
|
const ControlIcon = icon;
|
||||||
|
const toneClass = tone === 'horn'
|
||||||
|
? active ? 'border-fuchsia-300/70 bg-fuchsia-700 text-fuchsia-50' : 'border-cyan-300/70 bg-cyan-900 text-cyan-50'
|
||||||
|
: active ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50';
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={label}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={onClick}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
|
onPointerUp={onPointerUp}
|
||||||
|
onPointerCancel={onPointerUp}
|
||||||
|
className={`flex shrink-0 select-none flex-col items-center justify-center gap-1 rounded-full border-2 ${toneClass} ${large ? 'h-20 w-20' : 'h-16 w-16'} disabled:cursor-not-allowed disabled:opacity-40 ${className}`}
|
||||||
|
>
|
||||||
|
<ControlIcon className={large ? 'text-xl' : 'text-base'} aria-hidden="true" />
|
||||||
|
{value != null ? <span className="text-xs font-semibold leading-none">{value}</span> : null}
|
||||||
|
<KeyPill label={keyLabel} />
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,40 +1,28 @@
|
|||||||
// Top-left Corner Pod
|
// Top-left Corner Pod
|
||||||
// Purpose: Shows the user's current-turn or queue-wait countdown and the rover identity expansion.
|
// Purpose: Renders the caller's turn-display object without knowing its operating mode.
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useControlSelector } from '../../../../controls/index.js';
|
import { useControlSelector } from '../../../../controls/index.js';
|
||||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
|
||||||
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
|
import { useSharedClock } from '../../../../hooks/useSharedClock.js';
|
||||||
import RoverLabel from '../../../RoverLabel/index.jsx';
|
import RoverLabel from '../../../RoverLabel/index.jsx';
|
||||||
import CornerPodToggle from './CornerPodToggle.jsx';
|
import CornerPodToggle from './CornerPodToggle.jsx';
|
||||||
import ExpansionPanel from './ExpansionPanel.jsx';
|
import ExpansionPanel from './ExpansionPanel.jsx';
|
||||||
import usePodVisibility from './usePodVisibility.js';
|
import usePodVisibility from './usePodVisibility.js';
|
||||||
|
|
||||||
export default function TopLeftPod({ roverId }) {
|
export default function TopLeftPod({ turns, compact = false }) {
|
||||||
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
|
const [timerOpen, setTimerOpen] = usePodVisibility('turnTimer', true);
|
||||||
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
|
const [nameOpen, setNameOpen] = usePodVisibility('roverName', true);
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const turnInfo = useSessionSelector((state) => state.session?.turnQueues?.[roverId] || null);
|
|
||||||
const activeDriverId = useSessionSelector((state) => state.session?.activeDrivers?.[roverId] || null);
|
|
||||||
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
const lastControlIntentAt = useControlSelector((control) => control.state.lastControlIntentAt);
|
||||||
const deadline = turnInfo?.deadline || null;
|
const { target, labels, enabled: turnActive, queueLength, turnsAhead, ownsControl: isCurrentTurn } = turns;
|
||||||
const idleDeadline = turnInfo?.idleDeadline || null;
|
const deadline = turns.deadline;
|
||||||
const queue = turnInfo?.queue || [];
|
const idleDeadline = turns.idleDeadline;
|
||||||
// Direct ownership is published independently from the detailed queue and is
|
const durationSeconds = turns.durationMs / 1000;
|
||||||
// therefore the reliable initial-load/reconnect source for the current turn.
|
|
||||||
const currentDriverId = activeDriverId || turnInfo?.current || null;
|
|
||||||
const currentIndex = currentDriverId ? queue.indexOf(currentDriverId) : -1;
|
|
||||||
const userIndex = socketId ? queue.indexOf(socketId) : -1;
|
|
||||||
const turnActive = mode === 'turns' && queue.length > 1 && currentIndex >= 0 && userIndex >= 0;
|
|
||||||
const hasTurnDeadline = Boolean(turnActive && deadline);
|
const hasTurnDeadline = Boolean(turnActive && deadline);
|
||||||
const now = useSharedClock(1000, hasTurnDeadline);
|
const now = useSharedClock(1000, hasTurnDeadline);
|
||||||
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
|
const currentTurnSeconds = hasTurnDeadline ? Math.max(0, Math.ceil((deadline - now) / 1000)) : null;
|
||||||
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
|
const idleSkipSeconds = idleDeadline ? Math.max(0, Math.ceil((idleDeadline - now) / 1000)) : null;
|
||||||
const turnsAhead = turnActive ? (userIndex - currentIndex + queue.length) % queue.length : null;
|
|
||||||
const seconds = currentTurnSeconds == null || turnsAhead == null
|
const seconds = currentTurnSeconds == null || turnsAhead == null
|
||||||
? null
|
? null
|
||||||
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * 60;
|
: currentTurnSeconds + Math.max(0, turnsAhead - 1) * durationSeconds;
|
||||||
const isCurrentTurn = turnsAhead === 0;
|
|
||||||
const isWaitingForTurn = turnActive && !isCurrentTurn;
|
const isWaitingForTurn = turnActive && !isCurrentTurn;
|
||||||
const [showTurnCue, setShowTurnCue] = useState(false);
|
const [showTurnCue, setShowTurnCue] = useState(false);
|
||||||
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
|
const [turnCueStartedAt, setTurnCueStartedAt] = useState(null);
|
||||||
@@ -81,25 +69,20 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
|
}, [lastControlIntentAt, showTurnCue, turnCueStartedAt]);
|
||||||
const gaugePercent = useMemo(() => {
|
const gaugePercent = useMemo(() => {
|
||||||
if (seconds == null) return 0;
|
if (seconds == null) return 0;
|
||||||
/*
|
// Scale waiting time by this target's actual turn duration. PTZ and rover
|
||||||
The server's rover turns are sixty seconds long. Waiting users need one complete
|
// turns have different lengths, but use the same presentation.
|
||||||
turn added for each driver between the current driver and themselves. A complete
|
const rotationSeconds = Math.max(durationSeconds, queueLength * durationSeconds);
|
||||||
queue rotation is a stable scale across handoffs, so the ring drains continuously
|
|
||||||
instead of jumping back to full when the current driver changes.
|
|
||||||
*/
|
|
||||||
const rotationSeconds = Math.max(60, queue.length * 60);
|
|
||||||
return Math.max(0, Math.min(1, seconds / rotationSeconds));
|
return Math.max(0, Math.min(1, seconds / rotationSeconds));
|
||||||
}, [queue.length, seconds]);
|
}, [durationSeconds, queueLength, seconds]);
|
||||||
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
|
const visibleGaugePercent = showTurnCue && idleSkipSeconds != null
|
||||||
// The server's initial inactivity grace is seven seconds. Mirroring that known lifecycle
|
// The caller supplies its inactivity grace alongside the idle deadline.
|
||||||
// makes the enlarged ring itself reinforce the prominent skip countdown in the center.
|
? Math.max(0, Math.min(1, idleSkipSeconds / (turns.idleGraceMs / 1000)))
|
||||||
? Math.max(0, Math.min(1, idleSkipSeconds / 7))
|
|
||||||
: gaugePercent;
|
: gaugePercent;
|
||||||
const showTimer = Boolean(turnActive && timerOpen);
|
const showTimer = Boolean(turnActive && timerOpen);
|
||||||
const showLargeTimer = isWaitingForTurn || showTurnCue;
|
const showLargeTimer = isWaitingForTurn || showTurnCue;
|
||||||
const timerLabel = seconds == null
|
const timerLabel = seconds == null
|
||||||
// Ownership should remain visible while the detailed deadline is in flight.
|
// Ownership should remain visible while the detailed deadline is in flight.
|
||||||
? isCurrentTurn ? 'Your turn' : 'Waiting'
|
? isCurrentTurn ? labels.activeTimer : labels.waitingTimer
|
||||||
: seconds >= 60
|
: seconds >= 60
|
||||||
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
? `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`
|
||||||
: `${seconds}s`;
|
: `${seconds}s`;
|
||||||
@@ -113,8 +96,8 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
// The waiting/handoff state replaces the old full-screen turn cue.
|
// The waiting/handoff state replaces the old full-screen turn cue.
|
||||||
// It must be opaque and above every other in-video HUD surface so
|
// It must be opaque and above every other in-video HUD surface so
|
||||||
// sensor graphics, chat, and docking controls cannot muddy the text.
|
// sensor graphics, chat, and docking controls cannot muddy the text.
|
||||||
? 'h-102 w-102 rounded-br-[12.75rem] bg-black'
|
? compact ? 'h-[min(12rem,45vw)] w-[min(12rem,45vw)] rounded-br-[6rem] bg-black' : 'h-102 w-102 rounded-br-[12.75rem] bg-black'
|
||||||
: 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
|
: compact ? 'h-24 w-24 rounded-br-[3rem] bg-black/60' : 'h-34 w-34 rounded-br-[4.25rem] bg-black/60'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
|
{/* The SVG fills the shell. Its circle geometry supplies the same slim visible inset
|
||||||
@@ -129,31 +112,31 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
fixed-size so it never becomes a giant obstruction over the video. */}
|
fixed-size so it never becomes a giant obstruction over the video. */}
|
||||||
{showLargeTimer ? (
|
{showLargeTimer ? (
|
||||||
<>
|
<>
|
||||||
<span className={`mb-2 text-[1.75rem] font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
|
<span className={`${compact ? 'mb-1 text-sm' : 'mb-2 text-[1.75rem]'} font-bold leading-tight transition-colors duration-300 ${showTurnCue ? 'text-amber-200' : 'text-sky-100'}`}>
|
||||||
{showTurnCue ? 'It’s your turn!' : 'Someone else is driving'}
|
{showTurnCue ? labels.handoff : labels.waiting}
|
||||||
</span>
|
</span>
|
||||||
<strong className="text-[3.375rem] leading-none">
|
<strong className={`${compact ? "text-2xl" : "text-[3.375rem]"} leading-none`}>
|
||||||
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
|
{showTurnCue && idleSkipSeconds != null ? `${idleSkipSeconds}s` : timerLabel}
|
||||||
</strong>
|
</strong>
|
||||||
<span className={`mt-2 text-[1rem] font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
|
<span className={`${compact ? 'mt-1 text-xs' : 'mt-2 text-[1rem]'} font-semibold leading-tight ${showTurnCue ? 'text-amber-200' : 'text-sky-200'}`}>
|
||||||
{showTurnCue
|
{showTurnCue
|
||||||
? idleSkipSeconds != null
|
? idleSkipSeconds != null
|
||||||
? 'Start driving or your turn will be skipped'
|
? labels.idleWarning
|
||||||
: 'You’re driving'
|
: labels.active
|
||||||
: 'until your turn'}
|
: labels.untilTurn}
|
||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<strong className="text-lg leading-none">{timerLabel}</strong>
|
<strong className="text-lg leading-none">{timerLabel}</strong>
|
||||||
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">left</span> : null}
|
{seconds != null ? <span className="mt-1 text-[0.6rem] font-semibold text-sky-200">{labels.timeRemaining}</span> : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</span>
|
</span>
|
||||||
<CornerPodToggle corner="top-left" expanded label="Hide turn timer" onClick={() => setTimerOpen(false)} />
|
<CornerPodToggle corner="top-left" expanded label={labels.hideTimer} onClick={() => setTimerOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
) : turnActive ? (
|
) : turnActive ? (
|
||||||
<CornerPodToggle corner="top-left" expanded={false} label="Show turn timer" onClick={() => setTimerOpen(true)} />
|
<CornerPodToggle corner="top-left" expanded={false} label={labels.showTimer} onClick={() => setTimerOpen(true)} />
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{/* The rover name is an independent edge expansion. Its visibility control lives in
|
{/* The rover name is an independent edge expansion. Its visibility control lives in
|
||||||
@@ -166,10 +149,10 @@ export default function TopLeftPod({ roverId }) {
|
|||||||
panelClassName={`flex h-11 min-w-max items-center gap-2 bg-black/60 px-2 pt-3 ${showTimer ? '' : 'rounded-br-xl'}`}
|
panelClassName={`flex h-11 min-w-max items-center gap-2 bg-black/60 px-2 pt-3 ${showTimer ? '' : 'rounded-br-xl'}`}
|
||||||
openDirection="down"
|
openDirection="down"
|
||||||
closeDirection="up"
|
closeDirection="up"
|
||||||
openLabel="Show rover name"
|
openLabel={labels.showName}
|
||||||
closeLabel="Hide rover name"
|
closeLabel={labels.hideName}
|
||||||
>
|
>
|
||||||
<RoverLabel roverId={roverId} fallback={roverId} className="px-2 py-1 text-base" />
|
<RoverLabel name={target.name} color={target.color} fallback={target.fallback} className="px-2 py-1 text-base" />
|
||||||
</ExpansionPanel>
|
</ExpansionPanel>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,19 +1,22 @@
|
|||||||
// New Drive Corner Pods
|
// New Drive Corner Pods
|
||||||
// Purpose: Composes the four independently owned corner controls around the shared video stage.
|
// Purpose: Composes the four independently owned corner controls around the shared video stage.
|
||||||
import TopLeftPod from './TopLeftPod.jsx';
|
import TopLeftPod from './TopLeftPod.jsx';
|
||||||
|
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||||
import TopRightPod from './TopRightPod.jsx';
|
import TopRightPod from './TopRightPod.jsx';
|
||||||
import BottomLeftPod from './BottomLeftPod.jsx';
|
import BottomLeftPod from './BottomLeftPod.jsx';
|
||||||
import BottomRightPod from './BottomRightPod.jsx';
|
import BottomRightPod from './BottomRightPod.jsx';
|
||||||
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
|
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
|
||||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
|
|
||||||
export default function CornerPods({ roverId }) {
|
export default function CornerPods({ roverId }) {
|
||||||
const layout = useDriverLayout();
|
const layout = useLayout();
|
||||||
|
const turns = useSessionSelector((state) => state.session?.roster
|
||||||
|
?.find((rover) => String(rover.id) === String(roverId))?.turn);
|
||||||
const showPhysicalControlPods = layout === 'desktop';
|
const showPhysicalControlPods = layout === 'desktop';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<TopLeftPod roverId={roverId} />
|
{turns ? <TopLeftPod turns={turns} /> : null}
|
||||||
<TopRightPod roverId={roverId} />
|
<TopRightPod roverId={roverId} />
|
||||||
{/* The mobile layouts already provide large touch controls around the video.
|
{/* The mobile layouts already provide large touch controls around the video.
|
||||||
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { dockTelemetryEqual, selectDockTelemetry } from '../../../../context/tel
|
|||||||
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
import { useManualDockAssist } from '../../../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { useSettingsNamespace } from '../../../../settings/index.js';
|
import { useSettingsNamespace } from '../../../../settings/index.js';
|
||||||
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
import useCanControlRover from '../../../../hooks/useCanControlRover.js';
|
||||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../../../layouts/LayoutContext.jsx';
|
||||||
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../../context/SessionContext.jsx';
|
||||||
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
import KeyPill from '../../../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
|
import ExpansionPanel from '../CornerPods/ExpansionPanel.jsx';
|
||||||
@@ -250,7 +250,7 @@ function UndockTransitionGhost({ onFinish }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function DockingHud({ roverId }) {
|
export default function DockingHud({ roverId }) {
|
||||||
const layout = useDriverLayout();
|
const layout = useLayout();
|
||||||
const actions = useControlActions();
|
const actions = useControlActions();
|
||||||
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
const dockTelemetry = useTelemetrySelector(roverId, selectDockTelemetry, dockTelemetryEqual);
|
||||||
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
// This replaces ManualDockAssistOverlay as the current HUD's one lifecycle owner. It preserves the
|
||||||
|
|||||||
@@ -170,8 +170,6 @@ export function ExternalInstancesCompact({ onBrowse = null }) {
|
|||||||
key={remote.url}
|
key={remote.url}
|
||||||
title={remote.instance?.name || remote.url}
|
title={remote.instance?.name || remote.url}
|
||||||
roster={remote.roster}
|
roster={remote.roster}
|
||||||
turnQueues={remote.turnQueues}
|
|
||||||
users={remote.users}
|
|
||||||
externalInstance={remote}
|
externalInstance={remote}
|
||||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||||
/>
|
/>
|
||||||
@@ -221,8 +219,6 @@ function InterInstanceCards({ instances, centered = false, singleColumn = false
|
|||||||
<RemoteMediaStrip remote={remote} />
|
<RemoteMediaStrip remote={remote} />
|
||||||
<RoverQueuesPanel
|
<RoverQueuesPanel
|
||||||
roster={remote.roster}
|
roster={remote.roster}
|
||||||
turnQueues={remote.turnQueues}
|
|
||||||
users={remote.users}
|
|
||||||
externalInstance={remote}
|
externalInstance={remote}
|
||||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import BottomSensorHud from '../HudOverlays/newgen/BottomSensorHud/index.jsx';
|
|||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useControlSelector } from '../../controls/index.js';
|
import { useControlSelector } from '../../controls/index.js';
|
||||||
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
import { useDriverVideoModePolicy } from '../../hooks/useDriverVideoModePolicy.js';
|
||||||
import { useDriverLayout } from '../../layouts/driver/DriverLayoutContext.jsx';
|
import { useLayout } from '../../layouts/LayoutContext.jsx';
|
||||||
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
||||||
|
|
||||||
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
|
// Mobile keeps the same HUD composition and coordinate system as desktop, but its
|
||||||
@@ -21,7 +21,7 @@ import EmptyDriverVideoNotice from '../DriverVideo/EmptyDriverVideoNotice.jsx';
|
|||||||
const MOBILE_HUD_SCALE = 0.68;
|
const MOBILE_HUD_SCALE = 0.68;
|
||||||
|
|
||||||
export default function NewDriveVideo() {
|
export default function NewDriveVideo() {
|
||||||
const layout = useDriverLayout();
|
const layout = useLayout();
|
||||||
const mobileHud = layout !== 'desktop';
|
const mobileHud = layout !== 'desktop';
|
||||||
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const roverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const videoMode = useDriverVideoModePolicy(roverId);
|
const videoMode = useDriverVideoModePolicy(roverId);
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import { useSessionActions } from '../../context/SessionContext.jsx';
|
||||||
|
|
||||||
|
export default function OperatingModeRouteSync() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const previousPath = useRef(pathname);
|
||||||
|
const { setOperatingMode, pushAlert } = useSessionActions();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const leavingPtz = previousPath.current === '/ptz' && pathname !== '/ptz';
|
||||||
|
previousPath.current = pathname;
|
||||||
|
if (!leavingPtz) return;
|
||||||
|
// Observe actual navigation rather than component cleanup, which also runs
|
||||||
|
// during Strict Mode remounts. Entry remains gated by the PTZ request action.
|
||||||
|
setOperatingMode('rover').catch((err) => {
|
||||||
|
pushAlert({ title: 'Operating mode', message: err.message, color: '#f59e0b' });
|
||||||
|
});
|
||||||
|
}, [pathname, pushAlert, setOperatingMode]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -1,32 +1,13 @@
|
|||||||
// PTZ Camera UI
|
// Driver-page entry card; PTZ route lifecycle belongs to the PTZ page.
|
||||||
// Purpose: Integrates the single PTZ camera into the main rover UI flow as a
|
import { useState } from 'react';
|
||||||
// queueable controllable target instead of a VIP-panel card.
|
|
||||||
// Scope: Owns the driver-page PTZ entry card and the dedicated PTZ route
|
|
||||||
// composition; PTZ command authority, queue ownership, and stream authorization
|
|
||||||
// remain server-owned.
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||||
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
|
import { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
||||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
|
||||||
import HomeAssistantControls from '../HomeAssistantControls/index.jsx';
|
|
||||||
import PtzLiveVideo, { PTZ_CAMERA_ID } from '../PtzLiveVideo/index.jsx';
|
|
||||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
|
||||||
import QueueTargetRow, { QueueUserChips } from '../QueueTargetRow/index.jsx';
|
|
||||||
import TurnsOverlay from '../HudOverlays/TurnsOverlay/index.jsx';
|
|
||||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
|
||||||
import { useControlActions } from '../../controls/index.js';
|
|
||||||
import ControlHint from '../ControlHint/index.jsx';
|
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
|
||||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import { isFeatureEnabled } from '../../lib/features.js';
|
import { isFeatureEnabled } from '../../lib/features.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
const PTZ_DEFAULT_COLOR = '#387bf8';
|
||||||
import { DEFAULT_PAGE_THEME_KEY, usePageThemeClass } from '../../themes/index.js';
|
|
||||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
|
||||||
|
|
||||||
const PTZ_DEFAULT_COLOR = '#38bdf8';
|
|
||||||
|
|
||||||
function formatRemaining(deadline, now) {
|
function formatRemaining(deadline, now) {
|
||||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - now) / 1000));
|
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - now) / 1000));
|
||||||
@@ -36,840 +17,17 @@ function formatRemaining(deadline, now) {
|
|||||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSpotlightOn(light = {}) {
|
|
||||||
if (typeof light?.on === 'boolean') return light.on;
|
|
||||||
const raw = light?.state;
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const normalized = raw.trim().toLowerCase();
|
|
||||||
return !['', '0', 'off', 'false'].includes(normalized);
|
|
||||||
}
|
|
||||||
return Boolean(Number(raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIrMode(mode) {
|
|
||||||
const normalized = String(mode || '').trim().toLowerCase();
|
|
||||||
if (normalized === 'on') return 'On';
|
|
||||||
if (normalized === 'off') return 'Off';
|
|
||||||
return 'Auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextIrMode(currentMode) {
|
|
||||||
const current = normalizeIrMode(currentMode);
|
|
||||||
if (current === 'Auto') return 'On';
|
|
||||||
if (current === 'On') return 'Off';
|
|
||||||
return 'Auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePtzQueue(ptz = null) {
|
|
||||||
/*
|
|
||||||
The PTZ service exposes the current operator separately from the waiting
|
|
||||||
queue, while the rover queue row expects one ordered queue plus a current id.
|
|
||||||
Normalizing once keeps every PTZ surface consistent with the shared queue
|
|
||||||
renderer without making that renderer understand PTZ service internals.
|
|
||||||
*/
|
|
||||||
const currentId = ptz?.operatorSocketId || null;
|
|
||||||
const waiting = Array.isArray(ptz?.queue)
|
|
||||||
? ptz.queue.map((entry) => entry?.socketId || entry).filter(Boolean)
|
|
||||||
: [];
|
|
||||||
const queue = currentId ? [currentId, ...waiting.filter((id) => id !== currentId)] : waiting;
|
|
||||||
const nextId = currentId ? waiting[0] || null : queue[0] || null;
|
|
||||||
return { queue, currentId, nextId };
|
|
||||||
}
|
|
||||||
|
|
||||||
function usePtzQueueLookup(ptz = null) {
|
|
||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
|
||||||
return useCallback(
|
|
||||||
(socketId) => {
|
|
||||||
const fromUsers = users.find((entry) => entry.socketId === socketId);
|
|
||||||
if (fromUsers) return fromUsers;
|
|
||||||
if (ptz?.operatorSocketId === socketId) {
|
|
||||||
return { socketId, nickname: ptz?.operatorLabel || null, role: null };
|
|
||||||
}
|
|
||||||
const fromQueue = Array.isArray(ptz?.queue)
|
|
||||||
? ptz.queue.find((entry) => (entry?.socketId || entry) === socketId)
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
socketId,
|
|
||||||
nickname: fromQueue?.label || null,
|
|
||||||
role: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
[ptz, users],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzSnapshotPreview({ feed, label = 'PTZ Camera', className = 'h-full w-full' }) {
|
|
||||||
return (
|
|
||||||
<div className={`relative overflow-hidden bg-black ${className}`}>
|
|
||||||
{feed?.objectUrl ? (
|
|
||||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-contain" />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
|
||||||
)}
|
|
||||||
{/*
|
|
||||||
Snapshot mode should look like the regular rover video player: the
|
|
||||||
camera name belongs to the surrounding card/menu, while the media pane
|
|
||||||
only exposes stream health in the small top-left diagnostic overlay.
|
|
||||||
*/}
|
|
||||||
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
|
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
|
||||||
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzQueueSummary({ ptz, title = 'PTZ queue' }) {
|
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const lookupUser = usePtzQueueLookup(ptz);
|
|
||||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame title={title} bodyClassName="space-y-0.5 p-1 text-sm">
|
|
||||||
<QueueUserChips
|
|
||||||
targetId={ptz?.id || PTZ_CAMERA_ID}
|
|
||||||
queue={queue}
|
|
||||||
currentId={currentId}
|
|
||||||
nextId={nextId}
|
|
||||||
selfId={selfId}
|
|
||||||
lookupUser={lookupUser}
|
|
||||||
/>
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzLightingControls({ ptz, disabled = false }) {
|
|
||||||
const { ptzSpotlight, ptzIr } = useSessionActions();
|
|
||||||
const [busy, setBusy] = useState('');
|
|
||||||
const spotlightOn = isSpotlightOn(ptz?.light);
|
|
||||||
const irMode = normalizeIrMode(ptz?.ir?.state);
|
|
||||||
|
|
||||||
const toggleSpotlight = async (nextOn) => {
|
|
||||||
if (disabled) return;
|
|
||||||
setBusy('spotlight');
|
|
||||||
try {
|
|
||||||
await ptzSpotlight({ state: nextOn ? 1 : 0 });
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleIr = async () => {
|
|
||||||
if (disabled) return;
|
|
||||||
triggerTouchHaptic('button');
|
|
||||||
setBusy('ir');
|
|
||||||
try {
|
|
||||||
await ptzIr({ state: nextIrMode(irMode) });
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
||||||
<GPIOToggleControl
|
|
||||||
label="Spotlight"
|
|
||||||
on={spotlightOn}
|
|
||||||
disabled={disabled || busy === 'spotlight'}
|
|
||||||
onToggle={toggleSpotlight}
|
|
||||||
heightClass="min-h-14"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
|
|
||||||
disabled={disabled || busy === 'ir'}
|
|
||||||
onClick={cycleIr}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold">Infrared</span>
|
|
||||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileZoomButtons({ disabled = false }) {
|
|
||||||
const { setCameraAxisIntent } = useControlActions();
|
|
||||||
|
|
||||||
const stopZoom = useCallback(() => {
|
|
||||||
/*
|
|
||||||
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
|
||||||
pan/tilt direction still held on the movement pad, so lifting one finger
|
|
||||||
cannot erase the other finger's intent.
|
|
||||||
*/
|
|
||||||
setCameraAxisIntent(0);
|
|
||||||
}, [setCameraAxisIntent]);
|
|
||||||
|
|
||||||
const startZoom = useCallback(
|
|
||||||
(direction) => (event) => {
|
|
||||||
/*
|
|
||||||
Publish held state once. The adapter owns the single motion heartbeat,
|
|
||||||
so this button no longer creates a second interval whose queued callback
|
|
||||||
could run after pointerup and restart zoom.
|
|
||||||
*/
|
|
||||||
event.preventDefault();
|
|
||||||
if (disabled) return;
|
|
||||||
triggerTouchHaptic('button');
|
|
||||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
||||||
setCameraAxisIntent(direction);
|
|
||||||
},
|
|
||||||
[disabled, setCameraAxisIntent],
|
|
||||||
);
|
|
||||||
const stopFromPointer = useCallback(
|
|
||||||
(event) => {
|
|
||||||
event?.preventDefault?.();
|
|
||||||
if (disabled) return;
|
|
||||||
stopZoom();
|
|
||||||
},
|
|
||||||
[disabled, stopZoom],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
/*
|
|
||||||
A touch surface can unmount during orientation changes or fullscreen
|
|
||||||
close while a pointer is still down. Explicitly clear zoom here because
|
|
||||||
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
|
||||||
*/
|
|
||||||
setCameraAxisIntent(0);
|
|
||||||
},
|
|
||||||
[setCameraAxisIntent],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
||||||
disabled={disabled}
|
|
||||||
onPointerDown={startZoom(-1)}
|
|
||||||
onPointerUp={stopFromPointer}
|
|
||||||
onPointerCancel={stopFromPointer}
|
|
||||||
onLostPointerCapture={stopFromPointer}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
Zoom out
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
||||||
disabled={disabled}
|
|
||||||
onPointerDown={startZoom(1)}
|
|
||||||
onPointerUp={stopFromPointer}
|
|
||||||
onPointerCancel={stopFromPointer}
|
|
||||||
onLostPointerCapture={stopFromPointer}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
Zoom in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control space-y-0.5">
|
|
||||||
<PtzMobileZoomButtons disabled={disabled} />
|
|
||||||
<div className="mobile-touch-control h-44 min-h-0">
|
|
||||||
{/*
|
|
||||||
Reuse the rover control pad so touch intent still enters the normal
|
|
||||||
control system. The PTZ adapter translates that same drive vector into
|
|
||||||
pan/tilt commands only while this user is the PTZ operator.
|
|
||||||
*/}
|
|
||||||
<ControlPadPanel compact disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzControlReference() {
|
|
||||||
const rows = [
|
|
||||||
['Tilt up', 'driveForward'],
|
|
||||||
['Tilt down', 'driveBackward'],
|
|
||||||
['Pan left', 'driveLeft'],
|
|
||||||
['Pan right', 'driveRight'],
|
|
||||||
['Zoom in', 'cameraUp'],
|
|
||||||
['Zoom out', 'cameraDown'],
|
|
||||||
['Spotlight', 'headlightToggle'],
|
|
||||||
['Infrared mode', 'laserToggle'],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
|
||||||
{rows.map(([label, actionId]) => (
|
|
||||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
|
||||||
<span className="text-slate-400">{label}</span>
|
|
||||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzPresetPanel({ ptz }) {
|
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
|
||||||
const {
|
|
||||||
ptzListPresets,
|
|
||||||
ptzGotoPreset,
|
|
||||||
ptzCreatePreset,
|
|
||||||
ptzRemovePreset,
|
|
||||||
} = useSessionActions();
|
|
||||||
const [name, setName] = useState('');
|
|
||||||
const [busy, setBusy] = useState('');
|
|
||||||
const presets = Array.isArray(ptz?.presets) ? ptz.presets : [];
|
|
||||||
const isPresetAdmin = role === 'admin' || role === 'lockdown';
|
|
||||||
const canCreatePreset = Boolean(ptz?.canUse);
|
|
||||||
const canMoveToPreset = Boolean(ptz?.isOperator);
|
|
||||||
|
|
||||||
const refreshPresets = async () => {
|
|
||||||
if (busy) return;
|
|
||||||
setBusy('refresh');
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
Presets live on the camera, not in browser state. A manual refresh gives
|
|
||||||
admins a simple recovery path if another admin or the camera's native
|
|
||||||
app changes preset storage while this UI is already open.
|
|
||||||
*/
|
|
||||||
await ptzListPresets();
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to refresh PTZ presets.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const goToPreset = async (preset) => {
|
|
||||||
if (!canMoveToPreset || busy || !preset?.token) return;
|
|
||||||
setBusy(`goto:${preset.token}`);
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
Moving to a preset is a physical camera move, so the server still checks
|
|
||||||
that this browser owns the active PTZ turn before accepting the command.
|
|
||||||
*/
|
|
||||||
await ptzGotoPreset({ token: preset.token });
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to move to PTZ preset.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const createPreset = async (event) => {
|
|
||||||
event.preventDefault();
|
|
||||||
const trimmed = name.trim();
|
|
||||||
if (!canCreatePreset || busy || !trimmed) return;
|
|
||||||
setBusy('create');
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
ONVIF setPreset stores the camera's current physical position. The UI
|
|
||||||
only sends the user's label; the server supplies the active profile
|
|
||||||
token so browser code does not need camera profile internals, and the
|
|
||||||
server still enforces the PTZ feature gate for raw socket callers.
|
|
||||||
*/
|
|
||||||
await ptzCreatePreset({ name: trimmed });
|
|
||||||
setName('');
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to create PTZ preset.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const removePreset = async (preset) => {
|
|
||||||
if (!isPresetAdmin || busy || !preset?.token) return;
|
|
||||||
const confirmed = window.confirm(`Remove preset "${preset.name}"?`);
|
|
||||||
if (!confirmed) return;
|
|
||||||
setBusy(`remove:${preset.token}`);
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
The token is the camera's durable preset identifier. Names are only UI
|
|
||||||
labels and may not be unique, so deletion always targets the token.
|
|
||||||
*/
|
|
||||||
await ptzRemovePreset({ token: preset.token });
|
|
||||||
} catch (err) {
|
|
||||||
alert(err.message || 'Failed to remove PTZ preset.');
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame
|
|
||||||
title="Position presets"
|
|
||||||
fillHeight
|
|
||||||
actions={(
|
|
||||||
<button type="button" className="button-dark text-xs" disabled={Boolean(busy)} onClick={refreshPresets}>
|
|
||||||
Refresh
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
bodyClassName="flex min-h-0 flex-col gap-1 p-1 text-xs"
|
|
||||||
>
|
|
||||||
{ptz?.presetsError ? (
|
|
||||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-amber-100">
|
|
||||||
{ptz.presetsError}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex min-h-0 flex-1 flex-wrap content-start items-start gap-0.5 overflow-y-auto">
|
|
||||||
{/*
|
|
||||||
Presets should behave like a compact pile of actions, not a table.
|
|
||||||
flex-wrap lets each preset keep its natural button width and only
|
|
||||||
starts a new visual line when the current line runs out of room.
|
|
||||||
*/}
|
|
||||||
{presets.length ? presets.map((preset) => {
|
|
||||||
const gotoBusy = busy === `goto:${preset.token}`;
|
|
||||||
const removeBusy = busy === `remove:${preset.token}`;
|
|
||||||
return (
|
|
||||||
<div key={preset.token} className="surface inline-flex max-w-full items-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-dark min-w-0 max-w-40 truncate text-left text-xs disabled:opacity-50"
|
|
||||||
disabled={!canMoveToPreset || Boolean(busy)}
|
|
||||||
onClick={() => goToPreset(preset)}
|
|
||||||
title={canMoveToPreset ? `Move to ${preset.name}` : 'Your PTZ turn must be active'}
|
|
||||||
>
|
|
||||||
{gotoBusy ? 'Moving...' : preset.name}
|
|
||||||
</button>
|
|
||||||
{isPresetAdmin ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-dark text-xs text-rose-200 disabled:opacity-50"
|
|
||||||
disabled={Boolean(busy)}
|
|
||||||
onClick={() => removePreset(preset)}
|
|
||||||
>
|
|
||||||
{removeBusy ? 'Removing...' : 'Remove'}
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}) : (
|
|
||||||
<div className="rounded-sm border border-slate-700 bg-black/30 p-2 text-center text-slate-400">
|
|
||||||
No presets saved.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{canCreatePreset ? (
|
|
||||||
<form className="grid grid-cols-[minmax(0,1fr)_auto] gap-1" onSubmit={createPreset}>
|
|
||||||
<input
|
|
||||||
className="field-input min-w-0 text-xs"
|
|
||||||
value={name}
|
|
||||||
maxLength={60}
|
|
||||||
disabled={Boolean(busy)}
|
|
||||||
onChange={(event) => setName(event.target.value)}
|
|
||||||
placeholder="Preset name"
|
|
||||||
/>
|
|
||||||
<button type="submit" className="button-dark text-xs" disabled={Boolean(busy) || !name.trim()}>
|
|
||||||
{busy === 'create' ? 'Saving...' : 'Save'}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
) : null}
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPtzTurnModel(ptz, selfId) {
|
|
||||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
|
||||||
const isActive = Boolean(ptz?.isOperator);
|
|
||||||
const isQueued = Boolean(ptz?.queuedPosition);
|
|
||||||
return {
|
|
||||||
enabled: Boolean(ptz && (isActive || isQueued || currentId)),
|
|
||||||
targetId: ptz?.id || PTZ_CAMERA_ID,
|
|
||||||
activeId: currentId,
|
|
||||||
nextId,
|
|
||||||
isActive,
|
|
||||||
isNext: Boolean(selfId && nextId === selfId),
|
|
||||||
deadline: ptz?.deadline || null,
|
|
||||||
idleDeadline: null,
|
|
||||||
showNotTurnNotice: Boolean(!isActive && (isQueued || currentId || queue.length)),
|
|
||||||
showPreviewReason: false,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMediaPane({ ptz, open, framed = true }) {
|
|
||||||
const isOperator = Boolean(ptz?.isOperator);
|
|
||||||
const isParticipant = Boolean(isOperator || ptz?.queuedPosition);
|
|
||||||
const nonTurnSnapshotsActive = useSessionSelector(
|
|
||||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
|
||||||
);
|
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
/*
|
|
||||||
PTZ has its own turn queue, so non-operators are the camera equivalent of a
|
|
||||||
non-active rover driver. The server enforces the same policy in
|
|
||||||
canRequestLiveVideo(); this branch only chooses the expected browser render
|
|
||||||
path and never unlocks movement controls.
|
|
||||||
*/
|
|
||||||
/*
|
|
||||||
A direct /ptz load renders before its automatic queue claim is acknowledged.
|
|
||||||
Do not mount the live player during that short pre-claim window: its first
|
|
||||||
token request would correctly be rejected, and PtzLiveVideo intentionally
|
|
||||||
treats authorization rejection as a terminal snapshot fallback. Once the
|
|
||||||
session confirms queue/operator membership, mounting the player creates a
|
|
||||||
fresh authorized request without changing shared retry or server policy.
|
|
||||||
*/
|
|
||||||
const shouldUseLiveVideo = isParticipant && (isOperator || !nonTurnSnapshotsActive);
|
|
||||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
|
||||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
|
||||||
const turnModel = useMemo(() => buildPtzTurnModel(ptz, selfId), [ptz, selfId]);
|
|
||||||
const media = (
|
|
||||||
<>
|
|
||||||
{shouldUseLiveVideo ? (
|
|
||||||
<PtzLiveVideo enabled={open} startMuted={false} />
|
|
||||||
) : (
|
|
||||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
|
||||||
)}
|
|
||||||
<TurnsOverlay turnModel={turnModel} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!framed) {
|
|
||||||
return <div className="relative h-full min-h-0 w-full overflow-hidden bg-black">{media}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full min-h-0 w-full items-center justify-center overflow-hidden bg-black">
|
|
||||||
<div className="relative aspect-video max-h-full w-full max-w-full overflow-hidden bg-black">
|
|
||||||
{media}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzDesktopFullscreen({ ptz, releasePending }) {
|
|
||||||
return (
|
|
||||||
<div className="grid h-full min-h-0 grid-rows-[minmax(0,1fr)_minmax(7rem,0.22fr)] gap-0.5 overflow-hidden p-0.5">
|
|
||||||
<div className="flex min-h-0 min-w-0 gap-0.5 overflow-hidden">
|
|
||||||
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
|
||||||
<PtzMediaPane ptz={ptz} open framed />
|
|
||||||
</main>
|
|
||||||
{/* Keep the sidebar itself transparent. Its child cards still own their dark surfaces,
|
|
||||||
while the shared PTZ page theme can show through the same compact gaps as the driver
|
|
||||||
layout instead of being covered by one solid sidebar rectangle. */}
|
|
||||||
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto text-sm">
|
|
||||||
<PtzQueueSummary ptz={ptz} />
|
|
||||||
{ptz?.isOperator ? (
|
|
||||||
<PtzLightingControls ptz={ptz} />
|
|
||||||
) : (
|
|
||||||
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
|
|
||||||
Live PTZ controls unlock when your camera turn is active.
|
|
||||||
</CardFrame>
|
|
||||||
)}
|
|
||||||
<PtzControlReference />
|
|
||||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
|
||||||
{/*
|
|
||||||
Desktop keeps room lights as the final sidebar tool so camera
|
|
||||||
turn controls and replay remain above the less-frequent room-wide
|
|
||||||
actions. HomeAssistantControls owns its own feature and policy gate.
|
|
||||||
*/}
|
|
||||||
<HomeAssistantControls />
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
<div className="grid min-h-0 grid-cols-[minmax(0,1.6fr)_minmax(16rem,0.7fr)] gap-0.5 overflow-hidden">
|
|
||||||
<ChatPanel fillHeight title="Chat" allowSpectatorInput inputTarget="overlay" />
|
|
||||||
<PtzPresetPanel ptz={ptz} />
|
|
||||||
</div>
|
|
||||||
{releasePending ? (
|
|
||||||
<div className="pointer-events-none absolute bottom-1 right-1 rounded-sm bg-black/80 px-2 py-1 text-xs text-slate-200">
|
|
||||||
Closing...
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileLandscape({ ptz, onClose, releasePending = false }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
|
||||||
{/*
|
|
||||||
Landscape intentionally retains one control column beside the video.
|
|
||||||
This is the established PTZ interaction and avoids forcing rover-style
|
|
||||||
left/right columns onto a camera that has a smaller control inventory.
|
|
||||||
*/}
|
|
||||||
<section className="mobile-touch-control grid min-h-[calc(100dvh-0.25rem)] shrink-0 grid-cols-[minmax(0,1fr)_13rem] items-start gap-0.5">
|
|
||||||
{/*
|
|
||||||
The video keeps one viewport of height, but the grid row is allowed to
|
|
||||||
grow when the control column is taller. That makes the sidebar's tail
|
|
||||||
extend below the video instead of forcing it into a nested scroller.
|
|
||||||
*/}
|
|
||||||
<div className="min-w-0 space-y-0.5">
|
|
||||||
<main className="relative h-[calc(100dvh-0.25rem)] min-h-0 overflow-hidden bg-black">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
|
||||||
disabled={releasePending}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
|
||||||
</main>
|
|
||||||
{/*
|
|
||||||
The right control column is naturally taller than the viewport.
|
|
||||||
Placing room lights after the fixed-height video uses that left-
|
|
||||||
column space while the whole landscape page continues scrolling as
|
|
||||||
one surface.
|
|
||||||
*/}
|
|
||||||
<HomeAssistantControls />
|
|
||||||
</div>
|
|
||||||
{/*
|
|
||||||
Do not put overflow scrolling on this column. The surrounding PTZ
|
|
||||||
landscape content is the single page scroller, so a swipe over either
|
|
||||||
the video area or these controls advances the same document flow.
|
|
||||||
*/}
|
|
||||||
<aside className="min-h-0 space-y-0.5">
|
|
||||||
{/*
|
|
||||||
Landscape keeps all turn-critical controls in its one existing
|
|
||||||
sidebar. Queue position belongs first so the operator can confirm
|
|
||||||
control ownership before touching the camera, while replay follows
|
|
||||||
the lighting buttons because it is the next secondary action in
|
|
||||||
the same scroll column.
|
|
||||||
*/}
|
|
||||||
<PtzQueueSummary ptz={ptz} />
|
|
||||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
|
||||||
<ReplaySourcesPanel
|
|
||||||
panelId="ptz-controller-replay-mobile-landscape"
|
|
||||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
|
||||||
/>
|
|
||||||
</aside>
|
|
||||||
</section>
|
|
||||||
<section className="grid gap-0.5 md:grid-cols-[minmax(0,1fr)_minmax(0,0.7fr)]">
|
|
||||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
|
||||||
<PtzPresetPanel ptz={ptz} />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobilePortrait({ ptz, onClose, releasePending = false }) {
|
|
||||||
return (
|
|
||||||
<div className="flex min-h-0 flex-1 flex-col gap-0.5 overflow-y-auto p-0.5">
|
|
||||||
<main className="relative aspect-video min-h-0 shrink-0 overflow-hidden bg-black">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute left-1 top-1 z-50 rounded-sm border border-white/40 bg-black/80 px-2 py-1 text-xs font-semibold text-white shadow-sm disabled:opacity-50"
|
|
||||||
disabled={releasePending}
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
<PtzMediaPane ptz={ptz} open framed={false} />
|
|
||||||
</main>
|
|
||||||
{/*
|
|
||||||
Portrait gives the video its full available width and places controls
|
|
||||||
below it. Reusing the landscape sidebar width here was the source of the
|
|
||||||
cramped portrait presentation, while the controls themselves remain the
|
|
||||||
same shared PTZ controls used in landscape.
|
|
||||||
*/}
|
|
||||||
<section className="mobile-touch-control">
|
|
||||||
<PtzMobileControlsPanel ptz={ptz} disabled={!ptz?.isOperator} />
|
|
||||||
</section>
|
|
||||||
<section className="space-y-0.5">
|
|
||||||
{/*
|
|
||||||
Replay and presets are compact secondary actions, so portrait places
|
|
||||||
them in one equal-width row before the full-width queue and chat. The
|
|
||||||
explicit two-column grid keeps this arrangement local to portrait and
|
|
||||||
leaves the desktop and one-column landscape compositions unchanged.
|
|
||||||
*/}
|
|
||||||
<div className="grid grid-cols-2 items-start gap-0.5">
|
|
||||||
<ReplaySourcesPanel
|
|
||||||
panelId="ptz-controller-replay-mobile-portrait"
|
|
||||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
|
||||||
/>
|
|
||||||
<PtzPresetPanel ptz={ptz} />
|
|
||||||
</div>
|
|
||||||
<PtzQueueSummary ptz={ptz} />
|
|
||||||
<ChatPanel title="Chat" allowSpectatorInput inputTarget="overlay" />
|
|
||||||
{/* Portrait keeps room lights immediately after chat as requested. */}
|
|
||||||
<HomeAssistantControls />
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PtzControllerPage({ layout = 'desktop' }) {
|
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
|
||||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
|
||||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
|
||||||
const { ptzClaim, ptzRelease, pushAlert } = useSessionActions();
|
|
||||||
const { stopAllMotion } = useControlActions();
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [releasePending, setReleasePending] = useState(false);
|
|
||||||
const autoClaimSocketRef = useRef(null);
|
|
||||||
const routeExitReleaseTimerRef = useRef(null);
|
|
||||||
const participantRef = useRef(false);
|
|
||||||
const closingThroughButtonRef = useRef(false);
|
|
||||||
const { value: pageSettings } = useSettingsNamespace('page', {
|
|
||||||
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
|
||||||
});
|
|
||||||
const isMobile = layout !== 'desktop';
|
|
||||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
|
||||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
|
||||||
// PTZ is a separate route but shares the browser's page settings. Applying the catalog class to
|
|
||||||
// its body surface exposes the theme only through layout padding and card gaps; camera pixels,
|
|
||||||
// controls, and card interiors retain their purpose-built dark backgrounds.
|
|
||||||
const pageBackgroundClass = usePageThemeClass(pageSettings?.backgroundTheme);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Route-exit cleanup runs after the last render, so retain the latest
|
|
||||||
// server-confirmed membership without making the lifecycle effect resubscribe.
|
|
||||||
participantRef.current = isParticipant;
|
|
||||||
}, [isParticipant]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (routeExitReleaseTimerRef.current) {
|
|
||||||
clearTimeout(routeExitReleaseTimerRef.current);
|
|
||||||
routeExitReleaseTimerRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (!participantRef.current || closingThroughButtonRef.current) return;
|
|
||||||
/*
|
|
||||||
Browser Back and route navigation unmount the PTZ page without invoking
|
|
||||||
its Close button. Defer release by one task so React Strict Mode's
|
|
||||||
development-only cleanup/remount cycle can cancel it in the next setup;
|
|
||||||
a real route exit has no replacement setup, so membership is released.
|
|
||||||
|
|
||||||
This is intentionally membership-gated. An admin release command can
|
|
||||||
revoke the current operator even when the admin is not that operator,
|
|
||||||
so an admin merely visiting/leaving a disabled or unjoined page must not
|
|
||||||
emit a release command.
|
|
||||||
*/
|
|
||||||
routeExitReleaseTimerRef.current = setTimeout(() => {
|
|
||||||
routeExitReleaseTimerRef.current = null;
|
|
||||||
ptzRelease().catch(() => {});
|
|
||||||
}, 0);
|
|
||||||
};
|
|
||||||
}, [ptzRelease]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!featureEnabled || !ptz || !socketId || !canUse) return undefined;
|
|
||||||
|
|
||||||
if (ptz.isOperator || ptz.queuedPosition) {
|
|
||||||
/*
|
|
||||||
Navigation from the driver queue normally arrives with membership
|
|
||||||
already established. Mark this socket complete so later session syncs
|
|
||||||
cannot turn that normal route transition into another claim request.
|
|
||||||
*/
|
|
||||||
autoClaimSocketRef.current = socketId;
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (autoClaimSocketRef.current === socketId) return undefined;
|
|
||||||
autoClaimSocketRef.current = socketId;
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
/*
|
|
||||||
A direct /ptz load still receives the ordinary user role first, which can
|
|
||||||
briefly assign a rover. Claiming through the existing server action is
|
|
||||||
deliberate: ptzCameraService releases that rover ownership before it
|
|
||||||
activates or queues this socket, keeping one authoritative transition.
|
|
||||||
|
|
||||||
The socket-keyed ref suppresses repeats caused by session updates and
|
|
||||||
React's development effect replay. The server claim is also idempotent for
|
|
||||||
an existing operator/queue member, which covers an acknowledgement racing
|
|
||||||
with a fresh public-state sync.
|
|
||||||
*/
|
|
||||||
ptzClaim().catch((err) => {
|
|
||||||
if (!active) return;
|
|
||||||
pushAlert({
|
|
||||||
id: `ptz-auto-claim-${socketId}`,
|
|
||||||
title: 'PTZ camera',
|
|
||||||
message: err?.message || 'Unable to join the PTZ queue.',
|
|
||||||
color: '#f59e0b',
|
|
||||||
lifetimeMs: 6000,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
// Do not emit or update UI from a rejected request after this route has
|
|
||||||
// unmounted; the server still owns completion of any request in flight.
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [canUse, featureEnabled, ptz, ptzClaim, pushAlert, socketId]);
|
|
||||||
|
|
||||||
const releaseAndClose = useCallback(async () => {
|
|
||||||
if (releasePending) return;
|
|
||||||
setReleasePending(true);
|
|
||||||
closingThroughButtonRef.current = true;
|
|
||||||
try {
|
|
||||||
/*
|
|
||||||
Stop first so a held key/pointer cannot leave ONVIF continuous movement
|
|
||||||
running while the server removes this socket from the PTZ queue.
|
|
||||||
*/
|
|
||||||
stopAllMotion?.();
|
|
||||||
if (ptz?.isOperator || ptz?.queuedPosition) {
|
|
||||||
await ptzRelease();
|
|
||||||
}
|
|
||||||
navigate('/');
|
|
||||||
} catch (err) {
|
|
||||||
// A rejected manual release leaves the route mounted, so route-exit
|
|
||||||
// cleanup must remain armed for a later Back/navigation attempt.
|
|
||||||
closingThroughButtonRef.current = false;
|
|
||||||
throw err;
|
|
||||||
} finally {
|
|
||||||
setReleasePending(false);
|
|
||||||
}
|
|
||||||
}, [navigate, ptz?.isOperator, ptz?.queuedPosition, ptzRelease, releasePending, stopAllMotion]);
|
|
||||||
|
|
||||||
if (!featureEnabled) {
|
|
||||||
return (
|
|
||||||
<main className={`flex min-h-dvh items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
|
|
||||||
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
|
||||||
<p>The PTZ camera is not available.</p>
|
|
||||||
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
|
||||||
</CardFrame>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className={`h-dvh w-full overflow-hidden text-slate-100 ${pageBackgroundClass}`}>
|
|
||||||
{/* The fullscreen CardFrame remains the structural shell. Painting its otherwise
|
|
||||||
transparent body is what lets every desktop and mobile PTZ composition share one
|
|
||||||
continuous pattern without threading theme props into each individual child panel. */}
|
|
||||||
<CardFrame
|
|
||||||
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
|
||||||
actions={isMobile ? null : (
|
|
||||||
<button type="button" className="button-dark text-xs" disabled={releasePending} onClick={releaseAndClose}>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
hideHeader={isMobile}
|
|
||||||
fillHeight
|
|
||||||
clipOverflow={false}
|
|
||||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
|
||||||
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
|
|
||||||
>
|
|
||||||
{isMobile ? (
|
|
||||||
layout === 'mobile-landscape' ? (
|
|
||||||
<PtzMobileLandscape ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
|
||||||
) : (
|
|
||||||
<PtzMobilePortrait ptz={ptz} onClose={releaseAndClose} releasePending={releasePending} />
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<PtzDesktopFullscreen ptz={ptz} releasePending={releasePending} />
|
|
||||||
)}
|
|
||||||
</CardFrame>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PtzQueueCard() {
|
export default function PtzQueueCard() {
|
||||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const { ptzClaim, ptzRelease } = useSessionActions();
|
const { setOperatingMode } = useSessionActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const lookupUser = usePtzQueueLookup(ptz);
|
const now = useSharedClock(1000, Boolean(ptz?.turn?.deadline));
|
||||||
const { queue, currentId, nextId } = normalizePtzQueue(ptz);
|
|
||||||
const now = useSharedClock(1000, Boolean(ptz?.deadline));
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
const canUse = Boolean(ptz?.permissions?.canEnter);
|
||||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
const isParticipant = Boolean(ptz?.turn?.ownsControl || ptz?.turn?.turnsAhead);
|
||||||
const timerLabel = ptz?.isOperator && ptz?.deadline ? `${formatRemaining(ptz.deadline, now)} left` : '';
|
const timerLabel = ptz?.turn?.ownsControl && ptz?.turn?.deadline ? `${formatRemaining(ptz.turn.deadline, now)} left` : '';
|
||||||
|
|
||||||
if (!featureEnabled) return null;
|
if (!featureEnabled) return null;
|
||||||
|
|
||||||
@@ -881,13 +39,9 @@ export default function PtzQueueCard() {
|
|||||||
}
|
}
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
const response = await ptzClaim();
|
const response = await setOperatingMode('ptz');
|
||||||
/*
|
// Enter first; the page requests its initial turn after navigation.
|
||||||
The server is authoritative for whether the click became an active turn
|
if (response?.operatingMode === 'ptz') {
|
||||||
or a queued wait. Open only after it confirms one of those states so a
|
|
||||||
dock-guard rejection does not strand the user in fullscreen.
|
|
||||||
*/
|
|
||||||
if (response?.state?.isOperator || response?.state?.queuedPosition) {
|
|
||||||
navigate('/ptz');
|
navigate('/ptz');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -901,7 +55,7 @@ export default function PtzQueueCard() {
|
|||||||
if (pending) return;
|
if (pending) return;
|
||||||
setPending(true);
|
setPending(true);
|
||||||
try {
|
try {
|
||||||
await ptzRelease();
|
await setOperatingMode('rover');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(err.message || 'Failed to leave PTZ camera.');
|
alert(err.message || 'Failed to leave PTZ camera.');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -909,13 +63,7 @@ export default function PtzQueueCard() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const actionLabel = pending
|
const actionLabel = pending ? '...' : isParticipant ? 'Open' : 'request';
|
||||||
? '...'
|
|
||||||
: ptz?.isOperator
|
|
||||||
? 'Open'
|
|
||||||
: ptz?.queuedPosition
|
|
||||||
? 'Open'
|
|
||||||
: 'request';
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
<CardFrame title={ptz?.name || 'PTZ camera'} bodyClassName="relative space-y-0.5 text-sm">
|
||||||
@@ -925,22 +73,14 @@ export default function PtzQueueCard() {
|
|||||||
id: ptz?.id || PTZ_CAMERA_ID,
|
id: ptz?.id || PTZ_CAMERA_ID,
|
||||||
name: ptz?.name || 'PTZ Camera',
|
name: ptz?.name || 'PTZ Camera',
|
||||||
color: ptz?.color || PTZ_DEFAULT_COLOR,
|
color: ptz?.color || PTZ_DEFAULT_COLOR,
|
||||||
// description: ptz?.isOperator
|
|
||||||
// ? 'Live camera turn active'
|
|
||||||
// : ptz?.queuedPosition
|
|
||||||
// ? `Queue position ${ptz.queuedPosition}`
|
|
||||||
// : 'Pan, tilt, and zoom camera',
|
|
||||||
}}
|
}}
|
||||||
queue={queue}
|
turn={ptz?.turn}
|
||||||
currentId={currentId}
|
|
||||||
nextId={nextId}
|
|
||||||
selfId={selfId}
|
selfId={selfId}
|
||||||
lookupUser={lookupUser}
|
|
||||||
canClick={canUse && !pending}
|
canClick={canUse && !pending}
|
||||||
pending={pending}
|
pending={pending}
|
||||||
buttonLabel={actionLabel}
|
buttonLabel={actionLabel}
|
||||||
batteryLabel={ptz?.isOperator ? 'LIVE' : ptz?.queuedPosition ? `#${ptz.queuedPosition}` : '--'}
|
batteryLabel={ptz?.turn?.ownsControl ? 'LIVE' : ptz?.turn?.turnsAhead ? `#${ptz.turn.turnsAhead}` : '--'}
|
||||||
batteryClassName={ptz?.isOperator ? 'text-emerald-300' : ptz?.queuedPosition ? 'text-sky-300' : 'text-slate-400'}
|
batteryClassName={ptz?.turn?.ownsControl ? 'text-emerald-300' : ptz?.turn?.turnsAhead ? 'text-sky-300' : 'text-slate-400'}
|
||||||
timerLabel={timerLabel}
|
timerLabel={timerLabel}
|
||||||
onRequest={handleRequest}
|
onRequest={handleRequest}
|
||||||
showAction={canUse}
|
showAction={canUse}
|
||||||
|
|||||||
@@ -1,15 +1,14 @@
|
|||||||
// PTZ Live Video
|
// PTZ Live Video
|
||||||
// Purpose: Plays the single PTZ camera WHEP stream with the same fresh-session retry loop used by rover video.
|
// Purpose: Plays the single PTZ camera WHEP stream with the same fresh-session retry loop used by rover video.
|
||||||
// Scope: Owns browser-side WHEP playback/retry only; server authorization and snapshot fallback policy stay outside.
|
// Scope: Owns browser-side WHEP playback/retry only; server authorization and snapshot fallback policy stay outside.
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
import { useVideoRequests } from '../../hooks/useVideoRequests.js';
|
||||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||||
import { RESTART_DELAY_MS } from '../RoverMediaPlayer/constants.js';
|
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||||
|
|
||||||
export const PTZ_CAMERA_ID = 'ptz-camera';
|
export const PTZ_CAMERA_ID = 'ptz-camera';
|
||||||
|
|
||||||
const PTZ_AUDIO_RETRY_MS = 1000;
|
const PTZ_AUDIO_RETRY_MS = 1000;
|
||||||
const TERMINAL_WHEP_STATES = new Set(['error', 'failed', 'disconnected', 'closed']);
|
|
||||||
const AUTHORIZATION_ERROR_RE = /not authorized/i;
|
const AUTHORIZATION_ERROR_RE = /not authorized/i;
|
||||||
|
|
||||||
function isAuthorizationError(error) {
|
function isAuthorizationError(error) {
|
||||||
@@ -25,14 +24,10 @@ export default function PtzLiveVideo({
|
|||||||
fallback = null,
|
fallback = null,
|
||||||
}) {
|
}) {
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const retryTimerRef = useRef(null);
|
|
||||||
const playTimerRef = useRef(null);
|
const playTimerRef = useRef(null);
|
||||||
const enabledRef = useRef(enabled);
|
|
||||||
const fallbackRef = useRef(false);
|
|
||||||
const playerGenerationRef = useRef(0);
|
|
||||||
const [status, setStatus] = useState('idle');
|
const [status, setStatus] = useState('idle');
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [restartToken, setRestartToken] = useState(0);
|
const { restartToken, scheduleRestart, cancelRestart } = useWhepRestart(enabled);
|
||||||
const sources = useVideoRequests(
|
const sources = useVideoRequests(
|
||||||
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
|
[{ type: 'ptz', id: PTZ_CAMERA_ID, key: PTZ_CAMERA_ID }],
|
||||||
{ enabled, version: restartToken },
|
{ enabled, version: restartToken },
|
||||||
@@ -41,100 +36,34 @@ export default function PtzLiveVideo({
|
|||||||
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
|
const shouldUseFallback = Boolean(source?.error && isAuthorizationError(source.error));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
enabledRef.current = enabled;
|
// An authorization denial switches to snapshots and cancels any queued retry.
|
||||||
}, [enabled]);
|
if (shouldUseFallback) cancelRestart();
|
||||||
|
}, [shouldUseFallback, cancelRestart]);
|
||||||
useEffect(() => {
|
|
||||||
fallbackRef.current = shouldUseFallback;
|
|
||||||
}, [shouldUseFallback]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (enabled && !shouldUseFallback) return undefined;
|
|
||||||
/*
|
|
||||||
A retry that was scheduled before the server denied live access should not
|
|
||||||
keep firing in snapshot mode. Clear it when live playback is no longer the
|
|
||||||
active display policy.
|
|
||||||
*/
|
|
||||||
if (retryTimerRef.current) {
|
|
||||||
clearTimeout(retryTimerRef.current);
|
|
||||||
retryTimerRef.current = null;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}, [enabled, shouldUseFallback]);
|
|
||||||
|
|
||||||
const scheduleRestart = useCallback(() => {
|
|
||||||
if (!enabledRef.current || fallbackRef.current) return;
|
|
||||||
/*
|
|
||||||
WHEP sessions are one-shot browser/server negotiations. When the camera
|
|
||||||
reboots, the old PeerConnection and token can look alive enough to keep a
|
|
||||||
black element on screen, but they are not useful anymore. Bumping this
|
|
||||||
token forces useVideoRequests to ask the server for a new MediaMTX auth
|
|
||||||
session before creating the next WhepPlayer.
|
|
||||||
*/
|
|
||||||
clearTimeout(retryTimerRef.current);
|
|
||||||
retryTimerRef.current = setTimeout(() => {
|
|
||||||
retryTimerRef.current = null;
|
|
||||||
setRestartToken(Date.now());
|
|
||||||
}, RESTART_DELAY_MS);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
if (!enabled || shouldUseFallback || !source?.url || !videoRef.current) return undefined;
|
||||||
let active = true;
|
const video = videoRef.current;
|
||||||
const generation = playerGenerationRef.current + 1;
|
const stop = startWhepPlayback({
|
||||||
playerGenerationRef.current = generation;
|
|
||||||
const player = new WhepPlayer({
|
|
||||||
url: source.url,
|
url: source.url,
|
||||||
token: source.token,
|
token: source.token,
|
||||||
video: videoRef.current,
|
video: videoRef.current,
|
||||||
startMuted,
|
startMuted,
|
||||||
|
scheduleRestart,
|
||||||
onStatus: (nextStatus, info) => {
|
onStatus: (nextStatus, info) => {
|
||||||
/*
|
|
||||||
Old PeerConnection callbacks can arrive after React has already
|
|
||||||
cleaned up this effect for a newer token. Only the currently-owned
|
|
||||||
generation is allowed to update status or schedule another restart.
|
|
||||||
*/
|
|
||||||
if (!active || playerGenerationRef.current !== generation) return;
|
|
||||||
const normalized = String(nextStatus || '').toLowerCase();
|
|
||||||
setStatus(nextStatus || 'unknown');
|
setStatus(nextStatus || 'unknown');
|
||||||
setDetail(info || null);
|
setDetail(info || null);
|
||||||
if (TERMINAL_WHEP_STATES.has(normalized)) {
|
|
||||||
scheduleRestart();
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
onError: (err) => {
|
||||||
|
|
||||||
player.start().catch((err) => {
|
|
||||||
if (!active || playerGenerationRef.current !== generation) return;
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setDetail(err.message || 'WHEP start failed');
|
setDetail(err.message || 'WHEP start failed');
|
||||||
scheduleRestart();
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
|
||||||
/*
|
|
||||||
Mark inactive before stop() because WhepPlayer reports "stopped" during
|
|
||||||
normal cleanup. Cleanup-driven stops should not immediately schedule the
|
|
||||||
next retry; only the replacement effect should own the new connection.
|
|
||||||
*/
|
|
||||||
active = false;
|
|
||||||
player.stop();
|
|
||||||
};
|
|
||||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const video = videoRef.current;
|
|
||||||
if (!enabled || shouldUseFallback || !source?.url || !video) return undefined;
|
|
||||||
const generation = playerGenerationRef.current;
|
|
||||||
|
|
||||||
const handleEnded = () => {
|
const handleEnded = () => {
|
||||||
if (playerGenerationRef.current !== generation) return;
|
|
||||||
setStatus('stopped');
|
setStatus('stopped');
|
||||||
setDetail('ended');
|
setDetail('ended');
|
||||||
scheduleRestart();
|
scheduleRestart();
|
||||||
};
|
};
|
||||||
const handleError = () => {
|
const handleError = () => {
|
||||||
if (playerGenerationRef.current !== generation) return;
|
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setDetail(video.error?.message || 'video element error');
|
setDetail(video.error?.message || 'video element error');
|
||||||
scheduleRestart();
|
scheduleRestart();
|
||||||
@@ -145,8 +74,9 @@ export default function PtzLiveVideo({
|
|||||||
return () => {
|
return () => {
|
||||||
video.removeEventListener('ended', handleEnded);
|
video.removeEventListener('ended', handleEnded);
|
||||||
video.removeEventListener('error', handleError);
|
video.removeEventListener('error', handleError);
|
||||||
|
stop();
|
||||||
};
|
};
|
||||||
}, [enabled, scheduleRestart, shouldUseFallback, source?.url]);
|
}, [enabled, scheduleRestart, shouldUseFallback, source?.token, source?.url, startMuted]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
@@ -178,7 +108,6 @@ export default function PtzLiveVideo({
|
|||||||
}, [enabled, shouldUseFallback, source?.url, startMuted, status]);
|
}, [enabled, shouldUseFallback, source?.url, startMuted, status]);
|
||||||
|
|
||||||
useEffect(() => () => {
|
useEffect(() => () => {
|
||||||
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
|
|
||||||
if (playTimerRef.current) clearInterval(playTimerRef.current);
|
if (playTimerRef.current) clearInterval(playTimerRef.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|||||||
@@ -21,36 +21,20 @@ function roleColors(role) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatQueueUserLabel(user, selfId) {
|
|
||||||
/*
|
|
||||||
Queue chips need to be readable even when a socket has no nickname yet.
|
|
||||||
Keeping the socket-prefix fallback here means rover queues and PTZ queues
|
|
||||||
degrade identically instead of each target inventing its own anonymous label.
|
|
||||||
*/
|
|
||||||
if (!user) return '';
|
|
||||||
const base = user.nickname || user.label || user.socketId?.slice(0, 6) || 'unknown';
|
|
||||||
if (user.socketId && user.socketId === selfId) {
|
|
||||||
return `${base} (you)`;
|
|
||||||
}
|
|
||||||
return base;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QueueUserChips({
|
export function QueueUserChips({
|
||||||
targetId,
|
targetId,
|
||||||
queue = [],
|
turn,
|
||||||
currentId = null,
|
|
||||||
nextId = null,
|
|
||||||
selfId = null,
|
selfId = null,
|
||||||
lookupUser,
|
|
||||||
}) {
|
}) {
|
||||||
if (!queue.length) {
|
const { queue = [], currentId, nextId } = turn || {};
|
||||||
|
if (!queue?.length) {
|
||||||
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
|
return <p className="text-[0.7rem] text-slate-500">No queue.</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{queue.map((socketId, idx) => {
|
{queue.map((user, idx) => {
|
||||||
const user = lookupUser?.(socketId) || { socketId, nickname: null, role: null };
|
const { socketId } = user;
|
||||||
const isCurrent = socketId === currentId;
|
const isCurrent = socketId === currentId;
|
||||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||||
/*
|
/*
|
||||||
@@ -69,7 +53,7 @@ export function QueueUserChips({
|
|||||||
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
|
className={`flex items-center gap-0.5 rounded-sm px-1 text-[0.7rem] ${highlightClass}`}
|
||||||
>
|
>
|
||||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||||
{formatQueueUserLabel(user, selfId)}
|
{user.name}{socketId === selfId ? ' (you)' : ''}
|
||||||
</span>
|
</span>
|
||||||
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
{isCurrent && <span className="text-[0.65rem] text-slate-200">now</span>}
|
||||||
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
{isNext && <span className="text-[0.65rem] text-emerald-100">next</span>}
|
||||||
@@ -82,11 +66,8 @@ export function QueueUserChips({
|
|||||||
|
|
||||||
export default function QueueTargetRow({
|
export default function QueueTargetRow({
|
||||||
target,
|
target,
|
||||||
queue = [],
|
turn,
|
||||||
currentId = null,
|
|
||||||
nextId = null,
|
|
||||||
selfId = null,
|
selfId = null,
|
||||||
lookupUser,
|
|
||||||
canClick = false,
|
canClick = false,
|
||||||
pending = false,
|
pending = false,
|
||||||
locked = false,
|
locked = false,
|
||||||
@@ -164,11 +145,8 @@ export default function QueueTargetRow({
|
|||||||
</div>
|
</div>
|
||||||
<QueueUserChips
|
<QueueUserChips
|
||||||
targetId={targetId}
|
targetId={targetId}
|
||||||
queue={queue}
|
turn={turn}
|
||||||
currentId={currentId}
|
|
||||||
nextId={nextId}
|
|
||||||
selfId={selfId}
|
selfId={selfId}
|
||||||
lookupUser={lookupUser}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{showAction ? (
|
{showAction ? (
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
// constants
|
// constants
|
||||||
// Purpose: Defines the constants module and the local helpers/components used in this file.
|
// Purpose: Defines the constants module and the local helpers/components used in this file.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||||
export const RESTART_DELAY_MS = 2000;
|
|
||||||
export const UNMUTE_RETRY_MS = 3000;
|
export const UNMUTE_RETRY_MS = 3000;
|
||||||
export const AUDIO_RETRY_MS = 3000;
|
export const AUDIO_RETRY_MS = 3000;
|
||||||
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
export const BRUSH_CURRENT_THRESHOLD_MA = 40;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { WhepPlayer } from '../../lib/whepPlayer.js';
|
import { startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||||
|
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||||
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../../context/TelemetryContext.jsx';
|
||||||
import {
|
import {
|
||||||
mainBrushAudioTelemetryEqual,
|
mainBrushAudioTelemetryEqual,
|
||||||
@@ -11,7 +12,6 @@ import { useRoverSnapshots } from '../../hooks/useRoverSnapshots.js';
|
|||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
import {
|
import {
|
||||||
RESTART_DELAY_MS,
|
|
||||||
UNMUTE_RETRY_MS,
|
UNMUTE_RETRY_MS,
|
||||||
AUDIO_RETRY_MS,
|
AUDIO_RETRY_MS,
|
||||||
BRUSH_CURRENT_THRESHOLD_MA,
|
BRUSH_CURRENT_THRESHOLD_MA,
|
||||||
@@ -212,10 +212,10 @@ export default function RoverMediaPlayer({
|
|||||||
mainBrushOvercurrent: Boolean(sensors?.wheelOvercurrents?.mainBrush),
|
mainBrushOvercurrent: Boolean(sensors?.wheelOvercurrents?.mainBrush),
|
||||||
}
|
}
|
||||||
: mainBrushTelemetry;
|
: mainBrushTelemetry;
|
||||||
|
const { restartToken, scheduleRestart } = useWhepRestart();
|
||||||
|
const { restartToken: audioRestartToken, scheduleRestart: scheduleAudioRestart } = useWhepRestart();
|
||||||
const videoRef = useRef(null);
|
const videoRef = useRef(null);
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
const restartTimer = useRef(null);
|
|
||||||
const audioRestartTimer = useRef(null);
|
|
||||||
const audioPlayInterval = useRef(null);
|
const audioPlayInterval = useRef(null);
|
||||||
const unmuteTimer = useRef(null);
|
const unmuteTimer = useRef(null);
|
||||||
const volumeFadeFrameRef = useRef(null);
|
const volumeFadeFrameRef = useRef(null);
|
||||||
@@ -224,8 +224,6 @@ export default function RoverMediaPlayer({
|
|||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [audioStatus, setAudioStatus] = useState('idle');
|
const [audioStatus, setAudioStatus] = useState('idle');
|
||||||
const [audioDetail, setAudioDetail] = useState(null);
|
const [audioDetail, setAudioDetail] = useState(null);
|
||||||
const [restartToken, setRestartToken] = useState(0);
|
|
||||||
const [audioRestartToken, setAudioRestartToken] = useState(0);
|
|
||||||
const [muted, setMuted] = useState(true);
|
const [muted, setMuted] = useState(true);
|
||||||
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
|
const hasDedicatedAudio = Boolean(resolvedAudioSessionInfo?.url);
|
||||||
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
|
const usingSnapshot = videoMode === 'snapshot' || (!videoMode && !resolvedSessionInfo?.url);
|
||||||
@@ -341,15 +339,6 @@ export default function RoverMediaPlayer({
|
|||||||
logAudio('settings/update');
|
logAudio('settings/update');
|
||||||
}, [logAudio]);
|
}, [logAudio]);
|
||||||
|
|
||||||
const scheduleRestart = useCallback(() => {
|
|
||||||
clearTimeout(restartTimer.current);
|
|
||||||
restartTimer.current = setTimeout(() => setRestartToken(Date.now()), RESTART_DELAY_MS);
|
|
||||||
}, []);
|
|
||||||
const scheduleAudioRestart = useCallback(() => {
|
|
||||||
clearTimeout(audioRestartTimer.current);
|
|
||||||
audioRestartTimer.current = setTimeout(() => setAudioRestartToken(Date.now()), RESTART_DELAY_MS);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const ensurePlayback = useCallback(async () => {
|
const ensurePlayback = useCallback(async () => {
|
||||||
const video = videoRef.current;
|
const video = videoRef.current;
|
||||||
if (!video) return;
|
if (!video) return;
|
||||||
@@ -397,8 +386,6 @@ export default function RoverMediaPlayer({
|
|||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
clearTimeout(restartTimer.current);
|
|
||||||
clearTimeout(audioRestartTimer.current);
|
|
||||||
clearTimeout(unmuteTimer.current);
|
clearTimeout(unmuteTimer.current);
|
||||||
clearInterval(audioPlayInterval.current);
|
clearInterval(audioPlayInterval.current);
|
||||||
if (volumeFadeFrameRef.current) {
|
if (volumeFadeFrameRef.current) {
|
||||||
@@ -426,41 +413,27 @@ export default function RoverMediaPlayer({
|
|||||||
if (!isVideoVisible || usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
|
if (!isVideoVisible || usingSnapshot || !resolvedSessionInfo?.url || !videoRef.current) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let active = true;
|
|
||||||
let player;
|
|
||||||
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
const resetMuteId = setTimeout(() => setMuted(true), 0);
|
||||||
const handleStatus = (nextStatus, info) => {
|
const stop = startWhepPlayback({
|
||||||
if (!active) return;
|
|
||||||
logAudio('video/status', { nextStatus, info: info || null });
|
|
||||||
setStatus(nextStatus);
|
|
||||||
setDetail(info || null);
|
|
||||||
if (nextStatus === 'playing') {
|
|
||||||
ensurePlayback();
|
|
||||||
}
|
|
||||||
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
|
|
||||||
scheduleRestart();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
player = new WhepPlayer({
|
|
||||||
url: resolvedSessionInfo.url,
|
url: resolvedSessionInfo.url,
|
||||||
token: resolvedSessionInfo.token,
|
token: resolvedSessionInfo.token,
|
||||||
video: videoRef.current,
|
video: videoRef.current,
|
||||||
receiveAudio: !hasDedicatedAudio,
|
receiveAudio: !hasDedicatedAudio,
|
||||||
onStatus: handleStatus,
|
scheduleRestart,
|
||||||
});
|
onStatus: (nextStatus, info) => {
|
||||||
|
logAudio('video/status', { nextStatus, info: info || null });
|
||||||
player.start().catch((err) => {
|
setStatus(nextStatus);
|
||||||
if (!active) return;
|
setDetail(info || null);
|
||||||
|
if (nextStatus === 'playing') ensurePlayback();
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
setStatus('error');
|
setStatus('error');
|
||||||
setDetail(err.message);
|
setDetail(err.message);
|
||||||
scheduleRestart();
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
active = false;
|
|
||||||
clearTimeout(resetMuteId);
|
clearTimeout(resetMuteId);
|
||||||
player?.stop();
|
stop();
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
usingSnapshot,
|
usingSnapshot,
|
||||||
@@ -536,37 +509,24 @@ export default function RoverMediaPlayer({
|
|||||||
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
|
if (!resolvedAudioSessionInfo?.url || !audioRef.current) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
let active = true;
|
return startWhepPlayback({
|
||||||
let player;
|
|
||||||
const handleStatus = (nextStatus, info) => {
|
|
||||||
if (!active) return;
|
|
||||||
logAudio('audio/status', { nextStatus, info: info || null });
|
|
||||||
setAudioStatus(nextStatus);
|
|
||||||
setAudioDetail(info || null);
|
|
||||||
if (['error', 'failed'].includes(nextStatus)) {
|
|
||||||
scheduleAudioRestart();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
player = new WhepPlayer({
|
|
||||||
url: resolvedAudioSessionInfo.url,
|
url: resolvedAudioSessionInfo.url,
|
||||||
token: resolvedAudioSessionInfo.token,
|
token: resolvedAudioSessionInfo.token,
|
||||||
video: audioRef.current,
|
video: audioRef.current,
|
||||||
audioOnly: true,
|
audioOnly: true,
|
||||||
onStatus: handleStatus,
|
// Dedicated rover audio historically retries only errors and failures.
|
||||||
});
|
terminalStates: ['error', 'failed'],
|
||||||
|
scheduleRestart: scheduleAudioRestart,
|
||||||
player.start().catch((err) => {
|
onStatus: (nextStatus, info) => {
|
||||||
if (!active) return;
|
logAudio('audio/status', { nextStatus, info: info || null });
|
||||||
|
setAudioStatus(nextStatus);
|
||||||
|
setAudioDetail(info || null);
|
||||||
|
},
|
||||||
|
onError: (err) => {
|
||||||
setAudioStatus('error');
|
setAudioStatus('error');
|
||||||
setAudioDetail(err.message);
|
setAudioDetail(err.message);
|
||||||
scheduleAudioRestart();
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
player?.stop();
|
|
||||||
};
|
|
||||||
}, [
|
}, [
|
||||||
resolvedAudioSessionInfo?.url,
|
resolvedAudioSessionInfo?.url,
|
||||||
resolvedAudioSessionInfo?.token,
|
resolvedAudioSessionInfo?.token,
|
||||||
|
|||||||
@@ -96,16 +96,12 @@ function ScrollableQueueContent({ enabled = false, children }) {
|
|||||||
export default function RoverQueuesPanel({
|
export default function RoverQueuesPanel({
|
||||||
title = 'Rovers',
|
title = 'Rovers',
|
||||||
roster: rosterOverride = null,
|
roster: rosterOverride = null,
|
||||||
turnQueues: turnQueuesOverride = null,
|
|
||||||
users: usersOverride = null,
|
|
||||||
externalInstance = null,
|
externalInstance = null,
|
||||||
disabledOverlay = '',
|
disabledOverlay = '',
|
||||||
fillHeight = false,
|
fillHeight = false,
|
||||||
}) {
|
}) {
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
const localRoster = useSessionSelector((state) => state.session?.roster ?? []);
|
||||||
const localTurnQueues = useSessionSelector((state) => state.session?.turnQueues ?? {});
|
|
||||||
const localUsers = useSessionSelector((state) => state.session?.users ?? []);
|
|
||||||
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
const interInstanceEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'interInstance'));
|
||||||
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
const { value: pageSettings } = useSettingsNamespace('page', { interInstanceTransferSettings: true });
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
@@ -124,8 +120,6 @@ export default function RoverQueuesPanel({
|
|||||||
const externalBlocked = Boolean(externalMode && disabledOverlay);
|
const externalBlocked = Boolean(externalMode && disabledOverlay);
|
||||||
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
|
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
|
||||||
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
const roster = Array.isArray(rosterOverride) ? rosterOverride : localRoster;
|
||||||
const turnQueues = turnQueuesOverride && typeof turnQueuesOverride === 'object' ? turnQueuesOverride : localTurnQueues;
|
|
||||||
const users = Array.isArray(usersOverride) ? usersOverride : localUsers;
|
|
||||||
|
|
||||||
const canRequest = useMemo(
|
const canRequest = useMemo(
|
||||||
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
() => (externalMode ? !externalBlocked : role && role !== 'spectator'),
|
||||||
@@ -136,8 +130,8 @@ export default function RoverQueuesPanel({
|
|||||||
[role],
|
[role],
|
||||||
);
|
);
|
||||||
const hasDeadlines = useMemo(
|
const hasDeadlines = useMemo(
|
||||||
() => Object.values(turnQueues || {}).some((info) => info?.deadline || info?.idleDeadline),
|
() => roster.some((rover) => rover.turn?.deadline || rover.turn?.idleDeadline),
|
||||||
[turnQueues],
|
[roster],
|
||||||
);
|
);
|
||||||
/*
|
/*
|
||||||
Queue timers are shown in whole seconds, and several queue panels can be
|
Queue timers are shown in whole seconds, and several queue panels can be
|
||||||
@@ -146,14 +140,6 @@ export default function RoverQueuesPanel({
|
|||||||
*/
|
*/
|
||||||
const now = useSharedClock(1000, hasDeadlines);
|
const now = useSharedClock(1000, hasDeadlines);
|
||||||
|
|
||||||
const rosterItems = useMemo(() => {
|
|
||||||
const known = new Set(roster.map((rover) => String(rover.id)));
|
|
||||||
const extra = Object.keys(turnQueues || {})
|
|
||||||
.filter((id) => !known.has(String(id)))
|
|
||||||
.map((id) => ({ id, name: id, locked: false, batteryState: null }));
|
|
||||||
return [...roster, ...extra];
|
|
||||||
}, [roster, turnQueues]);
|
|
||||||
|
|
||||||
async function handleRequest(targetRoverId) {
|
async function handleRequest(targetRoverId) {
|
||||||
if (!targetRoverId) return;
|
if (!targetRoverId) return;
|
||||||
if (externalBlocked) return;
|
if (externalBlocked) return;
|
||||||
@@ -181,8 +167,6 @@ export default function RoverQueuesPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const lookupUser = (socketId) =>
|
|
||||||
users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null };
|
|
||||||
|
|
||||||
async function handleRebootOwnRover() {
|
async function handleRebootOwnRover() {
|
||||||
if (rebootPending) return;
|
if (rebootPending) return;
|
||||||
@@ -224,14 +208,13 @@ export default function RoverQueuesPanel({
|
|||||||
>
|
>
|
||||||
<ScrollableQueueContent enabled={fillHeight}>
|
<ScrollableQueueContent enabled={fillHeight}>
|
||||||
<div className="relative space-y-0.5">
|
<div className="relative space-y-0.5">
|
||||||
{rosterItems.length === 0 ? (
|
{roster.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||||
) : (
|
) : (
|
||||||
<ul className="space-y-0.5 text-sm">
|
<ul className="space-y-0.5 text-sm">
|
||||||
{rosterItems.map((rover) => {
|
{roster.map((rover) => {
|
||||||
const roverId = String(rover.id);
|
const roverId = String(rover.id);
|
||||||
const info = turnQueues?.[roverId] || null;
|
const info = rover.turn;
|
||||||
const queue = info?.queue || [];
|
|
||||||
/*
|
/*
|
||||||
A turn handoff and an inactivity skip are separate server
|
A turn handoff and an inactivity skip are separate server
|
||||||
deadlines. Keep them separate through formatting so an idle
|
deadlines. Keep them separate through formatting so an idle
|
||||||
@@ -246,14 +229,8 @@ export default function RoverQueuesPanel({
|
|||||||
const idleRemainingSeconds = idleDeadline
|
const idleRemainingSeconds = idleDeadline
|
||||||
? Math.max(0, Math.ceil((idleDeadline - now) / 1000))
|
? Math.max(0, Math.ceil((idleDeadline - now) / 1000))
|
||||||
: null;
|
: null;
|
||||||
const currentId = info?.current || null;
|
const currentId = info?.currentId || null;
|
||||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
const nextId = info?.nextId || null;
|
||||||
const nextId =
|
|
||||||
queue.length > 1
|
|
||||||
? currentIdx >= 0
|
|
||||||
? queue[(currentIdx + 1) % queue.length]
|
|
||||||
: queue[0]
|
|
||||||
: null;
|
|
||||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||||
const timerLabel = isSelfCurrent
|
const timerLabel = isSelfCurrent
|
||||||
@@ -281,11 +258,8 @@ export default function RoverQueuesPanel({
|
|||||||
<QueueTargetRow
|
<QueueTargetRow
|
||||||
key={rover.id}
|
key={rover.id}
|
||||||
target={{ ...rover, rover, roverId, id: roverId }}
|
target={{ ...rover, rover, roverId, id: roverId }}
|
||||||
queue={queue}
|
turn={info}
|
||||||
currentId={currentId}
|
|
||||||
nextId={nextId}
|
|
||||||
selfId={selfId}
|
selfId={selfId}
|
||||||
lookupUser={lookupUser}
|
|
||||||
canClick={canClickRow}
|
canClick={canClickRow}
|
||||||
pending={Boolean(pending[roverId])}
|
pending={Boolean(pending[roverId])}
|
||||||
locked={locked}
|
locked={locked}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ function useAudio(src, volume = 1) {
|
|||||||
|
|
||||||
export default function TurnAlertListener() {
|
export default function TurnAlertListener() {
|
||||||
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
const socketId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const assignments = useSessionSelector((state) => state.session?.turnQueues || {});
|
|
||||||
const roster = useSessionSelector((state) => state.session?.roster || []);
|
const roster = useSessionSelector((state) => state.session?.roster || []);
|
||||||
const { pushAlert } = useSessionActions();
|
const { pushAlert } = useSessionActions();
|
||||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||||
@@ -38,18 +37,19 @@ export default function TurnAlertListener() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!socketId) return;
|
if (!socketId) return;
|
||||||
const newlyMine = [];
|
const newlyMine = [];
|
||||||
Object.entries(assignments).forEach(([roverId, info]) => {
|
roster.forEach((rover) => {
|
||||||
if (!info || !info.current || info.current !== socketId) return;
|
const roverId = String(rover.id);
|
||||||
|
if (!rover.turn?.ownsControl) return;
|
||||||
if (!seenRoversRef.current.has(roverId)) {
|
if (!seenRoversRef.current.has(roverId)) {
|
||||||
newlyMine.push(roverId);
|
newlyMine.push(roverId);
|
||||||
seenRoversRef.current.add(roverId);
|
seenRoversRef.current.add(roverId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
Object.keys(assignments).forEach((roverId) => {
|
for (const roverId of seenRoversRef.current) {
|
||||||
if (!assignments[roverId] || assignments[roverId].current !== socketId) {
|
if (!roster.some((rover) => String(rover.id) === roverId && rover.turn?.ownsControl)) {
|
||||||
seenRoversRef.current.delete(roverId);
|
seenRoversRef.current.delete(roverId);
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
if (newlyMine.length === 0) return;
|
if (newlyMine.length === 0) return;
|
||||||
newlyMine.forEach((roverId) => {
|
newlyMine.forEach((roverId) => {
|
||||||
const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
|
const roverName = roster.find((r) => String(r.id) === String(roverId))?.name || roverId;
|
||||||
@@ -60,7 +60,7 @@ export default function TurnAlertListener() {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
playSound();
|
playSound();
|
||||||
}, [assignments, playSound, pushAlert, roster, socketId]);
|
}, [playSound, pushAlert, roster, socketId]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -73,8 +73,8 @@ export default function UserListPanel({
|
|||||||
const users = useSessionSelector((state) => state.session?.users ?? []);
|
const users = useSessionSelector((state) => state.session?.users ?? []);
|
||||||
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
const selfId = useSessionSelector((state) => state.session?.socketId || null);
|
||||||
const mode = useSessionSelector((state) => state.session?.mode || null);
|
const mode = useSessionSelector((state) => state.session?.mode || null);
|
||||||
const turnQueues = useSessionSelector((state) => state.session?.turnQueues || {});
|
|
||||||
const roster = useSessionSelector((state) => state.session?.roster || []);
|
const roster = useSessionSelector((state) => state.session?.roster || []);
|
||||||
|
const queuedRovers = roster.filter((rover) => rover.turn?.queue != null);
|
||||||
const isTurnsMode = mode === 'turns';
|
const isTurnsMode = mode === 'turns';
|
||||||
const [turnView, setTurnView] = useState('queues');
|
const [turnView, setTurnView] = useState('queues');
|
||||||
|
|
||||||
@@ -98,11 +98,6 @@ export default function UserListPanel({
|
|||||||
[roster],
|
[roster],
|
||||||
);
|
);
|
||||||
|
|
||||||
const lookupUser = useCallback(
|
|
||||||
(socketId) => users.find((u) => u.socketId === socketId) || { socketId, nickname: null, role: null },
|
|
||||||
[users],
|
|
||||||
);
|
|
||||||
|
|
||||||
const secondsRemaining = useCallback((deadline) => {
|
const secondsRemaining = useCallback((deadline) => {
|
||||||
if (!deadline) return null;
|
if (!deadline) return null;
|
||||||
const ms = deadline - Date.now();
|
const ms = deadline - Date.now();
|
||||||
@@ -141,10 +136,11 @@ export default function UserListPanel({
|
|||||||
className={`flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
|
className={`flex items-center gap-0.5 ${compact ? 'py-0.25 text-[0.8rem]' : 'text-sm'}`}
|
||||||
>
|
>
|
||||||
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
|
||||||
{user.roverId ? (
|
{user.roverId || user.operatingModeDisplay ? (
|
||||||
<RoverLabel
|
<RoverLabel
|
||||||
roverId={user.roverId}
|
roverId={user.roverId}
|
||||||
color={rosterEntry(user.roverId)?.color}
|
name={user.operatingModeDisplay?.name}
|
||||||
|
color={user.operatingModeDisplay?.color || rosterEntry(user.roverId)?.color}
|
||||||
fallback={user.roverId}
|
fallback={user.roverId}
|
||||||
className="text-[0.7rem]"
|
className="text-[0.7rem]"
|
||||||
/>
|
/>
|
||||||
@@ -164,7 +160,7 @@ export default function UserListPanel({
|
|||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title={!hideHeader ? (isTurnsMode ? (showQueuesSection ? 'Turn queues' : 'Users') : 'Users') : ''}
|
title={!hideHeader ? (isTurnsMode ? (showQueuesSection ? 'Turn queues' : 'Users') : 'Users') : ''}
|
||||||
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? Object.keys(turnQueues || {}).length : sorted.length) : null}
|
meta={!hideHeader ? (showQueuesSection && isTurnsMode ? queuedRovers.length : sorted.length) : null}
|
||||||
hideHeader={hideHeader}
|
hideHeader={hideHeader}
|
||||||
fillHeight={fillHeight}
|
fillHeight={fillHeight}
|
||||||
className={className}
|
className={className}
|
||||||
@@ -208,21 +204,14 @@ export default function UserListPanel({
|
|||||||
) : null}
|
) : null}
|
||||||
{showQueuesSection ? (
|
{showQueuesSection ? (
|
||||||
<div className={`space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
<div className={`space-y-0.5 px-0 pb-0 ${turnsListClass} ${compact ? 'text-[0.8rem]' : ''}`}>
|
||||||
{Object.keys(turnQueues || {}).length === 0 ? (
|
{queuedRovers.length === 0 ? (
|
||||||
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
<p className="text-sm text-slate-500">No turn queues yet.</p>
|
||||||
) : (
|
) : (
|
||||||
Object.entries(turnQueues).map(([roverId, info]) => {
|
queuedRovers.map(({ id: roverId, turn: info }) => {
|
||||||
const queue = info?.queue || [];
|
const queue = info?.queue || [];
|
||||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||||
const remaining = secondsRemaining(deadline);
|
const remaining = secondsRemaining(deadline);
|
||||||
const currentId = info?.current || null;
|
const { currentId, nextId } = info;
|
||||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
|
||||||
const nextId =
|
|
||||||
queue.length > 1
|
|
||||||
? currentIdx >= 0
|
|
||||||
? queue[(currentIdx + 1) % queue.length]
|
|
||||||
: queue[0]
|
|
||||||
: null;
|
|
||||||
return (
|
return (
|
||||||
<div key={roverId} className={`flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
<div key={roverId} className={`flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
@@ -239,11 +228,10 @@ export default function UserListPanel({
|
|||||||
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
|
<p className="text-[0.75rem] text-slate-500">No drivers queued.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex flex-wrap items-center gap-0.5">
|
<div className="flex flex-wrap items-center gap-0.5">
|
||||||
{queue.map((socketId, idx) => {
|
{queue.map((user, idx) => {
|
||||||
const user = lookupUser(socketId);
|
const { socketId } = user;
|
||||||
const isCurrent = socketId === currentId;
|
const isCurrent = socketId === currentId;
|
||||||
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
const isNext = Boolean(nextId && socketId === nextId && !isCurrent);
|
||||||
const isSelf = Boolean(selfId && socketId === selfId);
|
|
||||||
const isAdmin =
|
const isAdmin =
|
||||||
user.role === 'admin' || user.role === 'lockdown';
|
user.role === 'admin' || user.role === 'lockdown';
|
||||||
const highlightClass = isCurrent
|
const highlightClass = isCurrent
|
||||||
@@ -257,10 +245,9 @@ export default function UserListPanel({
|
|||||||
className={`flex items-center gap-0.5 rounded-sm px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
className={`flex items-center gap-0.5 rounded-sm px-1 ${compact ? 'text-[0.7rem]' : 'text-[0.8rem]'} ${highlightClass}`}
|
||||||
>
|
>
|
||||||
<span className={`${roleColors(user.role)} font-semibold`}>
|
<span className={`${roleColors(user.role)} font-semibold`}>
|
||||||
{formatLabel(user, selfId)}
|
{user.name}{socketId === selfId ? ' (you)' : ''}
|
||||||
</span>
|
</span>
|
||||||
{isAdmin && <span className="text-[0.7rem] text-amber-200">★</span>}
|
{isAdmin && <span className="text-[0.7rem] text-amber-200">★</span>}
|
||||||
{/* {isSelf && <span className="text-[0.7rem] text-white">YOU</span>} */}
|
|
||||||
{isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>}
|
{isCurrent && <span className="text-[0.7rem] text-slate-200">now</span>}
|
||||||
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
|
{isNext && <span className="text-[0.7rem] text-emerald-100">next</span>}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import VipVerificationCard from '../vip/VipVerificationCard.jsx';
|
|||||||
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
import VipIdentityCard from '../vip/VipIdentityCard.jsx';
|
||||||
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
import VipPrivateRoverAccessCard from '../vip/VipPrivateRoverAccessCard.jsx';
|
||||||
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
import VipProfileImageCard from '../vip/VipProfileImageCard.jsx';
|
||||||
import VipRoverLearningCard from '../vip/VipRoverLearningCard.jsx';
|
|
||||||
|
|
||||||
export default function VipPanel() {
|
export default function VipPanel() {
|
||||||
const session = useSessionSelector((state) => state.session);
|
const session = useSessionSelector((state) => state.session);
|
||||||
@@ -72,7 +71,6 @@ export default function VipPanel() {
|
|||||||
<div className="@[32rem]:col-span-2">
|
<div className="@[32rem]:col-span-2">
|
||||||
{isVerified ? (
|
{isVerified ? (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<VipRoverLearningCard />
|
|
||||||
<VipMidiBeeperCard />
|
<VipMidiBeeperCard />
|
||||||
<VipAudioUploadCard
|
<VipAudioUploadCard
|
||||||
ownRoverId={ownRoverId}
|
ownRoverId={ownRoverId}
|
||||||
|
|||||||
@@ -1,596 +0,0 @@
|
|||||||
// Vip PTZ Camera Card
|
|
||||||
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
|
|
||||||
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
|
|
||||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
||||||
import '../MobileControls/mobileControls.css';
|
|
||||||
import { createPortal } from 'react-dom';
|
|
||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
|
||||||
import GPIOToggleControl from '../GPIOToggleControl/index.jsx';
|
|
||||||
import ControlPadPanel from '../MobileControls/ControlPadPanel.jsx';
|
|
||||||
import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
|
|
||||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
|
||||||
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
|
||||||
import { useControlActions } from '../../controls/index.js';
|
|
||||||
import ControlHint from '../ControlHint/index.jsx';
|
|
||||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
|
||||||
import { isFeatureEnabled } from '../../lib/features.js';
|
|
||||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
|
||||||
|
|
||||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
|
||||||
|
|
||||||
function formatRemaining(deadline) {
|
|
||||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
|
||||||
if (!remaining) return '--';
|
|
||||||
const minutes = Math.floor(remaining / 60);
|
|
||||||
const seconds = remaining % 60;
|
|
||||||
return `${minutes}:${String(seconds).padStart(2, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSpotlightOn(light = {}) {
|
|
||||||
if (typeof light?.on === 'boolean') return light.on;
|
|
||||||
const raw = light?.state;
|
|
||||||
if (typeof raw === 'string') {
|
|
||||||
const normalized = raw.trim().toLowerCase();
|
|
||||||
return !['', '0', 'off', 'false'].includes(normalized);
|
|
||||||
}
|
|
||||||
return Boolean(Number(raw));
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeIrMode(mode) {
|
|
||||||
const normalized = String(mode || '').trim().toLowerCase();
|
|
||||||
if (normalized === 'on') return 'On';
|
|
||||||
if (normalized === 'off') return 'Off';
|
|
||||||
return 'Auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function nextIrMode(currentMode) {
|
|
||||||
const current = normalizeIrMode(currentMode);
|
|
||||||
if (current === 'Auto') return 'On';
|
|
||||||
if (current === 'On') return 'Off';
|
|
||||||
return 'Auto';
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzSnapshotPreview({ feed, label = 'PTZ Camera' }) {
|
|
||||||
return (
|
|
||||||
<div className="relative w-full overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
|
||||||
{feed?.objectUrl ? (
|
|
||||||
<img src={feed.objectUrl} alt={label} className="h-full w-full object-cover" />
|
|
||||||
) : (
|
|
||||||
<div className="flex h-full w-full items-center justify-center text-xs text-slate-400">Waiting for snapshot...</div>
|
|
||||||
)}
|
|
||||||
{/*
|
|
||||||
Even on this legacy VIP surface, keep the video pane itself identical
|
|
||||||
to rover media panes: no camera title inside the frame, only the small
|
|
||||||
top-left stream status. Any PTZ name/context belongs to the card chrome
|
|
||||||
around the media, not the media player.
|
|
||||||
*/}
|
|
||||||
<div className="pointer-events-none absolute left-1 top-1 z-20 font-medium text-slate-100 text-[0.65rem]">
|
|
||||||
<div className="flex flex-col gap-0.5 leading-none">
|
|
||||||
<span>Status: {feed?.error ? `Error: ${feed.error}` : feed?.status || 'connecting'}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatusRow({ label, value, tone = '' }) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-between gap-1 text-xs">
|
|
||||||
<span className="text-slate-400">{label}</span>
|
|
||||||
<span className={`min-w-0 truncate font-medium ${tone || 'text-slate-100'}`}>{value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatPublisherProgress(progress = null) {
|
|
||||||
/*
|
|
||||||
ffmpeg progress is intentionally shown as raw operational numbers instead
|
|
||||||
of translated prose. When the PTZ video feels delayed, fps/speed/drop/out
|
|
||||||
time make it obvious whether ffmpeg itself is keeping up or the delay is
|
|
||||||
somewhere before/after the transcoder.
|
|
||||||
*/
|
|
||||||
if (!progress) return null;
|
|
||||||
return [
|
|
||||||
progress.fps ? `fps ${progress.fps}` : null,
|
|
||||||
progress.speed ? `speed ${progress.speed}` : null,
|
|
||||||
progress.drop_frames ? `drop ${progress.drop_frames}` : null,
|
|
||||||
progress.out_time ? `out ${progress.out_time}` : null,
|
|
||||||
].filter(Boolean).join(' | ');
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzQueueList({ queue = [], operatorLabel = '' }) {
|
|
||||||
const hasQueue = Array.isArray(queue) && queue.length > 0;
|
|
||||||
return (
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="panel-muted text-xs">Turn queue</div>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
{operatorLabel ? (
|
|
||||||
<div className="surface flex items-center justify-between gap-1 text-xs">
|
|
||||||
<span className="text-slate-400">Now</span>
|
|
||||||
<span className="min-w-0 truncate text-emerald-200">{operatorLabel}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{hasQueue ? queue.map((entry, index) => (
|
|
||||||
<div key={entry.socketId || `${entry.label}-${index}`} className="surface flex items-center justify-between gap-1 text-xs">
|
|
||||||
<span className="text-slate-400">{index + 1}</span>
|
|
||||||
<span className="min-w-0 truncate text-slate-100">{entry.label || entry.socketId || 'queued user'}</span>
|
|
||||||
</div>
|
|
||||||
)) : (
|
|
||||||
<div className="surface text-xs text-slate-400">No one waiting</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzStatePanel({ ptz, onClose, onRelease, releaseDisabled = false }) {
|
|
||||||
const spotlightOn = isSpotlightOn(ptz?.light);
|
|
||||||
const irMode = normalizeIrMode(ptz?.ir?.state);
|
|
||||||
const publisher = ptz?.publisher || {};
|
|
||||||
const publisherStatus = publisher.running
|
|
||||||
? `running${publisher.pid ? ` ${publisher.pid}` : ''}`
|
|
||||||
: publisher.restartAt
|
|
||||||
? 'restarting'
|
|
||||||
: publisher.lastEvent || 'stopped';
|
|
||||||
const publisherProgress = formatPublisherProgress(publisher.progress);
|
|
||||||
const statusTone = ptz?.error ? 'text-amber-300' : ptz?.isOperator ? 'text-emerald-300' : 'text-slate-100';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame
|
|
||||||
title="Camera state"
|
|
||||||
actions={onClose ? <button type="button" className="button-dark text-xs" onClick={onClose}>Close</button> : null}
|
|
||||||
bodyClassName="space-y-0.5 p-1 text-sm"
|
|
||||||
>
|
|
||||||
<StatusRow label="Mode" value={ptz?.isOperator ? 'operator' : ptz?.queuedPosition ? `queued ${ptz.queuedPosition}` : 'spectator'} tone={statusTone} />
|
|
||||||
<StatusRow label="Operator" value={ptz?.operatorLabel || 'none'} />
|
|
||||||
<StatusRow label="Remaining" value={formatRemaining(ptz?.deadline)} />
|
|
||||||
<StatusRow label="Spotlight" value={spotlightOn ? 'On' : 'Off'} tone={spotlightOn ? 'text-emerald-300' : 'text-slate-200'} />
|
|
||||||
<StatusRow label="Infrared mode" value={irMode} />
|
|
||||||
<StatusRow label="Stream" value={ptz?.status || ptz?.error || 'idle'} tone={ptz?.error ? 'text-amber-300' : ''} />
|
|
||||||
<StatusRow label="Transcoder" value={publisherStatus} tone={publisher.running ? 'text-emerald-300' : 'text-amber-300'} />
|
|
||||||
{publisherProgress ? <StatusRow label="Progress" value={publisherProgress} /> : null}
|
|
||||||
{publisher.lastStderr ? (
|
|
||||||
<div className="surface max-h-24 overflow-y-auto whitespace-pre-wrap wrap-break-word font-mono text-[0.68rem] leading-tight text-slate-200">
|
|
||||||
{publisher.lastStderr}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{ptz?.blocked?.message ? (
|
|
||||||
<div className="rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
|
||||||
{ptz.blocked.message}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{onRelease ? (
|
|
||||||
<button type="button" className="button-dark w-full text-xs" disabled={releaseDisabled} onClick={onRelease}>
|
|
||||||
Release camera
|
|
||||||
</button>
|
|
||||||
) : null}
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzLightingControls({ ptz, disabled = false }) {
|
|
||||||
const { ptzSpotlight, ptzIr } = useSessionActions();
|
|
||||||
const [busy, setBusy] = useState('');
|
|
||||||
const spotlightOn = isSpotlightOn(ptz?.light);
|
|
||||||
const irMode = normalizeIrMode(ptz?.ir?.state);
|
|
||||||
|
|
||||||
const toggleSpotlight = async (nextOn) => {
|
|
||||||
if (disabled) return;
|
|
||||||
setBusy('spotlight');
|
|
||||||
try {
|
|
||||||
await ptzSpotlight({ state: nextOn ? 1 : 0 });
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const cycleIr = async () => {
|
|
||||||
if (disabled) return;
|
|
||||||
triggerTouchHaptic('button');
|
|
||||||
setBusy('ir');
|
|
||||||
try {
|
|
||||||
await ptzIr({ state: nextIrMode(irMode) });
|
|
||||||
} finally {
|
|
||||||
setBusy('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
||||||
<GPIOToggleControl
|
|
||||||
label="Spotlight"
|
|
||||||
on={spotlightOn}
|
|
||||||
disabled={disabled || busy === 'spotlight'}
|
|
||||||
onToggle={toggleSpotlight}
|
|
||||||
heightClass="min-h-14"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control flex min-h-14 flex-col items-center justify-center gap-0.5 rounded-xl border-2 border-cyan-300/70 bg-cyan-900 px-1 py-0.75 text-center text-cyan-50 disabled:opacity-50"
|
|
||||||
disabled={disabled || busy === 'ir'}
|
|
||||||
onClick={cycleIr}
|
|
||||||
>
|
|
||||||
<span className="text-sm font-semibold">Infrared</span>
|
|
||||||
<span className="rounded-sm bg-cyan-300 px-1 py-0.5 text-[0.7rem] font-semibold text-cyan-950">{irMode}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileZoomButtons({ disabled = false }) {
|
|
||||||
const { setCameraAxisIntent } = useControlActions();
|
|
||||||
const stopZoom = useCallback(() => {
|
|
||||||
// This is a zoom-only release; the shared adapter retains any simultaneous
|
|
||||||
// pan/tilt intent from the movement pad in its next combined motion state.
|
|
||||||
setCameraAxisIntent(0);
|
|
||||||
}, [setCameraAxisIntent]);
|
|
||||||
const startZoom = useCallback(
|
|
||||||
(direction) => (event) => {
|
|
||||||
/*
|
|
||||||
The adapter owns renewal for held PTZ state. Publishing the direction
|
|
||||||
once avoids a component-local repeat timer and keeps this legacy card on
|
|
||||||
the exact same motion path as the dedicated PTZ route.
|
|
||||||
*/
|
|
||||||
event.preventDefault();
|
|
||||||
if (disabled) return;
|
|
||||||
triggerTouchHaptic('button');
|
|
||||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
|
||||||
setCameraAxisIntent(direction);
|
|
||||||
},
|
|
||||||
[disabled, setCameraAxisIntent],
|
|
||||||
);
|
|
||||||
const stopFromPointer = useCallback(
|
|
||||||
(event) => {
|
|
||||||
event?.preventDefault?.();
|
|
||||||
if (disabled) return;
|
|
||||||
stopZoom();
|
|
||||||
},
|
|
||||||
[disabled, stopZoom],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
// Orientation changes can unmount the button before pointerup. Clear the
|
|
||||||
// held zoom state explicitly instead of waiting for the server watchdog.
|
|
||||||
setCameraAxisIntent(0);
|
|
||||||
},
|
|
||||||
[setCameraAxisIntent],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
||||||
disabled={disabled}
|
|
||||||
onPointerDown={startZoom(-1)}
|
|
||||||
onPointerUp={stopFromPointer}
|
|
||||||
onPointerCancel={stopFromPointer}
|
|
||||||
onLostPointerCapture={stopFromPointer}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
Zoom out
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="mobile-touch-control button-dark min-h-10 text-xs disabled:opacity-50"
|
|
||||||
disabled={disabled}
|
|
||||||
onPointerDown={startZoom(1)}
|
|
||||||
onPointerUp={stopFromPointer}
|
|
||||||
onPointerCancel={stopFromPointer}
|
|
||||||
onLostPointerCapture={stopFromPointer}
|
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
|
||||||
>
|
|
||||||
Zoom in
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzMobileControlsPanel({ ptz, disabled = false }) {
|
|
||||||
return (
|
|
||||||
<div className="mobile-touch-control space-y-0.5">
|
|
||||||
<PtzMobileZoomButtons disabled={disabled} />
|
|
||||||
<div className="mobile-touch-control h-44 min-h-0">
|
|
||||||
{/*
|
|
||||||
Reuse the rover mobile movement card instead of building a second PTZ
|
|
||||||
joystick. Its drive vector goes through the shared internal control
|
|
||||||
layer, where the PTZ adapter already converts movement plus speed mode
|
|
||||||
into camera pan/tilt commands.
|
|
||||||
*/}
|
|
||||||
<ControlPadPanel compact disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
<PtzLightingControls ptz={ptz} disabled={disabled} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzControlReference() {
|
|
||||||
const rows = [
|
|
||||||
['Tilt up', 'driveForward'],
|
|
||||||
['Tilt down', 'driveBackward'],
|
|
||||||
['Pan left', 'driveLeft'],
|
|
||||||
['Pan right', 'driveRight'],
|
|
||||||
['Zoom in', 'cameraUp'],
|
|
||||||
['Zoom out', 'cameraDown'],
|
|
||||||
['Spotlight', 'headlightToggle'],
|
|
||||||
['Infrared mode', 'laserToggle'],
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CardFrame title="Controls" bodyClassName="space-y-0.5 p-1 text-xs">
|
|
||||||
{rows.map(([label, actionId]) => (
|
|
||||||
<div key={label} className="surface flex items-center justify-between gap-1">
|
|
||||||
<span className="text-slate-400">{label}</span>
|
|
||||||
{/* Use the same key display component as the rest of the UI so PTZ
|
|
||||||
controls read as normal mapped controls instead of custom labels. */}
|
|
||||||
<KeyPill label={<ControlHint actionId={actionId} />} />
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</CardFrame>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PtzController({ open, onClose, layout = 'desktop' }) {
|
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
|
||||||
const isOperator = Boolean(ptz?.isOperator);
|
|
||||||
const nonTurnSnapshotsActive = useSessionSelector(
|
|
||||||
(state) => Boolean(state.session?.bandwidthSavings?.nonTurnVideo?.snapshotsActive),
|
|
||||||
);
|
|
||||||
const isMobile = layout === 'mobile-portrait' || layout === 'mobile-landscape';
|
|
||||||
const { ptzRelease } = useSessionActions();
|
|
||||||
/*
|
|
||||||
The VIP fullscreen surface is available to queued PTZ users too. Let queued
|
|
||||||
users see live video only when the central non-turn video policy allows it;
|
|
||||||
all movement and light controls still remain guarded by isOperator.
|
|
||||||
*/
|
|
||||||
const shouldUseLiveVideo = isOperator || !nonTurnSnapshotsActive;
|
|
||||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: open && !shouldUseLiveVideo });
|
|
||||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
|
||||||
const [releasePending, setReleasePending] = useState(false);
|
|
||||||
|
|
||||||
if (!open) return null;
|
|
||||||
|
|
||||||
const releaseAndClose = async () => {
|
|
||||||
setReleasePending(true);
|
|
||||||
try {
|
|
||||||
await ptzRelease();
|
|
||||||
onClose();
|
|
||||||
} finally {
|
|
||||||
setReleasePending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const desktopSidebar = (
|
|
||||||
<>
|
|
||||||
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
|
||||||
</div>
|
|
||||||
{isOperator ? (
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PtzLightingControls ptz={ptz} />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="shrink-0">
|
|
||||||
<CardFrame title="Controls" bodyClassName="p-1 text-xs text-slate-400">
|
|
||||||
Live PTZ controls unlock when your camera turn is active.
|
|
||||||
</CardFrame>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PtzControlReference />
|
|
||||||
</div>
|
|
||||||
<div className="min-h-48 flex-1">
|
|
||||||
{/*
|
|
||||||
The global chat key focuses the input registered by ChatPanel through
|
|
||||||
ChatContext. Keeping a real ChatPanel mounted inside the PTZ fullscreen
|
|
||||||
sidebar lets the normal keyboard path focus chat and lets Enter submit
|
|
||||||
through the existing chat composer form instead of adding PTZ-specific
|
|
||||||
chat handling.
|
|
||||||
*/}
|
|
||||||
<ChatPanel fillHeight title="Chat" />
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<ReplaySourcesPanel panelId="ptz-controller-replay" defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`} />
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PtzStatePanel
|
|
||||||
ptz={ptz}
|
|
||||||
onClose={onClose}
|
|
||||||
onRelease={isOperator ? releaseAndClose : null}
|
|
||||||
releaseDisabled={releasePending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const mobileSidebar = (
|
|
||||||
<>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PtzMobileControlsPanel ptz={ptz} disabled={!isOperator} />
|
|
||||||
</div>
|
|
||||||
<div className="min-h-40 flex-1">
|
|
||||||
{/*
|
|
||||||
Mobile uses the same ChatPanel registration as desktop so the mapped
|
|
||||||
chat key and the on-screen input stay on one shared chat implementation.
|
|
||||||
*/}
|
|
||||||
<ChatPanel fillHeight title="Chat" />
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<ReplaySourcesPanel
|
|
||||||
panelId="ptz-controller-replay-mobile"
|
|
||||||
defaultSelectedKey={`ptz:${PTZ_CAMERA_ID}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PtzStatePanel
|
|
||||||
ptz={ptz}
|
|
||||||
onClose={onClose}
|
|
||||||
onRelease={isOperator ? releaseAndClose : null}
|
|
||||||
releaseDisabled={releasePending}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="shrink-0">
|
|
||||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
const sidebarWidthClass = isMobile
|
|
||||||
? 'grid-cols-[minmax(0,1fr)_14rem]'
|
|
||||||
: 'grid-cols-[minmax(0,1fr)_20rem]';
|
|
||||||
|
|
||||||
const controller = (
|
|
||||||
<div className="fixed inset-0 z-110 h-dvh w-screen overflow-hidden bg-black text-slate-100">
|
|
||||||
<CardFrame
|
|
||||||
hideHeader
|
|
||||||
fillHeight
|
|
||||||
clipOverflow={false}
|
|
||||||
className="h-dvh w-screen rounded-none border-0 bg-black!"
|
|
||||||
bodyClassName={`grid h-full min-h-0 overflow-hidden ${sidebarWidthClass}`}
|
|
||||||
>
|
|
||||||
<main className="relative min-h-0 min-w-0 bg-black">
|
|
||||||
{shouldUseLiveVideo ? (
|
|
||||||
<PtzLiveVideo enabled startMuted={false} />
|
|
||||||
) : (
|
|
||||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
|
||||||
)}
|
|
||||||
</main>
|
|
||||||
<aside className="flex h-full min-h-0 items-stretch overflow-hidden border-l border-neutral-600 bg-neutral-950 text-sm">
|
|
||||||
<div className="flex min-h-0 w-full flex-col gap-0.5 overflow-y-auto p-0.5">
|
|
||||||
{isMobile ? mobileSidebar : desktopSidebar}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</CardFrame>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
/*
|
|
||||||
The controller is a true fullscreen surface, so mount it directly under
|
|
||||||
document.body instead of inside the VIP tab/card tree. That keeps tab panel
|
|
||||||
spacing, mobile banners, and parent overflow rules from creating visible
|
|
||||||
gaps around a fixed-position camera interface.
|
|
||||||
*/
|
|
||||||
return createPortal(controller, document.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function VipPtzCameraCard({ onMessage, fullWidth = false, layout = 'desktop' }) {
|
|
||||||
const featureEnabled = useSessionSelector((state) => isFeatureEnabled(state, 'ptzCamera'));
|
|
||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
|
||||||
const isVerified = useSessionSelector((state) => Boolean(state.session?.isVerified));
|
|
||||||
const { ptzClaim, ptzRelease } = useSessionActions();
|
|
||||||
const snapshotFeeds = usePtzCameraSnapshots([PTZ_CAMERA_ID], { enabled: Boolean(featureEnabled) });
|
|
||||||
const snapshot = snapshotFeeds[PTZ_CAMERA_ID] || null;
|
|
||||||
const [controllerOpen, setControllerOpen] = useState(false);
|
|
||||||
const [pending, setPending] = useState(false);
|
|
||||||
|
|
||||||
const wrapClass = fullWidth ? 'w-full' : 'mx-auto w-full max-w-xl';
|
|
||||||
const queueText = useMemo(() => {
|
|
||||||
if (ptz?.isOperator) return 'Your turn';
|
|
||||||
if (ptz?.queuedPosition) return `Queue position ${ptz.queuedPosition}`;
|
|
||||||
if (ptz?.operatorLabel) return `${ptz.operatorLabel} operating`;
|
|
||||||
return 'Available';
|
|
||||||
}, [ptz?.isOperator, ptz?.operatorLabel, ptz?.queuedPosition]);
|
|
||||||
|
|
||||||
if (!featureEnabled) return null;
|
|
||||||
|
|
||||||
const handleClaim = async () => {
|
|
||||||
setPending(true);
|
|
||||||
onMessage?.('');
|
|
||||||
try {
|
|
||||||
const response = await ptzClaim();
|
|
||||||
/*
|
|
||||||
Requesting the PTZ camera is only enough to open fullscreen after the
|
|
||||||
server confirms the user actually became the operator or entered the
|
|
||||||
queue. Dock-required failures reject before queue entry, so they must
|
|
||||||
leave the user on this card with the dock/charge message visible.
|
|
||||||
*/
|
|
||||||
if (response?.state?.isOperator || response?.state?.queuedPosition) setControllerOpen(true);
|
|
||||||
if (response?.state?.isOperator) onMessage?.('PTZ camera turn active.');
|
|
||||||
else if (response?.state?.queuedPosition) onMessage?.(`Joined PTZ queue at position ${response.state.queuedPosition}.`);
|
|
||||||
} catch (err) {
|
|
||||||
/*
|
|
||||||
Rover-to-rover switch denial uses the browser alert popup for the
|
|
||||||
dock-and-charge message. PTZ claim denial should feel identical because
|
|
||||||
it is enforcing the same "do not abandon an undocked rover" rule.
|
|
||||||
*/
|
|
||||||
alert(err.message || 'PTZ request failed.');
|
|
||||||
onMessage?.(err.message || 'PTZ request failed.');
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRelease = async () => {
|
|
||||||
setPending(true);
|
|
||||||
try {
|
|
||||||
await ptzRelease();
|
|
||||||
onMessage?.('Left PTZ camera.');
|
|
||||||
} catch (err) {
|
|
||||||
onMessage?.(err.message || 'Failed to leave PTZ camera.');
|
|
||||||
} finally {
|
|
||||||
setPending(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<CardFrame title="PTZ camera" className={wrapClass} bodyClassName="text-sm text-slate-300">
|
|
||||||
<div className="grid w-full gap-0.5 md:grid-cols-[minmax(16rem,0.8fr)_minmax(0,1.2fr)] md:items-start">
|
|
||||||
<PtzSnapshotPreview feed={snapshot} label={ptz?.name || 'PTZ Camera'} />
|
|
||||||
<div className="flex w-full min-w-0 flex-col gap-0.5">
|
|
||||||
<div className="grid w-full grid-cols-2 gap-0.5 text-left text-xs">
|
|
||||||
<div className="surface-muted p-1">
|
|
||||||
<p className="text-slate-500">State</p>
|
|
||||||
<p className="truncate text-slate-100">{queueText}</p>
|
|
||||||
</div>
|
|
||||||
<div className="surface-muted p-1">
|
|
||||||
<p className="text-slate-500">Remaining</p>
|
|
||||||
<p className="text-slate-100">{formatRemaining(ptz?.deadline)}</p>
|
|
||||||
</div>
|
|
||||||
<div className="surface-muted p-1">
|
|
||||||
<p className="text-slate-500">Spotlight</p>
|
|
||||||
<p className="truncate text-slate-100">{isSpotlightOn(ptz?.light) ? 'On' : 'Off'}</p>
|
|
||||||
</div>
|
|
||||||
<div className="surface-muted p-1">
|
|
||||||
<p className="text-slate-500">Infrared</p>
|
|
||||||
<p className="truncate text-slate-100">{normalizeIrMode(ptz?.ir?.state)}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<PtzQueueList queue={ptz?.queue} operatorLabel={ptz?.operatorLabel} />
|
|
||||||
{ptz?.blocked?.message ? (
|
|
||||||
<p className="w-full rounded-sm border border-amber-500/50 bg-amber-950/40 p-1 text-xs text-amber-100">
|
|
||||||
{ptz.blocked.message}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<div className="grid w-full grid-cols-2 gap-0.5">
|
|
||||||
{ptz?.isOperator ? (
|
|
||||||
<>
|
|
||||||
<button type="button" className="button-dark text-xs" onClick={() => setControllerOpen(true)}>
|
|
||||||
Open controller
|
|
||||||
</button>
|
|
||||||
<button type="button" className="button-dark text-xs" disabled={pending} onClick={handleRelease}>
|
|
||||||
Release
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="button-dark col-span-2 text-xs"
|
|
||||||
disabled={pending || !isVerified}
|
|
||||||
onClick={ptz?.queuedPosition ? handleRelease : handleClaim}
|
|
||||||
>
|
|
||||||
{ptz?.queuedPosition ? 'Leave queue' : pending ? 'Requesting...' : 'Claim camera'}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardFrame>
|
|
||||||
<PtzController open={controllerOpen} onClose={() => setControllerOpen(false)} layout={layout} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user