mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-18 02:20:47 -04:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dd1fd1e167 | ||
|
|
eba4b1dc1d | ||
|
|
cacd125fcb | ||
|
|
8a4162683f | ||
|
|
387a5f47d7 | ||
|
|
5b6947d92c | ||
|
|
cd8f8816c9 | ||
|
|
b86eec88f8 | ||
|
|
512adfc1e0 | ||
|
|
fe33f27bd0 | ||
|
|
afe8ffc62e | ||
|
|
4e6e9e3021 | ||
|
|
f9461433af | ||
|
|
b7d421c489 | ||
|
|
c6b2843150 | ||
|
|
b451849c02 | ||
|
|
955f6f213d | ||
|
|
c9842d7ba5 | ||
|
|
d7fb15d891 | ||
|
|
1cd0e05b4a | ||
|
|
7927768731 | ||
|
|
d9cb0c76b6 |
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
Vendored
BIN
Binary file not shown.
+93
-6
@@ -3,6 +3,7 @@ package roverd
|
|||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
"math"
|
||||||
"os"
|
"os"
|
||||||
@@ -14,7 +15,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
hostStatsInterval = 5 * time.Second
|
hostStatsInterval = 1 * time.Second
|
||||||
rootFilesystem = "/"
|
rootFilesystem = "/"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -63,7 +64,24 @@ type WiFiStats struct {
|
|||||||
TXBytes *uint64 `json:"txBytes,omitempty"`
|
TXBytes *uint64 `json:"txBytes,omitempty"`
|
||||||
RXPackets *uint64 `json:"rxPackets,omitempty"`
|
RXPackets *uint64 `json:"rxPackets,omitempty"`
|
||||||
TXPackets *uint64 `json:"txPackets,omitempty"`
|
TXPackets *uint64 `json:"txPackets,omitempty"`
|
||||||
|
DownloadMbps *float64 `json:"downloadMbps,omitempty"`
|
||||||
|
UploadMbps *float64 `json:"uploadMbps,omitempty"`
|
||||||
InactiveMs *int `json:"inactiveMs,omitempty"`
|
InactiveMs *int `json:"inactiveMs,omitempty"`
|
||||||
|
|
||||||
|
// networkSampledAt records the instant associated with the kernel byte
|
||||||
|
// counters. Keeping it out of JSON lets the websocket loop calculate rates
|
||||||
|
// with monotonic Go timestamps without expanding the browser contract with
|
||||||
|
// an implementation-only value.
|
||||||
|
networkSampledAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkRateSample is scoped to one rover websocket connection. A new
|
||||||
|
// connection intentionally starts a new baseline so counters from an old boot
|
||||||
|
// or network interface lifetime can never create an artificial traffic spike.
|
||||||
|
type networkRateSample struct {
|
||||||
|
rxBytes uint64
|
||||||
|
txBytes uint64
|
||||||
|
sampledAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
// CollectHostStats gathers every source independently so one missing kernel
|
// CollectHostStats gathers every source independently so one missing kernel
|
||||||
@@ -370,12 +388,81 @@ func collectWiFiStats(ctx context.Context) (*WiFiStats, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// The interface is used only to ask iw about the active connection. It is
|
// The interface is used only for local collection. It is not copied into
|
||||||
// not copied into WiFiStats because the UI does not need to expose it.
|
// WiFiStats because the UI does not need to expose Linux device names.
|
||||||
if err := enrichWiFiWithIW(ctx, iface, stats); err != nil {
|
iwErr := enrichWiFiWithIW(ctx, iface, stats)
|
||||||
return stats, err
|
|
||||||
|
// Read the kernel counters after iw because iw also provides cumulative
|
||||||
|
// station counters. The kernel interface values deliberately win: they are
|
||||||
|
// the host-traffic source used for both the cumulative display and Mbps math.
|
||||||
|
// Link capacity still comes independently from iw's bitrate fields.
|
||||||
|
counterErr := enrichWiFiWithNetworkCounters(iface, stats)
|
||||||
|
return stats, errors.Join(counterErr, iwErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
func enrichWiFiWithNetworkCounters(iface string, stats *WiFiStats) error {
|
||||||
|
basePath := "/sys/class/net/" + iface + "/statistics/"
|
||||||
|
rxBytes, err := readUintFile(basePath + "rx_bytes")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s receive bytes: %w", iface, err)
|
||||||
}
|
}
|
||||||
return stats, nil
|
txBytes, err := readUintFile(basePath + "tx_bytes")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("read %s transmit bytes: %w", iface, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stats.RXBytes = &rxBytes
|
||||||
|
stats.TXBytes = &txBytes
|
||||||
|
// Capture the timestamp immediately beside the counter reads so unrelated
|
||||||
|
// host-stat collection latency cannot distort the elapsed-time divisor.
|
||||||
|
stats.networkSampledAt = time.Now()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func readUintFile(path string) (uint64, error) {
|
||||||
|
raw, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return strconv.ParseUint(strings.TrimSpace(string(raw)), 10, 64)
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyNetworkThroughput(stats *WiFiStats, previous *networkRateSample) *networkRateSample {
|
||||||
|
if stats == nil || stats.RXBytes == nil || stats.TXBytes == nil || stats.networkSampledAt.IsZero() {
|
||||||
|
// Do not discard the last valid baseline during a temporary read failure.
|
||||||
|
// The next successful calculation then covers the full elapsed interval and
|
||||||
|
// remains an accurate average for all traffic transferred during the gap.
|
||||||
|
return previous
|
||||||
|
}
|
||||||
|
|
||||||
|
current := &networkRateSample{
|
||||||
|
rxBytes: *stats.RXBytes,
|
||||||
|
txBytes: *stats.TXBytes,
|
||||||
|
sampledAt: stats.networkSampledAt,
|
||||||
|
}
|
||||||
|
if previous == nil {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := current.sampledAt.Sub(previous.sampledAt).Seconds()
|
||||||
|
// Linux counters can return to zero after an interface reset. Re-baselining
|
||||||
|
// on any decrease prevents unsigned underflow from becoming a huge false
|
||||||
|
// throughput spike in the host-stat card.
|
||||||
|
if elapsed <= 0 || current.rxBytes < previous.rxBytes || current.txBytes < previous.txBytes {
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadMbps := bytesToMbps(current.rxBytes-previous.rxBytes, elapsed)
|
||||||
|
uploadMbps := bytesToMbps(current.txBytes-previous.txBytes, elapsed)
|
||||||
|
stats.DownloadMbps = &downloadMbps
|
||||||
|
stats.UploadMbps = &uploadMbps
|
||||||
|
return current
|
||||||
|
}
|
||||||
|
|
||||||
|
func bytesToMbps(byteDelta uint64, elapsedSeconds float64) float64 {
|
||||||
|
// Mbps uses decimal megabits, matching network equipment and link-rate
|
||||||
|
// conventions: eight bits per byte and 1,000,000 bits per megabit.
|
||||||
|
return roundOneDecimal((float64(byteDelta) * 8) / elapsedSeconds / 1_000_000)
|
||||||
}
|
}
|
||||||
|
|
||||||
func readWirelessStats() (string, *WiFiStats, error) {
|
func readWirelessStats() (string, *WiFiStats, error) {
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package roverd
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputCalculatesMbpsFromActualElapsedTime(t *testing.T) {
|
||||||
|
startedAt := time.Unix(100, 0)
|
||||||
|
previous := &networkRateSample{rxBytes: 1_000, txBytes: 2_000, sampledAt: startedAt}
|
||||||
|
rxBytes := uint64(2_001_000)
|
||||||
|
txBytes := uint64(1_002_000)
|
||||||
|
stats := &WiFiStats{
|
||||||
|
RXBytes: &rxBytes,
|
||||||
|
TXBytes: &txBytes,
|
||||||
|
networkSampledAt: startedAt.Add(2 * time.Second),
|
||||||
|
}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, previous)
|
||||||
|
|
||||||
|
if stats.DownloadMbps == nil || *stats.DownloadMbps != 8.0 {
|
||||||
|
t.Fatalf("expected 8.0 Mbps download, got %v", stats.DownloadMbps)
|
||||||
|
}
|
||||||
|
if stats.UploadMbps == nil || *stats.UploadMbps != 4.0 {
|
||||||
|
t.Fatalf("expected 4.0 Mbps upload, got %v", stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
|
||||||
|
t.Fatalf("expected current counters to become the next baseline, got %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputFirstSampleOnlyEstablishesBaseline(t *testing.T) {
|
||||||
|
rxBytes := uint64(100)
|
||||||
|
txBytes := uint64(200)
|
||||||
|
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: time.Unix(100, 0)}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, nil)
|
||||||
|
|
||||||
|
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||||
|
t.Fatalf("expected no rates for the first sample, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil {
|
||||||
|
t.Fatal("expected the first valid sample to establish a baseline")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputCounterResetEstablishesNewBaseline(t *testing.T) {
|
||||||
|
startedAt := time.Unix(100, 0)
|
||||||
|
previous := &networkRateSample{rxBytes: 10_000, txBytes: 20_000, sampledAt: startedAt}
|
||||||
|
rxBytes := uint64(10)
|
||||||
|
txBytes := uint64(20)
|
||||||
|
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: startedAt.Add(time.Second)}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, previous)
|
||||||
|
|
||||||
|
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||||
|
t.Fatalf("expected no rates after a counter reset, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil || next.rxBytes != rxBytes || next.txBytes != txBytes {
|
||||||
|
t.Fatalf("expected reset counters to become the new baseline, got %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestApplyNetworkThroughputInvalidElapsedTimeEstablishesNewBaseline(t *testing.T) {
|
||||||
|
sampledAt := time.Unix(100, 0)
|
||||||
|
previous := &networkRateSample{rxBytes: 100, txBytes: 200, sampledAt: sampledAt}
|
||||||
|
rxBytes := uint64(200)
|
||||||
|
txBytes := uint64(300)
|
||||||
|
stats := &WiFiStats{RXBytes: &rxBytes, TXBytes: &txBytes, networkSampledAt: sampledAt}
|
||||||
|
|
||||||
|
next := applyNetworkThroughput(stats, previous)
|
||||||
|
|
||||||
|
if stats.DownloadMbps != nil || stats.UploadMbps != nil {
|
||||||
|
t.Fatalf("expected no rates with zero elapsed time, got download=%v upload=%v", stats.DownloadMbps, stats.UploadMbps)
|
||||||
|
}
|
||||||
|
if next == nil || next.sampledAt != sampledAt {
|
||||||
|
t.Fatalf("expected invalid timing sample to become the new baseline, got %#v", next)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -531,14 +531,21 @@ func (c *WSClient) forwardEvents(ctx context.Context, conn *websocket.Conn) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
|
func (c *WSClient) forwardHostStats(ctx context.Context, conn *websocket.Conn) {
|
||||||
|
var previousNetworkSample *networkRateSample
|
||||||
|
|
||||||
send := func() bool {
|
send := func() bool {
|
||||||
// Host stats are collected on demand so each outbound message describes
|
// Host stats are collected on demand so each outbound message describes
|
||||||
// the current Pi state. Collection failures are encoded into the stats
|
// the current Pi state. Collection failures are encoded into the stats
|
||||||
// payload, which keeps this telemetry path from closing the rover socket.
|
// payload, which keeps this telemetry path from closing the rover socket.
|
||||||
|
stats := CollectHostStats(ctx)
|
||||||
|
// Throughput is derived here because this loop owns the ordered, periodic
|
||||||
|
// samples for one connection. CollectHostStats stays independent, while a
|
||||||
|
// reconnect automatically receives a clean counter baseline.
|
||||||
|
previousNetworkSample = applyNetworkThroughput(stats.WiFi, previousNetworkSample)
|
||||||
msg := hostStatsMessage{
|
msg := hostStatsMessage{
|
||||||
Type: "hostStats",
|
Type: "hostStats",
|
||||||
Timestamp: time.Now().UnixMilli(),
|
Timestamp: time.Now().UnixMilli(),
|
||||||
Stats: CollectHostStats(ctx),
|
Stats: stats,
|
||||||
}
|
}
|
||||||
if err := writeJSON(ctx, conn, msg); err != nil {
|
if err := writeJSON(ctx, conn, msg); err != nil {
|
||||||
c.log.Printf("host stats send failed: %v", err)
|
c.log.Printf("host stats send failed: %v", err)
|
||||||
|
|||||||
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
@@ -78,8 +78,8 @@
|
|||||||
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
<script defer src="https://analytics.otter.land/script.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land"></script>
|
||||||
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
<script defer src="https://analytics.otter.land/recorder.js" data-website-id="82dd56a5-db44-4279-bd1e-a4d9fee39af7" data-domains="rover.otter.land" data-sample-rate="0.15" data-mask-level="moderate" data-max-duration="300000"></script>
|
||||||
<title>Roomba Rover</title>
|
<title>Roomba Rover</title>
|
||||||
<script type="module" crossorigin src="/assets/index-Da9ufxPv.js"></script>
|
<script type="module" crossorigin src="/assets/index-C-g10Rjz.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-BcBTKEa5.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-BwjDTdpq.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const { isDeterred } = require('../verificationService');
|
|||||||
const logger = require('../../globals/logger').child('commandService');
|
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 pendingCommands = new Map(); // id -> { roverId }
|
const pendingCommands = new Map(); // id -> { roverId }
|
||||||
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
const lastDriveActivity = new Map(); // roverId -> { ts, socketId, direction, speed, isAdmin }
|
||||||
@@ -93,6 +94,31 @@ function issueCommand(roverId, payload) {
|
|||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The protection service owns decisions about when a held command must be
|
||||||
|
resent at a lower output. Injecting this raw transport function keeps those
|
||||||
|
resends on the same rover websocket path as every other server command while
|
||||||
|
avoiding a circular dependency from the protection service back into this
|
||||||
|
socket-facing module.
|
||||||
|
*/
|
||||||
|
overcurrentProtectionService.configureCommandIssuer((roverId, payload) => {
|
||||||
|
const blockedUntil = driveCooldowns.get(roverId);
|
||||||
|
const safetyCooldownActive = blockedUntil && Date.now() < blockedUntil;
|
||||||
|
if (safetyCooldownActive && getCommandMotionMagnitude(payload?.type, payload) > 0) {
|
||||||
|
/*
|
||||||
|
Private-rover and dock safety own the existing command cooldown map. A
|
||||||
|
rate-limited protection resend must respect those independent systems;
|
||||||
|
otherwise this new service could restart drive or brushes immediately
|
||||||
|
after an unrelated safety feature deliberately stopped them. Returning
|
||||||
|
false tells the protection service to retry after the cooldown instead of
|
||||||
|
recording an output that never reached the rover.
|
||||||
|
*/
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
issueCommand(roverId, payload);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
function handleAck(msg) {
|
function handleAck(msg) {
|
||||||
const pending = pendingCommands.get(msg.id);
|
const pending = pendingCommands.get(msg.id);
|
||||||
if (!pending) return;
|
if (!pending) return;
|
||||||
@@ -226,7 +252,7 @@ io.on('connection', (socket) => {
|
|||||||
if (type === 'audioLevels') {
|
if (type === 'audioLevels') {
|
||||||
throw new Error('audioLevels command is service-managed');
|
throw new Error('audioLevels command is service-managed');
|
||||||
}
|
}
|
||||||
const payload = data ? { ...data } : {};
|
let payload = data ? { ...data } : {};
|
||||||
if (type === 'headlight' && isHeadlightBlocked()) {
|
if (type === 'headlight' && isHeadlightBlocked()) {
|
||||||
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
logger.info('Ignoring headlight command while darkness lock is active', { socketId: socket.id, roverId });
|
||||||
reply({ ignored: true, reason: 'darknessActive' });
|
reply({ ignored: true, reason: 'darknessActive' });
|
||||||
@@ -291,6 +317,19 @@ io.on('connection', (socket) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (type === 'drive' || type === 'motors') {
|
||||||
|
/*
|
||||||
|
Role is supplied at the command boundary because telemetry does not
|
||||||
|
identify the operator who produced the active motor intent. Admin and
|
||||||
|
lockdown commands therefore enter the service explicitly bypassed;
|
||||||
|
they are recorded for status visibility but are never scaled, blocked,
|
||||||
|
or countermanded by a later sensor frame.
|
||||||
|
*/
|
||||||
|
payload = overcurrentProtectionService.protectCommand(roverId, type, payload, {
|
||||||
|
bypassed: isAdminSocket,
|
||||||
|
});
|
||||||
|
}
|
||||||
const id = issueCommand(roverId, { type, ...payload });
|
const id = issueCommand(roverId, { type, ...payload });
|
||||||
logger.info('Queued command', socket.id, roverId, type);
|
logger.info('Queued command', socket.id, roverId, type);
|
||||||
if (shouldRecordTurnActivity(type, payload)) {
|
if (shouldRecordTurnActivity(type, payload)) {
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ module.exports = {
|
|||||||
setLightColor: runtimeEngine.setLightColor,
|
setLightColor: runtimeEngine.setLightColor,
|
||||||
setLightWhite: runtimeEngine.setLightWhite,
|
setLightWhite: runtimeEngine.setLightWhite,
|
||||||
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
|
setAllControllableEntitiesState: runtimeEngine.setAllControllableEntitiesState,
|
||||||
|
setRandomColorScene: runtimeEngine.setRandomColorScene,
|
||||||
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
setLightsLockedOn: runtimeEngine.setLightsLockedOn,
|
||||||
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
|
toggleLightsLockedOn: runtimeEngine.toggleLightsLockedOn,
|
||||||
homeAssistantEvents: events,
|
homeAssistantEvents: events,
|
||||||
|
|||||||
@@ -196,6 +196,72 @@ function createRuntimeEngine(deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createBrightRandomRgbColor() {
|
||||||
|
// A completely random RGB triplet frequently produces colors that are very
|
||||||
|
// dark, gray, or visually indistinguishable from a bulb being off. Choosing
|
||||||
|
// a random hue at full saturation and brightness still gives every bulb a
|
||||||
|
// genuinely random color while keeping the requested room effect vivid.
|
||||||
|
const hueSegment = Math.random() * 6;
|
||||||
|
const segmentIndex = Math.floor(hueSegment);
|
||||||
|
const risingChannel = Math.round((hueSegment - segmentIndex) * 255);
|
||||||
|
const fallingChannel = 255 - risingChannel;
|
||||||
|
|
||||||
|
switch (segmentIndex) {
|
||||||
|
case 0: return [255, risingChannel, 0];
|
||||||
|
case 1: return [fallingChannel, 255, 0];
|
||||||
|
case 2: return [0, 255, risingChannel];
|
||||||
|
case 3: return [0, fallingChannel, 255];
|
||||||
|
case 4: return [risingChannel, 0, 255];
|
||||||
|
default: return [255, 0, fallingChannel];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setRandomColorScene(options = {}) {
|
||||||
|
const source = String(options?.source || 'homeAssistant:setRandomColorScene');
|
||||||
|
const entities = Array.from(entityConfig.values()).map((meta) => ({
|
||||||
|
meta,
|
||||||
|
state: entityState.get(meta.id) || buildState(meta, null),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// RGB capability comes from Home Assistant's live supported_color_modes
|
||||||
|
// snapshot. This avoids a second operator-maintained list and makes newly
|
||||||
|
// replaced bulbs automatically participate once Home Assistant reports
|
||||||
|
// their capabilities. Everything else is turned off, including switches
|
||||||
|
// and white-only lights, exactly matching the scene's requested boundary.
|
||||||
|
const operations = entities.map(({ meta, state }) => {
|
||||||
|
if (state.supportsColor) {
|
||||||
|
return setLightColor(meta.id, createBrightRandomRgbColor());
|
||||||
|
}
|
||||||
|
return setEntityState(meta.id, 'off', { source: `${source}:non-rgb-off` });
|
||||||
|
});
|
||||||
|
const results = await Promise.allSettled(operations);
|
||||||
|
const failures = results
|
||||||
|
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
|
||||||
|
.filter(({ result }) => result.status === 'rejected')
|
||||||
|
.map(({ result, entityId }) => ({ entityId, error: result.reason?.message || 'unknown error' }));
|
||||||
|
const succeeded = results
|
||||||
|
.map((result, index) => ({ result, entityId: entities[index].meta.id }))
|
||||||
|
.filter(({ result }) => result.status === 'fulfilled')
|
||||||
|
.map(({ entityId }) => entityId);
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
logger.warn('Some Home Assistant random color scene updates failed', {
|
||||||
|
total: entities.length,
|
||||||
|
failed: failures.length,
|
||||||
|
failures,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
total: entities.length,
|
||||||
|
colorLights: entities.filter(({ state }) => state.supportsColor).length,
|
||||||
|
nonColorEntities: entities.filter(({ state }) => !state.supportsColor).length,
|
||||||
|
succeeded,
|
||||||
|
failures,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function setEntityLockedOnWhite(entityId, options = {}) {
|
async function setEntityLockedOnWhite(entityId, options = {}) {
|
||||||
const meta = entityConfig.get(entityId);
|
const meta = entityConfig.get(entityId);
|
||||||
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
|
const source = String(options?.source || 'homeAssistant:setEntityLockedOnWhite');
|
||||||
@@ -498,6 +564,7 @@ function createRuntimeEngine(deps) {
|
|||||||
setLightColor,
|
setLightColor,
|
||||||
setLightWhite,
|
setLightWhite,
|
||||||
setAllControllableEntitiesState,
|
setAllControllableEntitiesState,
|
||||||
|
setRandomColorScene,
|
||||||
setAllControllableEntitiesLockedOnWhite,
|
setAllControllableEntitiesLockedOnWhite,
|
||||||
setLightsLockedOn,
|
setLightsLockedOn,
|
||||||
toggleLightsLockedOn,
|
toggleLightsLockedOn,
|
||||||
|
|||||||
@@ -33,6 +33,34 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isAdmin = Boolean(message.actor?.isAdmin);
|
||||||
|
const adminActions = new Set(['status', 'lock', 'unlock']);
|
||||||
|
|
||||||
|
// The lights namespace intentionally contains both public feature actions
|
||||||
|
// and room-policy actions. The shared dispatcher applies the current server
|
||||||
|
// mode to the feature as a whole; this focused check preserves the stronger
|
||||||
|
// historical permission on status/lock/unlock without making on/off/colors
|
||||||
|
// admin-only during normal open or turns operation.
|
||||||
|
if (adminActions.has(action) && !isAdmin) {
|
||||||
|
await message.reply({
|
||||||
|
content: 'Only admins can manage the room-light lock.',
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// An active lock is a policy boundary for ordinary feature commands. Admin
|
||||||
|
// lock management remains available, but public scene commands must not
|
||||||
|
// silently defeat a locked-on or locked-off room state.
|
||||||
|
const lightPolicy = homeAssistantService.getLightPolicyState?.() || {};
|
||||||
|
if ((action === 'on' || action === 'off' || action === 'colors') && lightPolicy.locked) {
|
||||||
|
await message.reply({
|
||||||
|
content: describeLightPolicy(lightPolicy),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action === 'status') {
|
if (action === 'status') {
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
|
content: describeLightPolicy(homeAssistantService.getLightPolicyState?.() || {}),
|
||||||
@@ -41,9 +69,35 @@ function createLightsCommand({ homeAssistantService, sanitizeMentions, config })
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (action === 'on' || action === 'off' || action === 'colors') {
|
||||||
|
try {
|
||||||
|
const result = action === 'colors'
|
||||||
|
? await homeAssistantService.setRandomColorScene({ source: 'bot-command:lights:colors' })
|
||||||
|
: await homeAssistantService.setAllControllableEntitiesState(action, {
|
||||||
|
source: `bot-command:lights:${action}`,
|
||||||
|
});
|
||||||
|
const failed = result?.failures?.length || 0;
|
||||||
|
const succeeded = result?.succeeded?.length || 0;
|
||||||
|
const description = action === 'colors'
|
||||||
|
? `Applied random colors to ${result?.colorLights || 0} RGB lights and requested off for ${result?.nonColorEntities || 0} non-RGB lights.`
|
||||||
|
: `Turned ${action} ${succeeded} room lights.`;
|
||||||
|
const failureSuffix = failed ? ` ${failed} failed.` : '';
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`${description}${failureSuffix}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
await message.reply({
|
||||||
|
content: sanitizeMentions(`Failed to update room lights: ${err.message}`),
|
||||||
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (action !== 'lock' && action !== 'unlock') {
|
if (action !== 'lock' && action !== 'unlock') {
|
||||||
await message.reply({
|
await message.reply({
|
||||||
content: `Invalid lights command. Use \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
|
content: `Invalid lights command. Use \`${commandPrefix} lights on\`, \`${commandPrefix} lights off\`, \`${commandPrefix} lights colors\`, \`${commandPrefix} lights lock\`, \`${commandPrefix} lights unlock\`, or \`${commandPrefix} lights status\`.`,
|
||||||
allowedMentions: { parse: [], repliedUser: false },
|
allowedMentions: { parse: [], repliedUser: false },
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -93,9 +93,10 @@ function createCommandHandlers(deps) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Actions in this set can change operational safety or access policy, so
|
// Actions in this set can change operational safety or access policy, so
|
||||||
// lockdown mode narrows them from normal admins to lockdown admins. Room
|
// lockdown mode narrows them from normal admins to lockdown admins. Lights
|
||||||
// light locking belongs here because it can force the physical room lights
|
// is included because its lock/unlock subcommands change room policy. Its
|
||||||
// on and disables ordinary Home Assistant room controls for everyone else.
|
// ordinary on/off/color actions are also intentionally restricted to a
|
||||||
|
// lockdown admin while the entire server is in lockdown.
|
||||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
|
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
|
||||||
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
||||||
const CATEGORIES = {
|
const CATEGORIES = {
|
||||||
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
||||||
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'lights', 'kick', 'verify', 'deter'] },
|
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] },
|
||||||
features: { title: 'Features', names: ['lift', 'neato'] },
|
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
|
||||||
discord: { title: 'Discord', names: ['bridge'] },
|
discord: { title: 'Discord', names: ['bridge'] },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -19,7 +19,18 @@ function buildCommandRegistry(prefix, timeCommand) {
|
|||||||
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
|
mode: { category: 'admin', summary: 'Change the server mode.', usage: [`${prefix} mode <open|turns|admin|lockdown>`], access: 'Admin', permission: 'admin' },
|
||||||
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
|
reason: { category: 'admin', summary: 'Show, set, or clear the admin-mode reason.', usage: [`${prefix} reason [text|clear]`], access: 'Admin to change' },
|
||||||
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
|
goal: { category: 'admin', summary: 'Show, set, or clear the global objective.', usage: [`${prefix} goal [text|clear]`], access: 'Admin to change' },
|
||||||
lights: { category: 'admin', summary: 'Show or change the room-light lock.', usage: [`${prefix} lights <status|lock|unlock>`], access: 'Admin', permission: 'admin' },
|
lights: {
|
||||||
|
category: 'features',
|
||||||
|
summary: 'Control room lights or manage the admin light lock.',
|
||||||
|
usage: [
|
||||||
|
`${prefix} lights <on|off|colors>`,
|
||||||
|
`${prefix} lights <status|lock|unlock>`,
|
||||||
|
],
|
||||||
|
access: 'Light controls are public unless server access is restricted; lock controls require admin',
|
||||||
|
permission: 'access-mode',
|
||||||
|
requiredFeature: 'homeAssistant',
|
||||||
|
unavailableLabel: 'Home Assistant',
|
||||||
|
},
|
||||||
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
|
kick: { category: 'admin', summary: 'Remove a user from their current rover.', usage: [`${prefix} kick <user> [reason]`], access: 'Admin', permission: 'admin' },
|
||||||
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
verify: { category: 'admin', summary: 'List or remove verified identities.', usage: [`${prefix} verify list`, `${prefix} verify remove <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
||||||
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
deter: { category: 'admin', summary: 'List, add, or remove identity deterrence.', usage: [`${prefix} deter list`, `${prefix} deter ban <identity>`, `${prefix} deter unban <identity>`], access: 'Lockdown admin', permission: 'lockdown-admin' },
|
||||||
|
|||||||
@@ -0,0 +1,587 @@
|
|||||||
|
// Overcurrent Protection Service
|
||||||
|
// Purpose: Owns fleet-wide, server-authoritative motor stress calculation and command limiting.
|
||||||
|
// Scope: Keeps overcurrent policy out of rover-manager sensor orchestration while combining command intent with decoded telemetry.
|
||||||
|
|
||||||
|
const logger = require('../../globals/logger').child('overcurrentProtectionService');
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG = Object.freeze({
|
||||||
|
minimumUsefulWheelIntent: 75,
|
||||||
|
stressGrace: 0.25,
|
||||||
|
wheelProgressWindowSec: 0.4,
|
||||||
|
encoderNoiseFloorMmPerSec: 15,
|
||||||
|
fullStallProgressRatio: 0.1,
|
||||||
|
movingProgressRatio: 0.6,
|
||||||
|
movingOrUnknownRatePerSec: 0.2,
|
||||||
|
fullStallRatePerSec: 0.6,
|
||||||
|
wheelRecoveryDelaySec: 0.75,
|
||||||
|
wheelRecoveryRatePerSec: 0.4,
|
||||||
|
brushOvercurrentRatePerSec: 1,
|
||||||
|
brushRecoveryRatePerSec: 0.75,
|
||||||
|
clearBeforeUnlockSec: 0.75,
|
||||||
|
outputRateMs: 250,
|
||||||
|
maxTelemetryDeltaSec: 0.5,
|
||||||
|
});
|
||||||
|
|
||||||
|
const MOTOR_KEYS = Object.freeze(['leftWheel', 'rightWheel', 'mainBrush', 'sideBrush']);
|
||||||
|
|
||||||
|
function clampUnit(value) {
|
||||||
|
if (!Number.isFinite(value)) return 0;
|
||||||
|
return Math.max(0, Math.min(1, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
function finiteNumber(value, fallback = 0) {
|
||||||
|
const number = Number(value);
|
||||||
|
return Number.isFinite(number) ? number : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createMotorState() {
|
||||||
|
return {
|
||||||
|
overcurrent: false,
|
||||||
|
commandedSpeed: 0,
|
||||||
|
measuredSpeed: null,
|
||||||
|
currentMa: null,
|
||||||
|
stallFactor: 0,
|
||||||
|
progressRatio: null,
|
||||||
|
classification: 'unknown',
|
||||||
|
progressSamples: [],
|
||||||
|
windowCommandSign: 0,
|
||||||
|
clearSec: 0,
|
||||||
|
stress: 0,
|
||||||
|
cap: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createRoverState() {
|
||||||
|
return {
|
||||||
|
bypassed: false,
|
||||||
|
lastTelemetryAt: 0,
|
||||||
|
driveIntent: { left: 0, right: 0 },
|
||||||
|
auxIntent: { main: 0, side: 0, vacuum: 0 },
|
||||||
|
driveBlocked: false,
|
||||||
|
requiresNeutral: false,
|
||||||
|
neutralSeen: false,
|
||||||
|
driveClearSec: 0,
|
||||||
|
stopReason: null,
|
||||||
|
lastDriveOutputAt: 0,
|
||||||
|
lastAuxOutputAt: 0,
|
||||||
|
lastDriveOutput: { left: 0, right: 0 },
|
||||||
|
lastAuxOutput: { main: 0, side: 0, vacuum: 0 },
|
||||||
|
motors: {
|
||||||
|
leftWheel: createMotorState(),
|
||||||
|
rightWheel: createMotorState(),
|
||||||
|
mainBrush: createMotorState(),
|
||||||
|
sideBrush: createMotorState(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOvercurrentProtectionService(options = {}) {
|
||||||
|
const config = Object.freeze({ ...DEFAULT_CONFIG, ...(options.config || {}) });
|
||||||
|
const states = new Map();
|
||||||
|
let commandIssuer = typeof options.issueCommand === 'function' ? options.issueCommand : null;
|
||||||
|
|
||||||
|
function getState(roverId) {
|
||||||
|
const key = String(roverId || '');
|
||||||
|
if (!states.has(key)) states.set(key, createRoverState());
|
||||||
|
return states.get(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetProtectionState(state, { bypassed = state.bypassed } = {}) {
|
||||||
|
/*
|
||||||
|
An administrator bypass is a change of authority, not merely a cap of
|
||||||
|
one. Clearing accumulated stress here prevents a previous user's event
|
||||||
|
from unexpectedly affecting the first command after authority changes.
|
||||||
|
Both command intents are cleared before the caller records the new command
|
||||||
|
so telemetry cannot apply a previous operator's held drive or brush value
|
||||||
|
after authority changes.
|
||||||
|
*/
|
||||||
|
state.bypassed = Boolean(bypassed);
|
||||||
|
state.lastTelemetryAt = 0;
|
||||||
|
state.driveBlocked = false;
|
||||||
|
state.requiresNeutral = false;
|
||||||
|
state.neutralSeen = false;
|
||||||
|
state.driveClearSec = 0;
|
||||||
|
state.stopReason = null;
|
||||||
|
state.driveIntent = { left: 0, right: 0 };
|
||||||
|
state.auxIntent = { main: 0, side: 0, vacuum: 0 };
|
||||||
|
state.lastDriveOutput = { left: 0, right: 0 };
|
||||||
|
state.lastAuxOutput = { main: 0, side: 0, vacuum: 0 };
|
||||||
|
state.lastDriveOutputAt = 0;
|
||||||
|
state.lastAuxOutputAt = 0;
|
||||||
|
MOTOR_KEYS.forEach((key) => {
|
||||||
|
state.motors[key] = createMotorState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function configureCommandIssuer(nextIssuer) {
|
||||||
|
commandIssuer = typeof nextIssuer === 'function' ? nextIssuer : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function calculateCap(stress) {
|
||||||
|
/*
|
||||||
|
The grace region absorbs short mechanical events such as initial wheel
|
||||||
|
acceleration and direction changes. Above it, the remaining stress range
|
||||||
|
maps linearly to output so the cap reaches exactly zero at hard-stop
|
||||||
|
stress instead of leaving a small command applied to a stalled motor.
|
||||||
|
*/
|
||||||
|
const grace = clampUnit(config.stressGrace);
|
||||||
|
if (stress <= grace) return 1;
|
||||||
|
const usableRange = Math.max(0.0001, 1 - grace);
|
||||||
|
return clampUnit(1 - (stress - grace) / usableRange);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDriveCap(state) {
|
||||||
|
return Math.min(state.motors.leftWheel.cap, state.motors.rightWheel.cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleDrive(state, driveDirect = state.driveIntent) {
|
||||||
|
if (state.bypassed) return { ...driveDirect };
|
||||||
|
if (state.driveBlocked) return { left: 0, right: 0 };
|
||||||
|
const cap = getDriveCap(state);
|
||||||
|
return {
|
||||||
|
left: Math.round(finiteNumber(driveDirect?.left) * cap),
|
||||||
|
right: Math.round(finiteNumber(driveDirect?.right) * cap),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function scaleAux(state, motorPwm = state.auxIntent) {
|
||||||
|
if (state.bypassed) return { ...motorPwm };
|
||||||
|
return {
|
||||||
|
main: Math.round(finiteNumber(motorPwm?.main) * state.motors.mainBrush.cap),
|
||||||
|
side: Math.round(finiteNumber(motorPwm?.side) * state.motors.sideBrush.cap),
|
||||||
|
// Create 2/Roomba 600 does not expose a vacuum overcurrent bit, so this
|
||||||
|
// service must not imply that it can measure or limit vacuum motor stress.
|
||||||
|
vacuum: Math.round(finiteNumber(motorPwm?.vacuum)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDriveIntent(state) {
|
||||||
|
return Boolean(state.driveIntent.left || state.driveIntent.right);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAuxIntent(state) {
|
||||||
|
return Boolean(state.auxIntent.main || state.auxIntent.side || state.auxIntent.vacuum);
|
||||||
|
}
|
||||||
|
|
||||||
|
function driveOutputsEqual(left, right) {
|
||||||
|
return left.left === right.left && left.right === right.right;
|
||||||
|
}
|
||||||
|
|
||||||
|
function auxOutputsEqual(left, right) {
|
||||||
|
return left.main === right.main && left.side === right.side && left.vacuum === right.vacuum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function issueAdjustedCommand(roverId, payload) {
|
||||||
|
if (!commandIssuer) return false;
|
||||||
|
try {
|
||||||
|
/*
|
||||||
|
The injected issuer is the raw server-to-roverd transport function.
|
||||||
|
Calling it here intentionally avoids routing a service-generated update
|
||||||
|
through the socket authorization/filter path a second time.
|
||||||
|
*/
|
||||||
|
return commandIssuer(roverId, payload) !== false;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to issue overcurrent protection command', {
|
||||||
|
roverId,
|
||||||
|
type: payload?.type,
|
||||||
|
error: err.message,
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function protectCommand(roverId, type, payload = {}, context = {}) {
|
||||||
|
const state = getState(roverId);
|
||||||
|
const bypassed = Boolean(context.bypassed);
|
||||||
|
if (bypassed !== state.bypassed) {
|
||||||
|
resetProtectionState(state, { bypassed });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'drive' && payload?.driveDirect) {
|
||||||
|
state.driveIntent = {
|
||||||
|
left: finiteNumber(payload.driveDirect.left),
|
||||||
|
right: finiteNumber(payload.driveDirect.right),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (bypassed) {
|
||||||
|
state.lastDriveOutput = { ...state.driveIntent };
|
||||||
|
state.lastDriveOutputAt = Date.now();
|
||||||
|
return { ...payload, driveDirect: { ...state.driveIntent } };
|
||||||
|
}
|
||||||
|
|
||||||
|
const neutral = !state.driveIntent.left && !state.driveIntent.right;
|
||||||
|
if (neutral) {
|
||||||
|
/*
|
||||||
|
A real neutral command proves the operator released their controls.
|
||||||
|
Merely observing zero encoder motion cannot provide that assurance,
|
||||||
|
because a held command against an obstruction also produces no motion.
|
||||||
|
*/
|
||||||
|
state.neutralSeen = true;
|
||||||
|
if (state.driveBlocked && state.driveClearSec >= config.clearBeforeUnlockSec) {
|
||||||
|
state.driveBlocked = false;
|
||||||
|
state.requiresNeutral = false;
|
||||||
|
state.stopReason = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const driveDirect = scaleDrive(state);
|
||||||
|
state.lastDriveOutput = { ...driveDirect };
|
||||||
|
state.lastDriveOutputAt = Date.now();
|
||||||
|
return { ...payload, driveDirect };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === 'motors' && payload?.motorPwm) {
|
||||||
|
state.auxIntent = {
|
||||||
|
main: finiteNumber(payload.motorPwm.main),
|
||||||
|
side: finiteNumber(payload.motorPwm.side),
|
||||||
|
vacuum: finiteNumber(payload.motorPwm.vacuum),
|
||||||
|
};
|
||||||
|
const motorPwm = scaleAux(state);
|
||||||
|
state.lastAuxOutput = { ...motorPwm };
|
||||||
|
state.lastAuxOutputAt = Date.now();
|
||||||
|
return { ...payload, motorPwm };
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWheelProgressWindow(motor, commandSign = 0) {
|
||||||
|
motor.progressSamples = [];
|
||||||
|
motor.windowCommandSign = commandSign;
|
||||||
|
motor.progressRatio = null;
|
||||||
|
motor.classification = 'unknown';
|
||||||
|
motor.stallFactor = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendWheelProgressSample(motor, sample) {
|
||||||
|
motor.progressSamples.push(sample);
|
||||||
|
let windowSec = motor.progressSamples.reduce((total, entry) => total + entry.deltaSec, 0);
|
||||||
|
|
||||||
|
/*
|
||||||
|
Keep an actual rolling time window rather than periodically clearing a
|
||||||
|
bucket. If the oldest sample crosses the boundary, retain only its
|
||||||
|
proportional tail so classification does not depend on sensor cadence.
|
||||||
|
*/
|
||||||
|
while (motor.progressSamples.length && windowSec > config.wheelProgressWindowSec) {
|
||||||
|
const excessSec = windowSec - config.wheelProgressWindowSec;
|
||||||
|
const oldest = motor.progressSamples[0];
|
||||||
|
if (oldest.deltaSec <= excessSec) {
|
||||||
|
motor.progressSamples.shift();
|
||||||
|
windowSec -= oldest.deltaSec;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const retainedFraction = (oldest.deltaSec - excessSec) / oldest.deltaSec;
|
||||||
|
oldest.deltaSec -= excessSec;
|
||||||
|
oldest.expectedDistance *= retainedFraction;
|
||||||
|
oldest.alignedDistance *= retainedFraction;
|
||||||
|
windowSec = config.wheelProgressWindowSec;
|
||||||
|
}
|
||||||
|
return windowSec;
|
||||||
|
}
|
||||||
|
|
||||||
|
function classifyWheelProgress(motor, windowSec) {
|
||||||
|
if (windowSec < config.wheelProgressWindowSec * 0.9) return;
|
||||||
|
const totals = motor.progressSamples.reduce(
|
||||||
|
(result, sample) => ({
|
||||||
|
expectedDistance: result.expectedDistance + sample.expectedDistance,
|
||||||
|
alignedDistance: result.alignedDistance + sample.alignedDistance,
|
||||||
|
}),
|
||||||
|
{ expectedDistance: 0, alignedDistance: 0 },
|
||||||
|
);
|
||||||
|
if (totals.expectedDistance <= 0) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Signed, command-aligned distance lets forward/backward encoder wobble
|
||||||
|
cancel over the window. The fixed noise floor then removes small residual
|
||||||
|
bias from gearbox lash or a wheel module rocking without real travel.
|
||||||
|
*/
|
||||||
|
const averageExpectedSpeed = totals.expectedDistance / windowSec;
|
||||||
|
const averageAlignedSpeed = totals.alignedDistance / windowSec;
|
||||||
|
const usefulProgressSpeed = Math.max(0, averageAlignedSpeed - config.encoderNoiseFloorMmPerSec);
|
||||||
|
const progressRatio = clampUnit(usefulProgressSpeed / averageExpectedSpeed);
|
||||||
|
const ratioRange = Math.max(0.0001, config.movingProgressRatio - config.fullStallProgressRatio);
|
||||||
|
const stallFactor = clampUnit((config.movingProgressRatio - progressRatio) / ratioRange);
|
||||||
|
|
||||||
|
motor.progressRatio = progressRatio;
|
||||||
|
motor.stallFactor = stallFactor;
|
||||||
|
motor.classification = progressRatio <= config.fullStallProgressRatio
|
||||||
|
? 'stalled'
|
||||||
|
: progressRatio >= config.movingProgressRatio
|
||||||
|
? 'moving'
|
||||||
|
: 'partial';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateWheelMotor(motor, { overcurrent, command, intent, measured, currentMa, deltaSec }) {
|
||||||
|
const commandNumber = finiteNumber(command);
|
||||||
|
const commandMagnitude = Math.abs(commandNumber);
|
||||||
|
const commandSign = Math.sign(commandNumber);
|
||||||
|
const intentMagnitude = Math.abs(finiteNumber(intent));
|
||||||
|
// Null is intentionally checked before Number conversion because
|
||||||
|
// Number(null) is zero, which would falsely turn missing telemetry into a
|
||||||
|
// perfectly stalled wheel.
|
||||||
|
const measuredNumber = measured == null ? null : Number(measured);
|
||||||
|
const measuredValid = Number.isFinite(measuredNumber);
|
||||||
|
const usefulIntent = intentMagnitude >= config.minimumUsefulWheelIntent;
|
||||||
|
|
||||||
|
motor.overcurrent = Boolean(overcurrent);
|
||||||
|
motor.commandedSpeed = commandNumber;
|
||||||
|
motor.measuredSpeed = measuredValid ? measuredNumber : null;
|
||||||
|
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
|
||||||
|
motor.clearSec = motor.overcurrent ? 0 : motor.clearSec + deltaSec;
|
||||||
|
|
||||||
|
if (!motor.overcurrent) {
|
||||||
|
resetWheelProgressWindow(motor, commandSign);
|
||||||
|
} else if (!usefulIntent || commandMagnitude <= 0 || !measuredValid) {
|
||||||
|
// Low commands and missing telemetry are real overcurrent events, but
|
||||||
|
// neither supplies enough evidence to label the wheel mechanically stalled.
|
||||||
|
// Raw operator intent decides whether the classifier remains meaningful;
|
||||||
|
// the progressively smaller applied output must not erase an already
|
||||||
|
// confirmed stall merely because protection itself reduced it below the
|
||||||
|
// normal command threshold.
|
||||||
|
resetWheelProgressWindow(motor, commandSign);
|
||||||
|
} else {
|
||||||
|
if (motor.windowCommandSign !== commandSign) {
|
||||||
|
// Samples from opposite requested directions cannot share a progress
|
||||||
|
// window because their aligned distances describe different motion.
|
||||||
|
resetWheelProgressWindow(motor, commandSign);
|
||||||
|
}
|
||||||
|
if (deltaSec > 0) {
|
||||||
|
const windowSec = appendWheelProgressSample(motor, {
|
||||||
|
deltaSec,
|
||||||
|
expectedDistance: commandMagnitude * deltaSec,
|
||||||
|
alignedDistance: measuredNumber * commandSign * deltaSec,
|
||||||
|
});
|
||||||
|
classifyWheelProgress(motor, windowSec);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const riseRate = config.movingOrUnknownRatePerSec
|
||||||
|
+ (config.fullStallRatePerSec - config.movingOrUnknownRatePerSec) * motor.stallFactor;
|
||||||
|
const recoveryAllowed = !motor.overcurrent && motor.clearSec >= config.wheelRecoveryDelaySec;
|
||||||
|
motor.stress = clampUnit(
|
||||||
|
motor.stress
|
||||||
|
+ (motor.overcurrent
|
||||||
|
? riseRate * deltaSec
|
||||||
|
: recoveryAllowed
|
||||||
|
? -config.wheelRecoveryRatePerSec * deltaSec
|
||||||
|
: 0),
|
||||||
|
);
|
||||||
|
motor.cap = calculateCap(motor.stress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBrushMotor(motor, { overcurrent, command, currentMa, deltaSec }) {
|
||||||
|
motor.overcurrent = Boolean(overcurrent);
|
||||||
|
motor.commandedSpeed = finiteNumber(command);
|
||||||
|
motor.measuredSpeed = null;
|
||||||
|
motor.currentMa = Number.isFinite(Number(currentMa)) ? Number(currentMa) : null;
|
||||||
|
motor.stallFactor = 0;
|
||||||
|
motor.stress = clampUnit(
|
||||||
|
motor.stress
|
||||||
|
+ (motor.overcurrent
|
||||||
|
? config.brushOvercurrentRatePerSec * deltaSec
|
||||||
|
: -config.brushRecoveryRatePerSec * deltaSec),
|
||||||
|
);
|
||||||
|
motor.cap = calculateCap(motor.stress);
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeStopDrive(roverId, state) {
|
||||||
|
if (state.bypassed || state.driveBlocked || !hasDriveIntent(state)) return;
|
||||||
|
const stalledWheel = ['leftWheel', 'rightWheel'].find((key) => state.motors[key].stress >= 1);
|
||||||
|
if (!stalledWheel) return;
|
||||||
|
|
||||||
|
state.driveBlocked = true;
|
||||||
|
state.requiresNeutral = true;
|
||||||
|
state.neutralSeen = false;
|
||||||
|
state.driveClearSec = 0;
|
||||||
|
state.stopReason = stalledWheel;
|
||||||
|
state.lastDriveOutputAt = Date.now();
|
||||||
|
state.lastDriveOutput = { left: 0, right: 0 };
|
||||||
|
issueAdjustedCommand(roverId, {
|
||||||
|
type: 'drive',
|
||||||
|
driveDirect: { left: 0, right: 0 },
|
||||||
|
});
|
||||||
|
logger.warn('Stopped rover after persistent wheel overcurrent', {
|
||||||
|
roverId,
|
||||||
|
motor: stalledWheel,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function maybeResendScaledOutputs(roverId, state, now) {
|
||||||
|
if (state.bypassed) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
A held keyboard/gamepad value may not emit another browser command while
|
||||||
|
telemetry continues changing the cap. Rate-limited resends make each new
|
||||||
|
server calculation effective without requiring the user to move the
|
||||||
|
control again or flooding the Pi websocket at sensor-frame cadence.
|
||||||
|
*/
|
||||||
|
const nextDriveOutput = scaleDrive(state);
|
||||||
|
if (
|
||||||
|
hasDriveIntent(state)
|
||||||
|
&& !state.driveBlocked
|
||||||
|
&& !driveOutputsEqual(nextDriveOutput, state.lastDriveOutput)
|
||||||
|
&& now - state.lastDriveOutputAt >= config.outputRateMs
|
||||||
|
) {
|
||||||
|
const issued = issueAdjustedCommand(roverId, {
|
||||||
|
type: 'drive',
|
||||||
|
driveDirect: nextDriveOutput,
|
||||||
|
});
|
||||||
|
if (issued) {
|
||||||
|
state.lastDriveOutputAt = now;
|
||||||
|
state.lastDriveOutput = { ...nextDriveOutput };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextAuxOutput = scaleAux(state);
|
||||||
|
if (
|
||||||
|
hasAuxIntent(state)
|
||||||
|
&& !auxOutputsEqual(nextAuxOutput, state.lastAuxOutput)
|
||||||
|
&& now - state.lastAuxOutputAt >= config.outputRateMs
|
||||||
|
) {
|
||||||
|
const issued = issueAdjustedCommand(roverId, {
|
||||||
|
type: 'motors',
|
||||||
|
motorPwm: nextAuxOutput,
|
||||||
|
});
|
||||||
|
if (issued) {
|
||||||
|
state.lastAuxOutputAt = now;
|
||||||
|
state.lastAuxOutput = { ...nextAuxOutput };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function processTelemetry(roverId, sensors = {}, now = Date.now()) {
|
||||||
|
const state = getState(roverId);
|
||||||
|
const timestamp = finiteNumber(now, Date.now());
|
||||||
|
const previousAt = state.lastTelemetryAt;
|
||||||
|
state.lastTelemetryAt = timestamp;
|
||||||
|
|
||||||
|
if (state.bypassed) {
|
||||||
|
/*
|
||||||
|
Admin bypass means telemetry remains visible but cannot build hidden
|
||||||
|
stress or schedule a delayed stop. Motor observations are still copied
|
||||||
|
into the public snapshot so administrators can see the hardware warning
|
||||||
|
while deliberately retaining full control.
|
||||||
|
*/
|
||||||
|
state.driveBlocked = false;
|
||||||
|
state.requiresNeutral = false;
|
||||||
|
state.neutralSeen = false;
|
||||||
|
state.driveClearSec = 0;
|
||||||
|
state.stopReason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawDeltaSec = previousAt > 0 ? Math.max(0, (timestamp - previousAt) / 1000) : 0;
|
||||||
|
const deltaSec = state.bypassed
|
||||||
|
? 0
|
||||||
|
: Math.min(config.maxTelemetryDeltaSec, rawDeltaSec);
|
||||||
|
const flags = sensors?.wheelOvercurrents || {};
|
||||||
|
const speeds = sensors?.wheelSpeedsMmPerSecond || {};
|
||||||
|
|
||||||
|
updateWheelMotor(state.motors.leftWheel, {
|
||||||
|
overcurrent: flags.leftWheel,
|
||||||
|
command: state.lastDriveOutput.left,
|
||||||
|
intent: state.driveIntent.left,
|
||||||
|
measured: speeds.left,
|
||||||
|
currentMa: sensors?.wheelLeftCurrentMa,
|
||||||
|
deltaSec,
|
||||||
|
});
|
||||||
|
updateWheelMotor(state.motors.rightWheel, {
|
||||||
|
overcurrent: flags.rightWheel,
|
||||||
|
command: state.lastDriveOutput.right,
|
||||||
|
intent: state.driveIntent.right,
|
||||||
|
measured: speeds.right,
|
||||||
|
currentMa: sensors?.wheelRightCurrentMa,
|
||||||
|
deltaSec,
|
||||||
|
});
|
||||||
|
updateBrushMotor(state.motors.mainBrush, {
|
||||||
|
overcurrent: flags.mainBrush,
|
||||||
|
command: state.auxIntent.main,
|
||||||
|
currentMa: sensors?.mainBrushCurrentMa,
|
||||||
|
deltaSec,
|
||||||
|
});
|
||||||
|
updateBrushMotor(state.motors.sideBrush, {
|
||||||
|
overcurrent: flags.sideBrush,
|
||||||
|
command: state.auxIntent.side,
|
||||||
|
currentMa: sensors?.sideBrushCurrentMa,
|
||||||
|
deltaSec,
|
||||||
|
});
|
||||||
|
|
||||||
|
const wheelOvercurrent = Boolean(flags.leftWheel || flags.rightWheel);
|
||||||
|
state.driveClearSec = wheelOvercurrent ? 0 : state.driveClearSec + deltaSec;
|
||||||
|
if (
|
||||||
|
state.driveBlocked
|
||||||
|
&& state.neutralSeen
|
||||||
|
&& state.driveClearSec >= config.clearBeforeUnlockSec
|
||||||
|
) {
|
||||||
|
state.driveBlocked = false;
|
||||||
|
state.requiresNeutral = false;
|
||||||
|
state.stopReason = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
maybeStopDrive(roverId, state);
|
||||||
|
maybeResendScaledOutputs(roverId, state, timestamp);
|
||||||
|
return getPublicState(roverId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus(state) {
|
||||||
|
const anyOvercurrent = MOTOR_KEYS.some((key) => state.motors[key].overcurrent);
|
||||||
|
if (state.bypassed && anyOvercurrent) return 'bypassed';
|
||||||
|
if (state.driveBlocked) return 'stopped';
|
||||||
|
const anyLimited = MOTOR_KEYS.some((key) => state.motors[key].cap < 1);
|
||||||
|
if (anyLimited && anyOvercurrent) return 'limiting';
|
||||||
|
// The raw Roomba flag is useful operator information even while stress is
|
||||||
|
// still inside the transient grace region. Reporting it separately keeps
|
||||||
|
// HUD visibility immediate without falsely claiming output is being scaled.
|
||||||
|
if (anyOvercurrent) return 'overcurrent';
|
||||||
|
// Stress below the command-limiting grace threshold still represents a
|
||||||
|
// recent hardware event. Keeping recovery visible until it reaches zero
|
||||||
|
// prevents the HUD from vanishing the instant the raw flag clears.
|
||||||
|
const anyStress = MOTOR_KEYS.some((key) => state.motors[key].stress > 0);
|
||||||
|
if (anyStress) return 'recovering';
|
||||||
|
return 'idle';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicState(roverId) {
|
||||||
|
const state = getState(roverId);
|
||||||
|
const motors = MOTOR_KEYS.reduce((result, key) => {
|
||||||
|
// Rolling samples are internal evidence, not UI state. Excluding them
|
||||||
|
// keeps every sensor frame compact while exposing the resulting ratio and
|
||||||
|
// classification needed to explain the service's decision.
|
||||||
|
const { progressSamples: _progressSamples, windowCommandSign: _windowCommandSign, ...motor } = state.motors[key];
|
||||||
|
result[key] = motor;
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
return {
|
||||||
|
status: getStatus(state),
|
||||||
|
bypassed: state.bypassed,
|
||||||
|
drive: {
|
||||||
|
cap: getDriveCap(state),
|
||||||
|
blocked: state.driveBlocked,
|
||||||
|
requiresNeutral: state.requiresNeutral,
|
||||||
|
clearSec: state.driveClearSec,
|
||||||
|
stopReason: state.stopReason,
|
||||||
|
},
|
||||||
|
motors,
|
||||||
|
config: { ...config },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupRover(roverId) {
|
||||||
|
states.delete(String(roverId || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
configureCommandIssuer,
|
||||||
|
protectCommand,
|
||||||
|
processTelemetry,
|
||||||
|
getPublicState,
|
||||||
|
cleanupRover,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = createOvercurrentProtectionService();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
...service,
|
||||||
|
createOvercurrentProtectionService,
|
||||||
|
DEFAULT_CONFIG,
|
||||||
|
};
|
||||||
@@ -0,0 +1,327 @@
|
|||||||
|
// Overcurrent Protection Service Tests
|
||||||
|
// Purpose: Verifies stress integration, administrator bypass, neutral recovery, and independent brush limiting.
|
||||||
|
// Scope: Exercises the service as a pure state machine with an injected command sink; no rover or socket process is started.
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
const { createOvercurrentProtectionService } = require('./index');
|
||||||
|
|
||||||
|
function makeSensors(overrides = {}) {
|
||||||
|
return {
|
||||||
|
wheelOvercurrents: {
|
||||||
|
leftWheel: false,
|
||||||
|
rightWheel: false,
|
||||||
|
mainBrush: false,
|
||||||
|
sideBrush: false,
|
||||||
|
...(overrides.wheelOvercurrents || {}),
|
||||||
|
},
|
||||||
|
wheelSpeedsMmPerSecond: {
|
||||||
|
left: 300,
|
||||||
|
right: 300,
|
||||||
|
...(overrides.wheelSpeedsMmPerSecond || {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createHarness(config = {}) {
|
||||||
|
const issued = [];
|
||||||
|
const service = createOvercurrentProtectionService({
|
||||||
|
config,
|
||||||
|
issueCommand: (roverId, payload) => issued.push({ roverId, payload }),
|
||||||
|
});
|
||||||
|
return { service, issued };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('a short stalled-wheel spike remains inside the grace region', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0 },
|
||||||
|
}), start);
|
||||||
|
const snapshot = service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0 },
|
||||||
|
}), start + 100);
|
||||||
|
|
||||||
|
// Stress is accumulated with floating-point time arithmetic, so compare
|
||||||
|
// within a tiny tolerance instead of depending on an exact binary decimal.
|
||||||
|
assert.ok(Math.abs(snapshot.motors.leftWheel.stress - 0.02) < 1e-9);
|
||||||
|
assert.equal(snapshot.motors.leftWheel.cap, 1);
|
||||||
|
// The hardware event is visible immediately even though the grace region
|
||||||
|
// correctly leaves the command cap at full output.
|
||||||
|
assert.equal(snapshot.status, 'overcurrent');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('persistent stalled-wheel overcurrent scales both wheels and then stops drive', () => {
|
||||||
|
const { service, issued } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 200 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let step = 0; step <= 18; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0, right: 200 },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The first 400 ms establish that the wheel is making no net progress. The
|
||||||
|
tuned stalled rate should then approach—but not yet cross—the hard-stop
|
||||||
|
boundary at 1.8 seconds.
|
||||||
|
*/
|
||||||
|
assert.equal(service.getPublicState('rover').drive.blocked, false);
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0, right: 200 },
|
||||||
|
}), start + 1900);
|
||||||
|
|
||||||
|
const snapshot = service.getPublicState('rover');
|
||||||
|
assert.equal(snapshot.status, 'stopped');
|
||||||
|
assert.equal(snapshot.drive.blocked, true);
|
||||||
|
assert.equal(snapshot.drive.requiresNeutral, true);
|
||||||
|
assert.equal(snapshot.drive.stopReason, 'leftWheel');
|
||||||
|
assert.deepEqual(issued.at(-1), {
|
||||||
|
roverId: 'rover',
|
||||||
|
payload: { type: 'drive', driveDirect: { left: 0, right: 0 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Before the hard stop, any rate-limited drive update must use one shared cap.
|
||||||
|
This preserves the requested curve instead of driving the healthy wheel at
|
||||||
|
full output around the mechanically obstructed side.
|
||||||
|
*/
|
||||||
|
const scaledDrive = issued.find((entry) => entry.payload.type === 'drive'
|
||||||
|
&& entry.payload.driveDirect.left > 0);
|
||||||
|
assert.ok(scaledDrive);
|
||||||
|
const leftScale = scaledDrive.payload.driveDirect.left / 300;
|
||||||
|
const rightScale = scaledDrive.payload.driveDirect.right / 200;
|
||||||
|
/*
|
||||||
|
Motor commands are integers, so applying one shared fractional cap can
|
||||||
|
round the two differently sized wheel commands by different sub-unit
|
||||||
|
amounts. A one-percent tolerance verifies the shared curve without
|
||||||
|
pretending integer transport preserves an exact floating-point ratio.
|
||||||
|
*/
|
||||||
|
assert.ok(Math.abs(leftScale - rightScale) < 0.01);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('administrator commands and telemetry bypass all enforcement', () => {
|
||||||
|
const { service, issued } = createHarness({ outputRateMs: 0 });
|
||||||
|
const start = Date.now();
|
||||||
|
const command = service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 500, right: -500 },
|
||||||
|
}, { bypassed: true });
|
||||||
|
|
||||||
|
for (let step = 0; step <= 20; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true, rightWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0, right: 0 },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = service.getPublicState('rover');
|
||||||
|
assert.deepEqual(command.driveDirect, { left: 500, right: -500 });
|
||||||
|
assert.equal(snapshot.status, 'bypassed');
|
||||||
|
assert.equal(snapshot.bypassed, true);
|
||||||
|
assert.equal(snapshot.motors.leftWheel.stress, 0);
|
||||||
|
assert.equal(snapshot.motors.rightWheel.stress, 0);
|
||||||
|
assert.equal(snapshot.drive.blocked, false);
|
||||||
|
assert.equal(issued.length, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('encoder wobble around zero is classified as a full stall', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let step = 0; step <= 19; step += 1) {
|
||||||
|
const wobbleSpeed = step % 2 === 0 ? 10 : -9;
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: wobbleSpeed },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = service.getPublicState('rover');
|
||||||
|
assert.equal(snapshot.motors.leftWheel.classification, 'stalled');
|
||||||
|
assert.equal(snapshot.motors.leftWheel.stallFactor, 1);
|
||||||
|
assert.equal(snapshot.drive.blocked, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('overcurrent while a wheel keeps moving accumulates at the slower rate', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let step = 0; step <= 20; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 240 },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = service.getPublicState('rover');
|
||||||
|
assert.equal(snapshot.motors.leftWheel.classification, 'moving');
|
||||||
|
assert.equal(snapshot.motors.leftWheel.stallFactor, 0);
|
||||||
|
assert.equal(snapshot.drive.blocked, false);
|
||||||
|
assert.ok(snapshot.motors.leftWheel.stress < 0.6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('partial wheel progress produces an intermediate stall factor', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let step = 0; step <= 5; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 105 },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const motor = service.getPublicState('rover').motors.leftWheel;
|
||||||
|
assert.equal(motor.classification, 'partial');
|
||||||
|
assert.ok(motor.stallFactor > 0 && motor.stallFactor < 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('missing wheel speed remains unknown instead of becoming a full stall', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let step = 0; step <= 20; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: null },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = service.getPublicState('rover');
|
||||||
|
assert.equal(snapshot.motors.leftWheel.measuredSpeed, null);
|
||||||
|
assert.equal(snapshot.motors.leftWheel.classification, 'unknown');
|
||||||
|
assert.equal(snapshot.motors.leftWheel.stallFactor, 0);
|
||||||
|
assert.equal(snapshot.drive.blocked, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wheel comparison follows scaled output and resets after reversal', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
|
||||||
|
for (let step = 0; step <= 8; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0 },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
const scaled = service.getPublicState('rover').motors.leftWheel.commandedSpeed;
|
||||||
|
assert.ok(scaled < 300);
|
||||||
|
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: -300, right: -300 },
|
||||||
|
});
|
||||||
|
const reversed = service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: -250 },
|
||||||
|
}), start + 900);
|
||||||
|
assert.equal(reversed.motors.leftWheel.classification, 'unknown');
|
||||||
|
assert.equal(reversed.motors.leftWheel.progressRatio, null);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a stopped drive stays blocked until both clear time and neutral are observed', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
for (let step = 0; step <= 19; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0 },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let step = 20; step <= 52; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors(), start + step * 100);
|
||||||
|
}
|
||||||
|
assert.equal(service.getPublicState('rover').drive.blocked, true);
|
||||||
|
|
||||||
|
const heldCommand = service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(heldCommand.driveDirect, { left: 0, right: 0 });
|
||||||
|
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 0, right: 0 },
|
||||||
|
});
|
||||||
|
assert.equal(service.getPublicState('rover').drive.blocked, false);
|
||||||
|
|
||||||
|
const resumed = service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
assert.deepEqual(resumed.driveDirect, { left: 300, right: 300 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cleared wheel stress remains visible through the hold and drains to idle', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'drive', {
|
||||||
|
driveDirect: { left: 300, right: 300 },
|
||||||
|
});
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0 },
|
||||||
|
}), start);
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { leftWheel: true },
|
||||||
|
wheelSpeedsMmPerSecond: { left: 0 },
|
||||||
|
}), start + 100);
|
||||||
|
|
||||||
|
const justCleared = service.processTelemetry('rover', makeSensors(), start + 200);
|
||||||
|
assert.equal(justCleared.status, 'recovering');
|
||||||
|
assert.ok(justCleared.motors.leftWheel.stress > 0);
|
||||||
|
|
||||||
|
const held = service.processTelemetry('rover', makeSensors(), start + 800);
|
||||||
|
assert.equal(held.status, 'recovering');
|
||||||
|
assert.ok(held.motors.leftWheel.stress > 0);
|
||||||
|
|
||||||
|
const recovered = service.processTelemetry('rover', makeSensors(), start + 1000);
|
||||||
|
assert.equal(recovered.status, 'idle');
|
||||||
|
assert.equal(recovered.motors.leftWheel.stress, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('brush stress limits only the brush that reports overcurrent', () => {
|
||||||
|
const { service } = createHarness();
|
||||||
|
const start = Date.now();
|
||||||
|
service.protectCommand('rover', 'motors', {
|
||||||
|
motorPwm: { main: 100, side: 100, vacuum: 100 },
|
||||||
|
});
|
||||||
|
for (let step = 0; step <= 4; step += 1) {
|
||||||
|
service.processTelemetry('rover', makeSensors({
|
||||||
|
wheelOvercurrents: { mainBrush: true },
|
||||||
|
}), start + step * 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
const protectedCommand = service.protectCommand('rover', 'motors', {
|
||||||
|
motorPwm: { main: 100, side: 100, vacuum: 100 },
|
||||||
|
});
|
||||||
|
assert.ok(protectedCommand.motorPwm.main < 100);
|
||||||
|
assert.equal(protectedCommand.motorPwm.side, 100);
|
||||||
|
assert.equal(protectedCommand.motorPwm.vacuum, 100);
|
||||||
|
});
|
||||||
@@ -29,6 +29,16 @@ const PTZ_STREAM_PATH = 'ptz-camera';
|
|||||||
const DEFAULT_ONVIF_PORT = 8000;
|
const DEFAULT_ONVIF_PORT = 8000;
|
||||||
const DEFAULT_PROFILE_TOKEN = '003';
|
const DEFAULT_PROFILE_TOKEN = '003';
|
||||||
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
|
const DEFAULT_TURN_DURATION_MS = 5 * 60 * 1000;
|
||||||
|
// The TrackMix exposes pan/tilt and zoom through the same ONVIF method but does
|
||||||
|
// not behave as if they were the same kind of motor. Pan/tilt runs smoothly from
|
||||||
|
// one long ContinuousMove; zoom advances in command-sized increments. Keep the
|
||||||
|
// timings separate so zoom can repeat quickly without restarting pan/tilt.
|
||||||
|
const MOTION_WATCHDOG_MS = 650;
|
||||||
|
const PAN_TILT_TIMEOUT_MS = 10000;
|
||||||
|
const PAN_TILT_RENEW_MS = 8000;
|
||||||
|
const ZOOM_PULSE_TIMEOUT_MS = 1000;
|
||||||
|
const ZOOM_REPEAT_MS = 120;
|
||||||
|
const STOP_MOTION = Object.freeze({ pan: 0, tilt: 0, zoom: 0 });
|
||||||
// PTZ is a normal replay source now, so capture should be on unless the feature
|
// PTZ is a normal replay source now, so capture should be on unless the feature
|
||||||
// explicitly disables replay for the camera.
|
// explicitly disables replay for the camera.
|
||||||
const DEFAULT_REPLAY_ENABLED = true;
|
const DEFAULT_REPLAY_ENABLED = true;
|
||||||
@@ -92,6 +102,14 @@ let publisherStderrSyncTimer = null;
|
|||||||
let snapshotTimer = null;
|
let snapshotTimer = null;
|
||||||
let spotlightVerifyTimer = null;
|
let spotlightVerifyTimer = null;
|
||||||
let vendorStatePromise = Promise.resolve();
|
let vendorStatePromise = Promise.resolve();
|
||||||
|
let motionWatchdogTimer = null;
|
||||||
|
let panTiltRenewTimer = null;
|
||||||
|
let zoomRepeatTimer = null;
|
||||||
|
let desiredMotion = STOP_MOTION;
|
||||||
|
let pendingFullStopCommand = false;
|
||||||
|
let pendingPanTiltCommand = false;
|
||||||
|
let pendingZoomCommand = false;
|
||||||
|
let motionCommandPromise = null;
|
||||||
let lastSnapshotState = null;
|
let lastSnapshotState = null;
|
||||||
const snapshotSubscribers = new Map();
|
const snapshotSubscribers = new Map();
|
||||||
const socketSnapshotSubscriptions = new Map();
|
const socketSnapshotSubscriptions = new Map();
|
||||||
@@ -924,7 +942,10 @@ function revokeOperator(reason = 'release') {
|
|||||||
state.deadline = null;
|
state.deadline = null;
|
||||||
clearTurnTimer();
|
clearTurnTimer();
|
||||||
videoSessions.revokeWhere((info) => info.socketId === previous && info.sourceType === 'ptz');
|
videoSessions.revokeWhere((info) => info.socketId === previous && info.sourceType === 'ptz');
|
||||||
callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {});
|
// Operator handoff/disconnect must enter the same serialized stream as
|
||||||
|
// movement. A raw concurrent Stop could otherwise finish before an older
|
||||||
|
// ContinuousMove and allow that stale move to restart the camera afterward.
|
||||||
|
forceMotionStop(`operator-${reason}`);
|
||||||
events.emit('operator', { socketId: previous, action: 'release', reason });
|
events.emit('operator', { socketId: previous, action: 'release', reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1089,26 +1110,235 @@ function normalizePresetCreateName(rawName) {
|
|||||||
return name;
|
return name;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function move(socket, payload = {}) {
|
function normalizeMotionIntent(payload = {}) {
|
||||||
requireOperator(socket);
|
return {
|
||||||
await initialize();
|
pan: clampUnit(payload.pan ?? payload.x),
|
||||||
const x = clampUnit(payload.pan ?? payload.x);
|
tilt: clampUnit(payload.tilt ?? payload.y),
|
||||||
const y = clampUnit(payload.tilt ?? payload.y);
|
zoom: clampUnit(payload.zoom),
|
||||||
const zoom = clampUnit(payload.zoom);
|
};
|
||||||
await callOnvif('continuousMove', {
|
|
||||||
profileToken: state.profileToken,
|
|
||||||
x,
|
|
||||||
y,
|
|
||||||
zoom,
|
|
||||||
timeout: 1000,
|
|
||||||
});
|
|
||||||
return { ok: true };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function stop(socket) {
|
function isMotionIdle(motion = STOP_MOTION) {
|
||||||
|
return !motion.pan && !motion.tilt && !motion.zoom;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMotionWatchdog() {
|
||||||
|
if (!motionWatchdogTimer) return;
|
||||||
|
clearTimeout(motionWatchdogTimer);
|
||||||
|
motionWatchdogTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPanTiltRenewal() {
|
||||||
|
if (!panTiltRenewTimer) return;
|
||||||
|
clearTimeout(panTiltRenewTimer);
|
||||||
|
panTiltRenewTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearZoomRepeat() {
|
||||||
|
if (!zoomRepeatTimer) return;
|
||||||
|
clearInterval(zoomRepeatTimer);
|
||||||
|
zoomRepeatTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function panTiltMatches(left = STOP_MOTION, right = STOP_MOTION) {
|
||||||
|
return left.pan === right.pan && left.tilt === right.tilt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestMotionCommands({ fullStop = false, panTilt = false, zoom = false } = {}) {
|
||||||
|
pendingFullStopCommand = pendingFullStopCommand || fullStop;
|
||||||
|
pendingPanTiltCommand = pendingPanTiltCommand || panTilt;
|
||||||
|
pendingZoomCommand = pendingZoomCommand || zoom;
|
||||||
|
if (
|
||||||
|
motionCommandPromise ||
|
||||||
|
(!pendingFullStopCommand && !pendingPanTiltCommand && !pendingZoomCommand)
|
||||||
|
) {
|
||||||
|
return motionCommandPromise || Promise.resolve();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Keep one ONVIF request in flight at a time, but coalesce independently by
|
||||||
|
axis. A zoom timer can tick several times while the camera answers one SOAP
|
||||||
|
request; one pending boolean preserves the newest required pulse without
|
||||||
|
building a delayed command backlog that would continue after release.
|
||||||
|
*/
|
||||||
|
motionCommandPromise = (async () => {
|
||||||
|
while (pendingFullStopCommand || pendingPanTiltCommand || pendingZoomCommand) {
|
||||||
|
const sendFullStop = pendingFullStopCommand;
|
||||||
|
pendingFullStopCommand = false;
|
||||||
|
if (sendFullStop) {
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
/*
|
||||||
|
Reserve ONVIF Stop for real all-axis safety events. The TrackMix
|
||||||
|
appears to treat even an axis-filtered Stop as global, so ordinary
|
||||||
|
user releases below use zero velocity instead.
|
||||||
|
*/
|
||||||
|
await callOnvif('stop', {
|
||||||
|
profileToken: state.profileToken,
|
||||||
|
panTilt: true,
|
||||||
|
zoom: true,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('PTZ full stop command failed', { error: getErrorMessage(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendPanTilt = pendingPanTiltCommand;
|
||||||
|
pendingPanTiltCommand = false;
|
||||||
|
if (sendPanTilt) {
|
||||||
|
const pan = desiredMotion.pan;
|
||||||
|
const tilt = desiredMotion.tilt;
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
if (!pan && !tilt) {
|
||||||
|
/*
|
||||||
|
Zero pan/tilt velocity stops only that axis under ContinuousMove.
|
||||||
|
Do not use ONVIF Stop here: this TrackMix ignores the requested
|
||||||
|
axis filter and can also stop zoom that is still being held.
|
||||||
|
*/
|
||||||
|
await callOnvif('continuousMove', {
|
||||||
|
profileToken: state.profileToken,
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
onlySendPanTilt: true,
|
||||||
|
timeout: PAN_TILT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await callOnvif('continuousMove', {
|
||||||
|
profileToken: state.profileToken,
|
||||||
|
x: pan,
|
||||||
|
y: tilt,
|
||||||
|
onlySendPanTilt: true,
|
||||||
|
timeout: PAN_TILT_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('PTZ pan/tilt command failed', { error: getErrorMessage(err), pan, tilt });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendZoom = pendingZoomCommand;
|
||||||
|
pendingZoomCommand = false;
|
||||||
|
if (sendZoom) {
|
||||||
|
const zoom = desiredMotion.zoom;
|
||||||
|
try {
|
||||||
|
await initialize();
|
||||||
|
if (zoom) {
|
||||||
|
/*
|
||||||
|
The TrackMix does not advertise continuous zoom, but physical
|
||||||
|
testing showed each accepted zoom-only ContinuousMove advances one
|
||||||
|
step. Repeating this axis-only request restores responsive zoom
|
||||||
|
without resending or restarting the pan/tilt motor.
|
||||||
|
*/
|
||||||
|
await callOnvif('continuousMove', {
|
||||||
|
profileToken: state.profileToken,
|
||||||
|
zoom,
|
||||||
|
onlySendZoom: true,
|
||||||
|
timeout: ZOOM_PULSE_TIMEOUT_MS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('PTZ zoom command failed', { error: getErrorMessage(err), zoom });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})().finally(() => {
|
||||||
|
motionCommandPromise = null;
|
||||||
|
// Cover an intent arriving between the loop check and promise cleanup.
|
||||||
|
if (pendingFullStopCommand || pendingPanTiltCommand || pendingZoomCommand) {
|
||||||
|
requestMotionCommands();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return motionCommandPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function armPanTiltRenewal() {
|
||||||
|
clearPanTiltRenewal();
|
||||||
|
if (!desiredMotion.pan && !desiredMotion.tilt) return;
|
||||||
|
/*
|
||||||
|
The camera requires a finite timeout. Renew close to the ten-second limit,
|
||||||
|
not on every browser heartbeat, so an unusually long hold stays continuous
|
||||||
|
without bringing back the quarter-second motor restarts.
|
||||||
|
*/
|
||||||
|
panTiltRenewTimer = setTimeout(() => {
|
||||||
|
panTiltRenewTimer = null;
|
||||||
|
requestMotionCommands({ panTilt: true });
|
||||||
|
armPanTiltRenewal();
|
||||||
|
}, PAN_TILT_RENEW_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncZoomRepeater() {
|
||||||
|
clearZoomRepeat();
|
||||||
|
if (!desiredMotion.zoom) return;
|
||||||
|
// queueMotionIntent sends the first step once after configuring this timer;
|
||||||
|
// subsequent ticks retain the old fast hold cadence without a double pulse.
|
||||||
|
zoomRepeatTimer = setInterval(() => {
|
||||||
|
requestMotionCommands({ zoom: true });
|
||||||
|
}, ZOOM_REPEAT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueMotionIntent(motion, reason = 'input') {
|
||||||
|
const nextMotion = normalizeMotionIntent(motion);
|
||||||
|
const panTiltChanged = !panTiltMatches(nextMotion, desiredMotion);
|
||||||
|
const zoomChanged = nextMotion.zoom !== desiredMotion.zoom;
|
||||||
|
desiredMotion = nextMotion;
|
||||||
|
clearMotionWatchdog();
|
||||||
|
|
||||||
|
if (!isMotionIdle(nextMotion)) {
|
||||||
|
/*
|
||||||
|
Socket disconnect normally arrives quickly, but it is not a suitable motor
|
||||||
|
safety boundary. Every non-zero browser heartbeat replaces this timer; if
|
||||||
|
releases or subsequent heartbeats disappear, the server injects a zero
|
||||||
|
intent into the same serialized stream as ordinary control changes.
|
||||||
|
*/
|
||||||
|
motionWatchdogTimer = setTimeout(() => {
|
||||||
|
motionWatchdogTimer = null;
|
||||||
|
queueMotionIntent(STOP_MOTION, 'watchdog');
|
||||||
|
}, MOTION_WATCHDOG_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Identical browser heartbeats refresh only the watchdog. They must not touch
|
||||||
|
either motor scheduler: pan/tilt already has a long continuous command, and
|
||||||
|
zoom has its own 120 ms axis-only repeater.
|
||||||
|
*/
|
||||||
|
const sendPanTilt = panTiltChanged;
|
||||||
|
/*
|
||||||
|
A live-camera recording proved that both Stop(Zoom=true) and a zero-velocity
|
||||||
|
zoom ContinuousMove halt pan/tilt on this firmware. Zoom itself is step-based:
|
||||||
|
each non-zero pulse advances once and then settles. Releasing zoom therefore
|
||||||
|
means clearing its timer and any coalesced-but-unsent pulse, with no camera
|
||||||
|
command at all. The last transmitted pulse retains its finite one-second
|
||||||
|
timeout as a backstop.
|
||||||
|
*/
|
||||||
|
if (zoomChanged && !nextMotion.zoom) pendingZoomCommand = false;
|
||||||
|
const sendZoom = Boolean(nextMotion.zoom) && zoomChanged;
|
||||||
|
if (sendPanTilt) armPanTiltRenewal();
|
||||||
|
if (sendZoom) syncZoomRepeater();
|
||||||
|
if (zoomChanged && !nextMotion.zoom) clearZoomRepeat();
|
||||||
|
const pending = requestMotionCommands({ panTilt: sendPanTilt, zoom: sendZoom });
|
||||||
|
pending.catch(() => {});
|
||||||
|
return { ok: true, motion: desiredMotion, reason };
|
||||||
|
}
|
||||||
|
|
||||||
|
function acceptMotionIntent(socket, payload = {}) {
|
||||||
requireOperator(socket);
|
requireOperator(socket);
|
||||||
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true });
|
return queueMotionIntent(payload, 'operator-input');
|
||||||
return { ok: true };
|
}
|
||||||
|
|
||||||
|
function forceMotionStop(reason = 'safety-stop') {
|
||||||
|
/*
|
||||||
|
Lifecycle stops force a real all-axis ONVIF Stop even when local state is
|
||||||
|
already zero. The browser may have lost its final packet, or the camera may
|
||||||
|
have accepted a command whose response has not returned, so deduplicating a
|
||||||
|
safety stop would trust precisely the state we are trying to recover from.
|
||||||
|
*/
|
||||||
|
clearPanTiltRenewal();
|
||||||
|
clearZoomRepeat();
|
||||||
|
queueMotionIntent(STOP_MOTION, reason);
|
||||||
|
requestMotionCommands({ fullStop: true });
|
||||||
|
return motionCommandPromise || Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getStatus(socket) {
|
async function getStatus(socket) {
|
||||||
@@ -1133,9 +1363,10 @@ async function gotoPreset(socket, payload = {}) {
|
|||||||
Stop any continuous move before jumping to a preset. Without this, a held
|
Stop any continuous move before jumping to a preset. Without this, a held
|
||||||
key or touch control can keep sending pan/tilt velocity while the camera is
|
key or touch control can keep sending pan/tilt velocity while the camera is
|
||||||
trying to execute the absolute preset move, which makes the final position
|
trying to execute the absolute preset move, which makes the final position
|
||||||
feel inconsistent.
|
feel inconsistent. Await the serialized safety stop instead of issuing a
|
||||||
|
raw concurrent ONVIF request that could itself race an older movement.
|
||||||
*/
|
*/
|
||||||
await callOnvif('stop', { profileToken: state.profileToken, panTilt: true, zoom: true }).catch(() => {});
|
await forceMotionStop('preset').catch(() => {});
|
||||||
await callOnvif('gotoPreset', {
|
await callOnvif('gotoPreset', {
|
||||||
profileToken: state.profileToken,
|
profileToken: state.profileToken,
|
||||||
/*
|
/*
|
||||||
@@ -1475,18 +1706,16 @@ function registerSocketHandlers() {
|
|||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
socket.on('ptzCamera:move', async (firstArg, secondArg) => {
|
socket.on('ptzCamera:motion', (firstArg, secondArg) => {
|
||||||
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
const { payload, cb } = normalizeSocketArgs(firstArg, secondArg);
|
||||||
try {
|
try {
|
||||||
cb(await move(socket, payload));
|
/*
|
||||||
} catch (err) {
|
Acknowledge acceptance of the newest desired state immediately. The
|
||||||
cb({ error: err.message });
|
serialized ONVIF pump deliberately runs independently of Socket.IO
|
||||||
}
|
request latency so browser heartbeats cannot accumulate while waiting
|
||||||
});
|
for a camera SOAP response.
|
||||||
socket.on('ptzCamera:stop', async (firstArg, secondArg) => {
|
*/
|
||||||
const { cb } = normalizeSocketArgs(firstArg, secondArg);
|
cb(acceptMotionIntent(socket, payload));
|
||||||
try {
|
|
||||||
cb(await stop(socket));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,6 +61,25 @@ function validateSources(list = [], socket = null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getDefaultWebSources(assignment = {}, socket = null) {
|
function getDefaultWebSources(assignment = {}, socket = null) {
|
||||||
|
/*
|
||||||
|
PTZ ownership is intentionally tracked outside assignmentService because
|
||||||
|
taking the camera releases the user's rover assignment. Check the PTZ
|
||||||
|
service directly so a source-less web replay request, including `rs
|
||||||
|
replay`, follows the camera currently controlled by that socket just as it
|
||||||
|
follows an assigned rover below.
|
||||||
|
|
||||||
|
isOperator is deliberately stricter than PTZ access or queue membership:
|
||||||
|
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
|
||||||
|
every web replay entry point share the same default instead of teaching the
|
||||||
|
chat-command adapter about PTZ-specific state.
|
||||||
|
*/
|
||||||
|
if (ptzCameraService.getPublicState(socket).isOperator) {
|
||||||
|
const source = ptzCameraService.getReplaySource();
|
||||||
|
if (!source) return [];
|
||||||
|
return [{ type: source.type, id: String(source.id), label: source.label || source.id }];
|
||||||
|
}
|
||||||
|
|
||||||
if (assignment?.roverId) {
|
if (assignment?.roverId) {
|
||||||
const id = String(assignment.roverId);
|
const id = String(assignment.roverId);
|
||||||
const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id);
|
const match = getReplaySources(socket).find((entry) => entry.type === 'rover' && entry.id === id);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ const logger = require('../../globals/logger').child('roverManager');
|
|||||||
const { sendAlert } = require('../alertService');
|
const { sendAlert } = require('../alertService');
|
||||||
const { parseSensorFrame } = require('../../helpers/sensorDecoder');
|
const { parseSensorFrame } = require('../../helpers/sensorDecoder');
|
||||||
const odometerService = require('../odometerService');
|
const odometerService = require('../odometerService');
|
||||||
|
const overcurrentProtectionService = require('../overcurrentProtectionService');
|
||||||
const { MODES, getMode } = require('../modeManager');
|
const { MODES, getMode } = require('../modeManager');
|
||||||
const { isAdmin, isLockdownAdmin, roleEvents } = require('../roleService');
|
const { isAdmin, isLockdownAdmin, roleEvents } = require('../roleService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
@@ -179,6 +180,7 @@ const sensorPipeline = createSensorPipeline({
|
|||||||
sendAlert,
|
sendAlert,
|
||||||
publishEvent,
|
publishEvent,
|
||||||
processOdometerFrame: odometerService.processSensorFrame,
|
processOdometerFrame: odometerService.processSensorFrame,
|
||||||
|
processOvercurrentTelemetry: overcurrentProtectionService.processTelemetry,
|
||||||
isPrivateRecord,
|
isPrivateRecord,
|
||||||
isPrivateOpen,
|
isPrivateOpen,
|
||||||
getPrivateSafety,
|
getPrivateSafety,
|
||||||
@@ -190,6 +192,18 @@ const sensorPipeline = createSensorPipeline({
|
|||||||
const { handleSensorFrame, applyPrivateDriveSafety } = sensorPipeline;
|
const { handleSensorFrame, applyPrivateDriveSafety } = sensorPipeline;
|
||||||
stopDockGuard = sensorPipeline.stopDockGuard;
|
stopDockGuard = sensorPipeline.stopDockGuard;
|
||||||
|
|
||||||
|
managerEvents.on('rover', ({ roverId, action }) => {
|
||||||
|
/*
|
||||||
|
Protection state contains the last motor intent for a specific physical
|
||||||
|
rover connection. Removing it with the roster record prevents a reconnect
|
||||||
|
from inheriting stale stress, an old administrator bypass, or a neutral
|
||||||
|
requirement from the previous connection.
|
||||||
|
*/
|
||||||
|
if (action === 'removed') {
|
||||||
|
overcurrentProtectionService.cleanupRover(roverId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
function removeSocket(socket) {
|
function removeSocket(socket) {
|
||||||
roverLifecycle.removeSocket(socket, disableSpectator);
|
roverLifecycle.removeSocket(socket, disableSpectator);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ function createSensorPipeline(deps) {
|
|||||||
sendAlert,
|
sendAlert,
|
||||||
publishEvent,
|
publishEvent,
|
||||||
processOdometerFrame,
|
processOdometerFrame,
|
||||||
|
processOvercurrentTelemetry,
|
||||||
isPrivateRecord,
|
isPrivateRecord,
|
||||||
isPrivateOpen,
|
isPrivateOpen,
|
||||||
getPrivateSafety,
|
getPrivateSafety,
|
||||||
@@ -551,6 +552,19 @@ function createSensorPipeline(deps) {
|
|||||||
};
|
};
|
||||||
record.lastSensor = { raw: frame, decoded };
|
record.lastSensor = { raw: frame, decoded };
|
||||||
}
|
}
|
||||||
|
/*
|
||||||
|
Rover manager remains responsible only for decoding and routing sensor
|
||||||
|
frames. The dedicated service receives the completed sensor object after
|
||||||
|
odometry has added measured wheel speeds, because requested-versus-actual
|
||||||
|
motion is the evidence that distinguishes a transient current spike from
|
||||||
|
a mechanically stalled wheel.
|
||||||
|
*/
|
||||||
|
// Use server arrival time inside the service rather than the Pi timestamp.
|
||||||
|
// Raspberry Pi clocks can differ across the fleet, while command resend
|
||||||
|
// throttling is also measured on this server and needs one clock domain.
|
||||||
|
const overcurrentProtection = decoded && typeof processOvercurrentTelemetry === 'function'
|
||||||
|
? processOvercurrentTelemetry(roverId, decoded)
|
||||||
|
: null;
|
||||||
updateMovement(record, decoded);
|
updateMovement(record, decoded);
|
||||||
const hasDockInfo = decoded?.chargingSources != null;
|
const hasDockInfo = decoded?.chargingSources != null;
|
||||||
if (hasDockInfo) {
|
if (hasDockInfo) {
|
||||||
@@ -563,8 +577,18 @@ function createSensorPipeline(deps) {
|
|||||||
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
if (bumps?.bumpLeft || bumps?.bumpRight) record.lastBumpAt = Date.now();
|
||||||
handlePrivateButtonHold(record, decoded);
|
handlePrivateButtonHold(record, decoded);
|
||||||
evaluatePrivateSafety(record, decoded);
|
evaluatePrivateSafety(record, decoded);
|
||||||
io.to(record.room).volatile.emit('sensorFrame', { roverId, frame, sensors: decoded });
|
io.to(record.room).volatile.emit('sensorFrame', {
|
||||||
managerEvents.emit('sensor', { roverId, sensors: decoded, batteryState: record.batteryState });
|
roverId,
|
||||||
|
frame,
|
||||||
|
sensors: decoded,
|
||||||
|
overcurrentProtection,
|
||||||
|
});
|
||||||
|
managerEvents.emit('sensor', {
|
||||||
|
roverId,
|
||||||
|
sensors: decoded,
|
||||||
|
batteryState: record.batteryState,
|
||||||
|
overcurrentProtection,
|
||||||
|
});
|
||||||
evaluateDockGuard(record, decoded);
|
evaluateDockGuard(record, decoded);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -196,6 +196,26 @@ function canDrive(roverId, socket) {
|
|||||||
return activeDrivers.get(roverId) === socket.id;
|
return activeDrivers.get(roverId) === socket.id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function canRequestLiveVideo(roverId, socket) {
|
||||||
|
if (!socket) return false;
|
||||||
|
if (canDrive(roverId, socket)) return true;
|
||||||
|
|
||||||
|
const queue = driverQueues.get(roverId);
|
||||||
|
if (!queue || getMode() !== MODES.TURNS) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
This helper is intentionally broader than canDrive(). Bandwidth saving is a
|
||||||
|
presentation/subscription decision for normal driver clients: the UI keeps
|
||||||
|
non-current drivers on snapshots, and only asks for live video when it wants
|
||||||
|
to warm or show the stream. The server should still verify that the socket is
|
||||||
|
actually attached to this rover, but it should not reject a legitimate queued
|
||||||
|
driver because the browser and turn timer are a few milliseconds out of sync.
|
||||||
|
*/
|
||||||
|
return queue.queue.includes(socket.id);
|
||||||
|
}
|
||||||
|
|
||||||
function isQueuedDriver(roverId, socketId) {
|
function isQueuedDriver(roverId, socketId) {
|
||||||
if (!socketId) return false;
|
if (!socketId) return false;
|
||||||
const queue = driverQueues.get(roverId);
|
const queue = driverQueues.get(roverId);
|
||||||
@@ -410,6 +430,7 @@ module.exports = {
|
|||||||
driverRemoved,
|
driverRemoved,
|
||||||
cleanupRover,
|
cleanupRover,
|
||||||
canDrive,
|
canDrive,
|
||||||
|
canRequestLiveVideo,
|
||||||
isQueuedDriver,
|
isQueuedDriver,
|
||||||
getActiveDrivers,
|
getActiveDrivers,
|
||||||
turnEvents,
|
turnEvents,
|
||||||
|
|||||||
@@ -104,12 +104,12 @@ function createVideoAuthPolicy(deps) {
|
|||||||
if (
|
if (
|
||||||
!isAudio &&
|
!isAudio &&
|
||||||
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
|
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
|
||||||
!turnService.canDrive(roverId, socket)
|
!turnService.canRequestLiveVideo(roverId, socket)
|
||||||
) {
|
) {
|
||||||
/*
|
/*
|
||||||
This mirrors videoSocketService's token gate. MediaMTX can ask auth
|
This mirrors videoSocketService's token gate. MediaMTX can ask auth
|
||||||
after a token has been issued, so the active-turn bandwidth rule must
|
after a token has been issued, so the same "must belong to this rover's
|
||||||
be evaluated here too instead of trusting an older browser decision.
|
driver queue" rule has to be evaluated here too.
|
||||||
*/
|
*/
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,15 +153,15 @@ io.on('connection', (socket) => {
|
|||||||
role !== 'spectator' &&
|
role !== 'spectator' &&
|
||||||
!isAdmin(socket) &&
|
!isAdmin(socket) &&
|
||||||
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
|
shouldUseSnapshotsForNonTurnVideo({ controllableUserCount: countControllableUsers() }) &&
|
||||||
!turnService.canDrive(baseId, socket)
|
!turnService.canRequestLiveVideo(baseId, socket)
|
||||||
) {
|
) {
|
||||||
/*
|
/*
|
||||||
The browser also forces snapshots for non-active turn holders, but
|
The browser owns the snapshot-vs-live presentation for queued rover
|
||||||
the socket token path must enforce the same rule. Otherwise a stale
|
drivers. The server side only verifies that the socket belongs to
|
||||||
component or direct socket caller could still mint a MediaMTX token
|
this rover's driver queue so legitimate warm-up/switch requests are
|
||||||
while the UI is showing snapshots.
|
not rejected by small turn-timer timing differences.
|
||||||
*/
|
*/
|
||||||
throw new Error('Live video is limited to the active turn');
|
throw new Error('Live video is limited to this rover queue');
|
||||||
}
|
}
|
||||||
} else if (target.type === 'room') {
|
} else if (target.type === 'room') {
|
||||||
throw new Error('Room cameras now use the snapshot feed');
|
throw new Error('Room cameras now use the snapshot feed');
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
1. assign rovers based on battery percentage, give people highest one
|
1. assign rovers based on battery percentage, give people highest one
|
||||||
2. add admin ui for VIP and private requests instead of only through discord
|
2. add admin ui for VIP and private requests instead of only through discord
|
||||||
3. make overcurrent limiter speed sensitive, slower fill at lower speeds
|
3. add flag in roverd for video aspect ratio
|
||||||
4. add more background gap themes
|
1. maybe dont? whats the point anyway? why do we exist at all? is there purpose to life?
|
||||||
5. fix this:
|
1. just removing the black bars, doesnt do anything practical for the driver page
|
||||||
|
2. would only actually help for keeping spectate page compact
|
||||||
|
1. maybe just make the spectate videos be fixed width and match the height of the media
|
||||||
|
2. either 4:3 or 16:9
|
||||||
|
3. default is 4:3
|
||||||
|
4. all it does is tell the web UI to make the rover video 16:9 or 4:3 shaped
|
||||||
|
1. web UI should default to 4:3 if that rover doesnt yet have that config yet
|
||||||
|
4. fix this:
|
||||||
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
`Jun 18 15:14:18 roombaserver.local node[216731]: /home/daniel/MultiRoombaRover/server/src/services/roverManager/socketHandlers.js:92
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
Jun 18 15:14:18 roombaserver.local node[216731]: cb({ error: err.message });
|
||||||
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
Jun 18 15:14:18 roombaserver.local node[216731]: ^
|
||||||
|
|||||||
+12
-1
@@ -51,7 +51,12 @@ import NeatoCard from './components/NeatoCard/index.jsx';
|
|||||||
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
import RewardRunOverlay from './components/RewardRunOverlay/index.jsx';
|
||||||
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
import SocketConnectionPill from './components/SocketConnectionPill/index.jsx';
|
||||||
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
|
import DuplicateIdentityOverlay from './components/DuplicateIdentityOverlay/index.jsx';
|
||||||
import { pageBackgroundClass, themeGapClass, themeStackClass } from './themeFlags.js';
|
import {
|
||||||
|
DEFAULT_PAGE_THEME_KEY,
|
||||||
|
getPageThemeClass,
|
||||||
|
themeGapClass,
|
||||||
|
themeStackClass,
|
||||||
|
} from './themes/index.js';
|
||||||
import { trackAnalyticsEvent } from './analytics/index.js';
|
import { trackAnalyticsEvent } from './analytics/index.js';
|
||||||
import useLayoutMode from './hooks/useLayoutMode.js';
|
import useLayoutMode from './hooks/useLayoutMode.js';
|
||||||
|
|
||||||
@@ -280,6 +285,12 @@ function App() {
|
|||||||
const layout = useLayoutMode();
|
const layout = useLayoutMode();
|
||||||
const isDesktop = layout === 'desktop';
|
const isDesktop = layout === 'desktop';
|
||||||
const fullscreen = useFullscreenPrompt(layout);
|
const fullscreen = useFullscreenPrompt(layout);
|
||||||
|
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||||
|
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||||
|
});
|
||||||
|
// Resolve the cookie value through the shared catalog before painting the page. This prevents
|
||||||
|
// an obsolete or hand-edited key from stripping the background class from every exposed seam.
|
||||||
|
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`${pageBackgroundClass} text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'ios-safe-screen min-h-screen'}`}>
|
<div className={`${pageBackgroundClass} text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'ios-safe-screen min-h-screen'}`}>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { memo, useEffect, useMemo, useRef, useState } from 'react';
|
|||||||
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
|
import { useChatActions, useChatTimeline } from '../../context/ChatContext.jsx';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
import useChatMessageHistoryNavigation from '../../hooks/useChatMessageHistoryNavigation.js';
|
||||||
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
import ChatMessageRow from '../ChatMessageRow/index.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import NicknameForm from '../NicknameForm/index.jsx';
|
import NicknameForm from '../NicknameForm/index.jsx';
|
||||||
@@ -260,6 +261,7 @@ function ChatComposer({
|
|||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
const [speak, setSpeak] = useState(true);
|
const [speak, setSpeak] = useState(true);
|
||||||
|
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||||
const effectiveSpeak = ttsSupported && speak;
|
const effectiveSpeak = ttsSupported && speak;
|
||||||
const ttsPayload = useMemo(() => {
|
const ttsPayload = useMemo(() => {
|
||||||
if (!effectiveSpeak) return null;
|
if (!effectiveSpeak) return null;
|
||||||
@@ -291,6 +293,7 @@ function ChatComposer({
|
|||||||
try {
|
try {
|
||||||
await sendMessage(clean, ttsPayload);
|
await sendMessage(clean, ttsPayload);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
|
resetHistoryNavigation();
|
||||||
blurChat();
|
blurChat();
|
||||||
setTypingActive(false);
|
setTypingActive(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -314,6 +317,9 @@ function ChatComposer({
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = event.target.value;
|
const next = event.target.value;
|
||||||
|
// A direct edit starts a fresh history traversal. This prevents an
|
||||||
|
// old ArrowDown position from overwriting text the user just typed.
|
||||||
|
resetHistoryNavigation();
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
setTypingActive(Boolean(next.trim()));
|
setTypingActive(Boolean(next.trim()));
|
||||||
}}
|
}}
|
||||||
@@ -326,6 +332,15 @@ function ChatComposer({
|
|||||||
setTypingActive(false);
|
setTypingActive(false);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||||
|
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
|
||||||
|
if (recalledDraft !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
setDraft(recalledDraft);
|
||||||
|
setTypingActive(Boolean(recalledDraft.trim()));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === 'Enter' && !draft.trim()) {
|
if (event.key === 'Enter' && !draft.trim()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
blurChat();
|
blurChat();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { memo, useMemo, useState } from 'react';
|
|||||||
import { useChatActions } from '../../../context/ChatContext.jsx';
|
import { useChatActions } from '../../../context/ChatContext.jsx';
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
import { useSettingsNamespace } from '../../../settings/index.js';
|
import { useSettingsNamespace } from '../../../settings/index.js';
|
||||||
|
import useChatMessageHistoryNavigation from '../../../hooks/useChatMessageHistoryNavigation.js';
|
||||||
|
|
||||||
function detectSafari() {
|
function detectSafari() {
|
||||||
if (typeof navigator === 'undefined') return false;
|
if (typeof navigator === 'undefined') return false;
|
||||||
@@ -42,6 +43,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
});
|
});
|
||||||
const [draft, setDraft] = useState('');
|
const [draft, setDraft] = useState('');
|
||||||
const [sending, setSending] = useState(false);
|
const [sending, setSending] = useState(false);
|
||||||
|
const { navigateHistory, resetHistoryNavigation } = useChatMessageHistoryNavigation();
|
||||||
const canChat = role !== 'spectator';
|
const canChat = role !== 'spectator';
|
||||||
const hideHudChat = role === 'spectator';
|
const hideHudChat = role === 'spectator';
|
||||||
const chatTargetId = useMemo(() => {
|
const chatTargetId = useMemo(() => {
|
||||||
@@ -118,6 +120,7 @@ function HudChatInput({ compact = false }) {
|
|||||||
try {
|
try {
|
||||||
await sendMessage(clean, ttsPayload);
|
await sendMessage(clean, ttsPayload);
|
||||||
setDraft('');
|
setDraft('');
|
||||||
|
resetHistoryNavigation();
|
||||||
blurChat();
|
blurChat();
|
||||||
setTypingActive(false);
|
setTypingActive(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -136,6 +139,9 @@ function HudChatInput({ compact = false }) {
|
|||||||
value={draft}
|
value={draft}
|
||||||
onChange={(event) => {
|
onChange={(event) => {
|
||||||
const next = event.target.value;
|
const next = event.target.value;
|
||||||
|
// Keep HUD navigation independent from the panel's cursor even
|
||||||
|
// though both inputs read the same persisted message collection.
|
||||||
|
resetHistoryNavigation();
|
||||||
setDraft(next);
|
setDraft(next);
|
||||||
setTypingActive(Boolean(next.trim()));
|
setTypingActive(Boolean(next.trim()));
|
||||||
}}
|
}}
|
||||||
@@ -148,6 +154,15 @@ function HudChatInput({ compact = false }) {
|
|||||||
setTypingActive(false);
|
setTypingActive(false);
|
||||||
}}
|
}}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
|
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
||||||
|
const recalledDraft = navigateHistory(event.key === 'ArrowUp' ? 'previous' : 'next', draft);
|
||||||
|
if (recalledDraft !== null) {
|
||||||
|
event.preventDefault();
|
||||||
|
setDraft(recalledDraft);
|
||||||
|
setTypingActive(Boolean(recalledDraft.trim()));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (event.key === 'Enter' && !draft.trim()) {
|
if (event.key === 'Enter' && !draft.trim()) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
blurChat();
|
blurChat();
|
||||||
|
|||||||
@@ -1,63 +1,74 @@
|
|||||||
// Overcurrent Overlay
|
// Overcurrent Overlay
|
||||||
// Purpose: Defines the Overcurrent Overlay module and the local helpers/components used in this file.
|
// Purpose: Shows server-authoritative motor limiting, stop, recovery, and administrator-bypass status.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Renders protection state only; it never calculates stress or changes motor commands.
|
||||||
import React from 'react';
|
|
||||||
import { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../../context/SessionContext.jsx';
|
||||||
import { useVisualTelemetrySelector } from '../../../context/TelemetryContext.jsx';
|
|
||||||
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../../../context/telemetryViews.js';
|
|
||||||
import { useOvercurrentLimiter } from '../../../controls/index.js';
|
import { useOvercurrentLimiter } from '../../../controls/index.js';
|
||||||
import { OVERCURRENT_LABELS } from './constants.js';
|
import { OVERCURRENT_LABELS } from './constants.js';
|
||||||
|
|
||||||
function OvercurrentOverlay({ roverId = null, sensors, overcurrentLimiter = null, compact = false }) {
|
function OvercurrentOverlay({ roverId = null, overcurrentLimiter = null, compact = false }) {
|
||||||
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
const assignedRoverId = useSessionSelector((state) => state.session?.assignment?.roverId ?? null);
|
||||||
const effectiveRoverId = roverId ?? assignedRoverId;
|
const effectiveRoverId = roverId ?? assignedRoverId;
|
||||||
const selectedOvercurrents = useVisualTelemetrySelector(effectiveRoverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
|
||||||
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
|
const internalLimiter = useOvercurrentLimiter(effectiveRoverId);
|
||||||
const resolvedOvercurrents = sensors?.wheelOvercurrents ?? selectedOvercurrents;
|
const protection = overcurrentLimiter ?? internalLimiter;
|
||||||
const resolvedOvercurrentLimiter = overcurrentLimiter ?? internalLimiter ?? null;
|
const status = protection?.status || 'idle';
|
||||||
const overcurrentMotors = useMemo(
|
const motors = protection?.motors || {};
|
||||||
() =>
|
const activeMotors = useMemo(
|
||||||
resolvedOvercurrents == null
|
() => Object.entries(motors)
|
||||||
? []
|
.filter(([, motor]) => Boolean(motor?.overcurrent) || Number(motor?.stress) > 0)
|
||||||
: Object.entries(resolvedOvercurrents)
|
.map(([key]) => key),
|
||||||
.filter(([, active]) => Boolean(active))
|
[motors],
|
||||||
.map(([key]) => key),
|
|
||||||
[resolvedOvercurrents],
|
|
||||||
);
|
);
|
||||||
const limiterCaps = resolvedOvercurrentLimiter?.caps || null;
|
|
||||||
const limiterFill = useMemo(() => {
|
|
||||||
if (!limiterCaps) return null;
|
|
||||||
const driveCap = Number.isFinite(limiterCaps?.drive?.cap) ? limiterCaps.drive.cap : 1;
|
|
||||||
const auxCap = Number.isFinite(limiterCaps?.aux?.cap) ? limiterCaps.aux.cap : 1;
|
|
||||||
return Math.max(0, Math.min(1, 1 - Math.min(driveCap, auxCap)));
|
|
||||||
}, [limiterCaps]);
|
|
||||||
const limiterActive = Boolean(resolvedOvercurrentLimiter?.isActive);
|
|
||||||
const motors = useMemo(
|
|
||||||
() => (overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []),
|
|
||||||
[overcurrentMotors, limiterActive],
|
|
||||||
);
|
|
||||||
const fill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
|
|
||||||
|
|
||||||
if (!motors?.length) return null;
|
if (status === 'idle') return null;
|
||||||
const safeLabels = motors.map((name) => OVERCURRENT_LABELS[name] || name);
|
|
||||||
const containerClass = compact ? 'w-[12rem] h-[3.5rem]' : 'w-[20rem] h-[7rem]';
|
const stopReason = protection?.drive?.stopReason;
|
||||||
const padClass = compact ? 'px-2 py-1' : 'px-4 py-2';
|
const displayMotors = stopReason ? [stopReason] : activeMotors;
|
||||||
const textClass = compact ? 'text-lg' : 'text-4xl';
|
const labels = displayMotors.map((name) => OVERCURRENT_LABELS[name] || name);
|
||||||
const subTextClass = compact ? 'text-xs' : 'text-xl';
|
const highestStress = displayMotors.reduce(
|
||||||
const safeFill = Math.max(0, Math.min(1, fill));
|
(highest, name) => Math.max(highest, Number(motors?.[name]?.stress) || 0),
|
||||||
const fillWidth = `${Math.round(safeFill * 100)}%`;
|
0,
|
||||||
|
);
|
||||||
|
const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
|
||||||
|
const fillWidth = `${Math.round(Math.max(0, Math.min(1, highestStress)) * 100)}%`;
|
||||||
|
const bypassed = status === 'bypassed';
|
||||||
|
const stopped = status === 'stopped';
|
||||||
|
const title = bypassed
|
||||||
|
? 'Overcurrent detected'
|
||||||
|
: stopped
|
||||||
|
? 'Drive stopped'
|
||||||
|
: status === 'recovering'
|
||||||
|
? 'Protection recovering'
|
||||||
|
: status === 'limiting'
|
||||||
|
? 'Overcurrent limiting'
|
||||||
|
: 'Overcurrent detected';
|
||||||
|
const detail = bypassed
|
||||||
|
? 'Admin bypass'
|
||||||
|
: stopped && protection?.drive?.requiresNeutral
|
||||||
|
? `${labels.join(', ') || 'Wheel stall'} · release controls to resume`
|
||||||
|
: status === 'limiting'
|
||||||
|
? `${labels.join(', ')} · output ${Math.round(driveCap * 100)}%`
|
||||||
|
: labels.join(', ');
|
||||||
|
const containerClass = bypassed
|
||||||
|
? 'h-[3.5rem] w-[14rem]'
|
||||||
|
: compact
|
||||||
|
? 'h-[3.5rem] w-[14rem]'
|
||||||
|
: 'h-[7rem] w-[22rem]';
|
||||||
|
const titleClass = compact || bypassed ? 'text-base' : 'text-3xl';
|
||||||
|
const detailClass = compact || bypassed ? 'text-xs' : 'text-base';
|
||||||
|
const backgroundClass = bypassed ? 'bg-amber-950/75' : 'bg-red-950/70';
|
||||||
|
const fillClass = bypassed ? 'bg-amber-700/50' : 'bg-red-700/60';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`pointer-events-none absolute flex items-center justify-center bg-red-900/50 top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 ${containerClass}`}
|
className={`pointer-events-none absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 items-center justify-center ${backgroundClass} ${containerClass}`}
|
||||||
>
|
>
|
||||||
<div className="relative h-full w-full">
|
<div className="relative h-full w-full overflow-hidden">
|
||||||
<div className="absolute inset-0 overflow-hidden">
|
<div className={`absolute inset-y-0 left-0 ${fillClass}`} style={{ width: fillWidth }} />
|
||||||
<div className="h-full bg-red-700/60" style={{ width: fillWidth }} />
|
<div className="relative z-10 flex h-full flex-col items-center justify-center px-2 text-center font-semibold text-white">
|
||||||
</div>
|
<div className={titleClass}>{title}</div>
|
||||||
<div className={`relative z-10 flex h-full w-full flex-col items-center justify-center text-center font-semibold text-white animate-pulse ${textClass} ${padClass}`}>
|
<div className={`font-medium ${detailClass}`}>{detail}</div>
|
||||||
<div>OVERCURRENT</div>
|
|
||||||
<div className={`mt-0 font-medium text-white ${subTextClass}`}>{safeLabels.join(', ')}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Inter Instance Panel
|
// Inter Instance Panel
|
||||||
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
|
// Purpose: Renders remote rover servers discovered through the inter-instance directory.
|
||||||
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
|
// Scope: Owns external server metadata presentation while reusing RoverQueuesPanel for rover/queue rows.
|
||||||
import { useMemo, useState } from 'react';
|
import { useMemo } from 'react';
|
||||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
import RoverQueuesPanel from '../RoverQueuesPanel/index.jsx';
|
||||||
@@ -133,45 +133,52 @@ function RemoteMediaStrip({ remote }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExternalInstancesCompact() {
|
export function ExternalInstancesCompact({ onBrowse = null }) {
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [popupOpen, setPopupOpen] = useState(false);
|
|
||||||
const enabled = useInterInstanceEnabled();
|
const enabled = useInterInstanceEnabled();
|
||||||
const instances = useRemoteInstances();
|
const instances = useRemoteInstances();
|
||||||
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
|
const visible = useMemo(() => instances.filter((remote) => remote?.online || remote?.url), [instances]);
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
if (!visible.length) return null;
|
if (!visible.length) return null;
|
||||||
|
const browseAction = onBrowse ? (
|
||||||
|
<button type="button" className="button-dark" onClick={onBrowse}>
|
||||||
|
Browse Servers
|
||||||
|
</button>
|
||||||
|
) : null;
|
||||||
return (
|
return (
|
||||||
<div className="space-y-0.5">
|
/*
|
||||||
<div className="grid grid-cols-2 gap-0.5">
|
External instances are intentionally always mounted. Besides removing an
|
||||||
<button type="button" className="button-dark w-full" onClick={() => setExpanded((value) => !value)}>
|
unnecessary disclosure click, this preserves the live queue rows while
|
||||||
{expanded ? 'Hide external' : `Show external (${visible.length})`}
|
the local Rover Queues card can provide one continuous scroll surface for
|
||||||
</button>
|
both its local and external rows. Scrolling belongs to that owning panel,
|
||||||
<button type="button" className="button-dark w-full" onClick={() => setPopupOpen(true)}>
|
so this nested section deliberately keeps its natural content height.
|
||||||
Browse servers
|
*/
|
||||||
</button>
|
<CardFrame
|
||||||
</div>
|
title="External servers below:"
|
||||||
{expanded ? (
|
actions={browseAction}
|
||||||
<div className="space-y-0.5">
|
bodyClassName="space-y-0.5 text-sm"
|
||||||
{visible.map((remote) =>
|
>
|
||||||
remote.online ? (
|
{/*
|
||||||
<RoverQueuesPanel
|
One containing card gives the remote-server collection a clear boundary
|
||||||
key={remote.url}
|
below the local rover rows. Individual remote queue cards stay intact
|
||||||
title={remote.instance?.name || remote.url}
|
inside it because they still own each server's title and operational
|
||||||
roster={remote.roster}
|
status, while this outer title bar owns the collection-wide browser.
|
||||||
turnQueues={remote.turnQueues}
|
*/}
|
||||||
users={remote.users}
|
{visible.map((remote) =>
|
||||||
externalInstance={remote}
|
remote.online ? (
|
||||||
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
<RoverQueuesPanel
|
||||||
/>
|
key={remote.url}
|
||||||
) : (
|
title={remote.instance?.name || remote.url}
|
||||||
<InstancePanel key={remote.url} remote={remote} />
|
roster={remote.roster}
|
||||||
),
|
turnQueues={remote.turnQueues}
|
||||||
)}
|
users={remote.users}
|
||||||
</div>
|
externalInstance={remote}
|
||||||
) : null}
|
disabledOverlay={getRemoteAvailability(remote).blocked ? getRemoteAvailability(remote).overlay : ''}
|
||||||
{popupOpen ? <InterInstancePopup onClose={() => setPopupOpen(false)} /> : null}
|
/>
|
||||||
</div>
|
) : (
|
||||||
|
<InstancePanel key={remote.url} remote={remote} />
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</CardFrame>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,8 +187,9 @@ export function InterInstancePopup({ onClose }) {
|
|||||||
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
|
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/80 p-0.5">
|
||||||
<InterInstanceBrowserFrame
|
<InterInstanceBrowserFrame
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
className="max-w-[calc(100vw-0.5rem)]"
|
scaledOverlay
|
||||||
bodyClassName="max-h-[82vh] overflow-y-auto p-0.5"
|
className="inter-instance-overlay-frame"
|
||||||
|
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -226,6 +234,7 @@ export function InterInstanceBrowserFrame({
|
|||||||
className = '',
|
className = '',
|
||||||
bodyClassName = 'p-0.5',
|
bodyClassName = 'p-0.5',
|
||||||
centered = false,
|
centered = false,
|
||||||
|
scaledOverlay = false,
|
||||||
}) {
|
}) {
|
||||||
const enabled = useInterInstanceEnabled();
|
const enabled = useInterInstanceEnabled();
|
||||||
const instances = useRemoteInstances();
|
const instances = useRemoteInstances();
|
||||||
@@ -245,7 +254,7 @@ export function InterInstanceBrowserFrame({
|
|||||||
<CardFrame
|
<CardFrame
|
||||||
title="External instances"
|
title="External instances"
|
||||||
actions={actions}
|
actions={actions}
|
||||||
className={className}
|
className={classNames(scaledOverlay && 'inter-instance-overlay-scale', className)}
|
||||||
bodyClassName={bodyClassName}
|
bodyClassName={bodyClassName}
|
||||||
clipOverflow={false}
|
clipOverflow={false}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -148,13 +148,25 @@ export default function ControlPadPanel({ compact = false, disabled = false }) {
|
|||||||
return () => {
|
return () => {
|
||||||
clearRepeatTimer();
|
clearRepeatTimer();
|
||||||
/*
|
/*
|
||||||
Mobile controls can unmount when layouts change or the driver leaves the
|
Mobile controls can unmount during an orientation/layout change while a
|
||||||
control surface. Clear the shared flag so a stale mobile precision choice
|
pointer is still captured by the disappearing element. Publish a neutral
|
||||||
cannot leave desktop/keyboard camera tilt in fine-step mode.
|
vector directly during cleanup so neither rover drive nor PTZ pan/tilt
|
||||||
|
can retain the last cell merely because pointerup had nowhere to land.
|
||||||
*/
|
*/
|
||||||
|
activeCellRef.current = null;
|
||||||
|
setDriveVector({ x: 0, y: 0, boost: false }, { source: SOURCE });
|
||||||
|
registerInputState(SOURCE, {
|
||||||
|
keys: [],
|
||||||
|
vector: { x: 0, y: 0, boost: false },
|
||||||
|
activeCell: 'stop',
|
||||||
|
speedMode: speedModeRef.current,
|
||||||
|
lastEvent: 'unmount',
|
||||||
|
});
|
||||||
|
// Clear the shared flag too, so a stale mobile precision choice cannot
|
||||||
|
// leave desktop/keyboard camera tilt in fine-step mode.
|
||||||
setCameraPrecisionMode(false);
|
setCameraPrecisionMode(false);
|
||||||
};
|
};
|
||||||
}, [clearRepeatTimer, setCameraPrecisionMode]);
|
}, [clearRepeatTimer, registerInputState, setCameraPrecisionMode, setDriveVector]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!disabled) return;
|
if (!disabled) return;
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ export default function ModeGateOverlay() {
|
|||||||
*/
|
*/
|
||||||
<InterInstanceBrowserFrame
|
<InterInstanceBrowserFrame
|
||||||
hideWhenEmpty
|
hideWhenEmpty
|
||||||
className="max-w-[calc(100vw-0.5rem)]"
|
scaledOverlay
|
||||||
bodyClassName="max-h-[86vh] overflow-y-auto p-0.5"
|
className="inter-instance-overlay-frame"
|
||||||
|
bodyClassName="inter-instance-overlay-body overflow-y-auto p-0.5"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
// Overcurrent Limiter Panel
|
// Overcurrent Protection Panel
|
||||||
// Purpose: Defines the Overcurrent Limiter Panel module and the local helpers/components used in this file.
|
// Purpose: Presents detailed server-calculated motor stress and command-tracking diagnostics.
|
||||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
// Scope: Read-only status surface for the assigned rover; protection and recovery remain server-owned.
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { useControlSelector } from '../../controls/index.js';
|
import { useControlSelector } from '../../controls/index.js';
|
||||||
import { OVERCURRENT_GROUPS } from '../../controls/overcurrentLimiter.js';
|
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
|
||||||
const GROUP_LABELS = {
|
const MOTOR_LABELS = {
|
||||||
drive: 'Drive wheels',
|
leftWheel: 'Left wheel',
|
||||||
aux: 'Aux motors',
|
rightWheel: 'Right wheel',
|
||||||
|
mainBrush: 'Main brush',
|
||||||
|
sideBrush: 'Side brush',
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatPct(value) {
|
function formatPct(value) {
|
||||||
@@ -16,8 +17,20 @@ function formatPct(value) {
|
|||||||
return `${Math.round(value * 100)}%`;
|
return `${Math.round(value * 100)}%`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSpeed(value) {
|
||||||
|
if (!Number.isFinite(value)) return '--';
|
||||||
|
return `${Math.round(value)} mm/s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatClassification(value) {
|
||||||
|
if (value === 'stalled') return 'Stalled';
|
||||||
|
if (value === 'partial') return 'Partial';
|
||||||
|
if (value === 'moving') return 'Moving';
|
||||||
|
return 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
||||||
const width = `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
|
const width = `${Math.round(Math.max(0, Math.min(1, Number(value) || 0)) * 100)}%`;
|
||||||
return (
|
return (
|
||||||
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
|
<div className="h-2 w-full overflow-hidden rounded bg-slate-800">
|
||||||
<div className={`h-full ${color}`} style={{ width }} />
|
<div className={`h-full ${color}`} style={{ width }} />
|
||||||
@@ -25,51 +38,74 @@ function ProgressBar({ value, color = 'bg-emerald-500' }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function statusLabel(protection) {
|
||||||
|
if (protection?.adminImmune) return 'Admin bypass';
|
||||||
|
if (protection?.status === 'stopped') return 'Drive stopped';
|
||||||
|
if (protection?.status === 'limiting') return 'Limiting';
|
||||||
|
if (protection?.status === 'overcurrent') return 'Overcurrent detected';
|
||||||
|
if (protection?.status === 'recovering') return 'Recovering';
|
||||||
|
return 'Ready';
|
||||||
|
}
|
||||||
|
|
||||||
export default function OvercurrentLimiterPanel() {
|
export default function OvercurrentLimiterPanel() {
|
||||||
const roverId = useControlSelector((control) => control.state.roverId);
|
const roverId = useControlSelector((control) => control.state.roverId);
|
||||||
const overcurrentLimiter = useControlSelector((control) => control.overcurrentLimiter);
|
const protection = useControlSelector((control) => control.overcurrentLimiter);
|
||||||
const groups = useMemo(() => OVERCURRENT_GROUPS.map((group) => group.key), []);
|
const motors = protection?.motors || {};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame
|
<CardFrame
|
||||||
title="Overcurrent limiter"
|
title="Overcurrent protection"
|
||||||
meta={overcurrentLimiter?.adminImmune ? 'Admin immune' : 'Active'}
|
meta={statusLabel(protection)}
|
||||||
bodyClassName="space-y-0.5 text-sm"
|
bodyClassName="space-y-1 text-sm"
|
||||||
>
|
>
|
||||||
{!roverId ? (
|
{!roverId ? (
|
||||||
<p className="text-xs text-slate-500">Assign a rover to view limiter status.</p>
|
<p className="text-xs text-slate-500">Assign a rover to view protection status.</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-1">
|
||||||
{groups.map((key) => {
|
{Object.entries(MOTOR_LABELS).map(([key, label]) => {
|
||||||
const cap = overcurrentLimiter?.caps?.[key]?.cap ?? 0;
|
const motor = motors[key] || {};
|
||||||
const over = overcurrentLimiter?.overcurrent?.groups?.[key] ?? false;
|
const wheel = key === 'leftWheel' || key === 'rightWheel';
|
||||||
const scale = overcurrentLimiter?.scales?.perGroup?.[key] ?? 1;
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="space-y-0.5">
|
<div key={key} className="space-y-0.5 border-b border-slate-800 pb-1 last:border-0">
|
||||||
<div className="flex items-center justify-between text-xs">
|
<div className="flex items-center justify-between text-xs">
|
||||||
<span className="text-slate-200">{GROUP_LABELS[key] || key}</span>
|
<span className="text-slate-200">{label}</span>
|
||||||
<span className={over ? 'text-red-300' : 'text-slate-400'}>
|
<span className={motor.overcurrent ? 'text-red-300' : 'text-slate-400'}>
|
||||||
{over ? 'overcurrent' : 'ok'}
|
{motor.overcurrent ? 'Overcurrent' : 'Clear'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
||||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
<span>Stress {formatPct(motor.stress)}</span>
|
||||||
<span>Cap</span>
|
<span>Output {formatPct(motor.cap)}</span>
|
||||||
<span>{formatPct(cap)}</span>
|
|
||||||
</div>
|
|
||||||
<ProgressBar value={cap} color="bg-amber-500" />
|
|
||||||
<div className="flex items-center justify-between text-[0.7rem] text-slate-400">
|
|
||||||
<span>Scale</span>
|
|
||||||
<span>{formatPct(scale)}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<ProgressBar
|
||||||
|
value={motor.stress}
|
||||||
|
color={motor.overcurrent ? 'bg-red-500' : 'bg-amber-500'}
|
||||||
|
/>
|
||||||
|
{wheel ? (
|
||||||
|
<div className="grid grid-cols-2 gap-1 text-[0.65rem] text-slate-500">
|
||||||
|
<span>{`Command ${formatSpeed(Math.abs(Number(motor.commandedSpeed)))}`}</span>
|
||||||
|
<span>{`Measured ${formatSpeed(motor.measuredSpeed)}`}</span>
|
||||||
|
<span>{`Progress ${formatPct(motor.progressRatio)}`}</span>
|
||||||
|
<span>{`${formatClassification(motor.classification)} · stall ${formatPct(motor.stallFactor)}`}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
{protection?.drive?.blocked ? (
|
||||||
|
<p className="text-xs text-red-300">
|
||||||
|
{protection.drive.requiresNeutral
|
||||||
|
? 'Drive is stopped. Release controls to neutral before resuming.'
|
||||||
|
: 'Drive is stopped while the wheel condition clears.'}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
<div className="text-[0.7rem] text-slate-400">
|
<div className="text-[0.7rem] text-slate-400">
|
||||||
<div>{`Down rate ${overcurrentLimiter?.config?.downRatePerSec}/s · Up rate ${overcurrentLimiter?.config?.upRatePerSec}/s`}</div>
|
<div>{`Drive output ${formatPct(protection?.drive?.cap)}`}</div>
|
||||||
<div>{`Release delay ${overcurrentLimiter?.config?.releaseDelaySec}s`}</div>
|
<div>
|
||||||
<div>{`Output rate ${overcurrentLimiter?.config?.outputRateMs}ms`}</div>
|
{protection?.adminImmune
|
||||||
|
? 'This session bypasses all overcurrent enforcement.'
|
||||||
|
: 'Status and output limits are calculated by the server.'}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const PLACEHOLDER_STATS = Object.freeze({
|
|||||||
qualityMax: 70,
|
qualityMax: 70,
|
||||||
rxBitrateMbit: 72.2,
|
rxBitrateMbit: 72.2,
|
||||||
txBitrateMbit: 58.5,
|
txBitrateMbit: 58.5,
|
||||||
|
downloadMbps: 12.4,
|
||||||
|
uploadMbps: 3.7,
|
||||||
rxBytes: 12400000,
|
rxBytes: 12400000,
|
||||||
txBytes: 2300000,
|
txBytes: 2300000,
|
||||||
rxPackets: 12640,
|
rxPackets: 12640,
|
||||||
@@ -304,14 +306,22 @@ export default function PiHostStatsCard() {
|
|||||||
|
|
||||||
<section className="min-w-0 space-y-0.5">
|
<section className="min-w-0 space-y-0.5">
|
||||||
<ColumnTitle label="WiFi" />
|
<ColumnTitle label="WiFi" />
|
||||||
<div className="surface grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-1">
|
<div className="grid min-w-0 grid-cols-2 gap-0.5">
|
||||||
<div className="min-w-0">
|
<div className="surface grid min-w-0 grid-cols-[minmax(0,1fr)_auto] items-start gap-1">
|
||||||
<div className="truncate text-base leading-tight text-slate-100">SSID: {valueOrDash(wifi.ssidSample)}</div>
|
<div className="min-w-0">
|
||||||
<div className="text-xs text-slate-400">{formatFrequency(wifi.frequencyMhz)}</div>
|
<div className="truncate text-base leading-tight text-slate-100">SSID: {valueOrDash(wifi.ssidSample)}</div>
|
||||||
|
<div className="text-xs text-slate-400">{formatFrequency(wifi.frequencyMhz)}</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<div className={`font-semibold leading-tight ${toneTextClass(currentSignalTone)}`}>{formatDbm(wifi.signalDbm)}</div>
|
||||||
|
<SignalBars bars={bars} tone={currentSignalTone} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="surface flex min-w-0 flex-col justify-center gap-0.5 text-xs">
|
||||||
<div className={`font-semibold leading-tight ${toneTextClass(currentSignalTone)}`}>{formatDbm(wifi.signalDbm)}</div>
|
{/* Actual traffic belongs beside connection identity and signal,
|
||||||
<SignalBars bars={bars} tone={currentSignalTone} />
|
while negotiated link rates remain in their existing rows. */}
|
||||||
|
<ThroughputRow label="Download" value={formatBitrate(wifi.downloadMbps)} />
|
||||||
|
<ThroughputRow label="Upload" value={formatBitrate(wifi.uploadMbps)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<BarRow
|
<BarRow
|
||||||
@@ -347,6 +357,15 @@ function StatRow({ label, value, compact = false, valueClassName = 'text-slate-1
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThroughputRow({ label, value }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-w-0 items-center justify-between gap-1">
|
||||||
|
<span className="shrink-0 text-slate-400">{label}</span>
|
||||||
|
<span className="min-w-0 truncate text-right text-slate-100">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function BarRow({ label, value, detail = null, percent, tone = 'neutral' }) {
|
function BarRow({ label, value, detail = null, percent, tone = 'neutral' }) {
|
||||||
return (
|
return (
|
||||||
<div className="surface">
|
<div className="surface">
|
||||||
|
|||||||
@@ -23,6 +23,8 @@ 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 { trackAnalyticsEvent } from '../../analytics/index.js';
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../../themes/index.js';
|
||||||
|
|
||||||
const PTZ_DEFAULT_COLOR = '#38bdf8';
|
const PTZ_DEFAULT_COLOR = '#38bdf8';
|
||||||
|
|
||||||
@@ -186,46 +188,30 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PtzMobileZoomButtons({ disabled = false }) {
|
function PtzMobileZoomButtons({ disabled = false }) {
|
||||||
const { nudgeServo } = useControlActions();
|
const { setCameraAxisIntent } = useControlActions();
|
||||||
const repeatTimerRef = useRef(null);
|
|
||||||
|
|
||||||
const stopZoom = useCallback(() => {
|
const stopZoom = useCallback(() => {
|
||||||
/*
|
/*
|
||||||
Mobile zoom is intentionally routed through the normal camera-up/down
|
Zero only releases the zoom axis. The PTZ adapter combines it with any
|
||||||
control action instead of emitting PTZ socket commands directly. That
|
pan/tilt direction still held on the movement pad, so lifting one finger
|
||||||
keeps the zoom buttons on the same path as keyboard/gamepad camera tilt,
|
cannot erase the other finger's intent.
|
||||||
and the PTZ adapter remains the one place that translates "camera nudge"
|
|
||||||
into Reolink zoom pulses.
|
|
||||||
*/
|
*/
|
||||||
if (repeatTimerRef.current) {
|
setCameraAxisIntent(0);
|
||||||
clearInterval(repeatTimerRef.current);
|
}, [setCameraAxisIntent]);
|
||||||
repeatTimerRef.current = null;
|
|
||||||
}
|
|
||||||
/*
|
|
||||||
Zero is a zoom-only release signal in the PTZ adapter. Using the global
|
|
||||||
stop action here previously erased a simultaneously held pan/tilt vector,
|
|
||||||
making mixed touch controls unexpectedly stop the camera.
|
|
||||||
*/
|
|
||||||
nudgeServo(0);
|
|
||||||
}, [nudgeServo]);
|
|
||||||
|
|
||||||
const startZoom = useCallback(
|
const startZoom = useCallback(
|
||||||
(direction) => (event) => {
|
(direction) => (event) => {
|
||||||
/*
|
/*
|
||||||
Send an immediate nudge and then repeat while held. The adapter turns
|
Publish held state once. The adapter owns the single motion heartbeat,
|
||||||
each nudge into a short zoom pulse, so repeating the standard action is
|
so this button no longer creates a second interval whose queued callback
|
||||||
the simplest way to get continuous hold-to-zoom without adding another
|
could run after pointerup and restart zoom.
|
||||||
PTZ-specific command loop.
|
|
||||||
*/
|
*/
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
stopZoom();
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
nudgeServo(direction);
|
setCameraAxisIntent(direction);
|
||||||
repeatTimerRef.current = setInterval(() => {
|
|
||||||
nudgeServo(direction);
|
|
||||||
}, 120);
|
|
||||||
},
|
},
|
||||||
[disabled, nudgeServo, stopZoom],
|
[disabled, setCameraAxisIntent],
|
||||||
);
|
);
|
||||||
const stopFromPointer = useCallback(
|
const stopFromPointer = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -240,16 +226,12 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
|||||||
() => () => {
|
() => () => {
|
||||||
/*
|
/*
|
||||||
A touch surface can unmount during orientation changes or fullscreen
|
A touch surface can unmount during orientation changes or fullscreen
|
||||||
close while a pointer is still down. Clear the repeat timer here so a
|
close while a pointer is still down. Explicitly clear zoom here because
|
||||||
held zoom button cannot keep firing camera-up/down actions after the
|
an unmounted DOM node cannot deliver its pointerup/pointercancel event.
|
||||||
mobile controls have disappeared.
|
|
||||||
*/
|
*/
|
||||||
if (repeatTimerRef.current) {
|
setCameraAxisIntent(0);
|
||||||
clearInterval(repeatTimerRef.current);
|
|
||||||
repeatTimerRef.current = null;
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[],
|
[setCameraAxisIntent],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -261,7 +243,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
|||||||
onPointerDown={startZoom(-1)}
|
onPointerDown={startZoom(-1)}
|
||||||
onPointerUp={stopFromPointer}
|
onPointerUp={stopFromPointer}
|
||||||
onPointerCancel={stopFromPointer}
|
onPointerCancel={stopFromPointer}
|
||||||
onPointerLeave={stopFromPointer}
|
onLostPointerCapture={stopFromPointer}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
Zoom out
|
Zoom out
|
||||||
@@ -273,7 +255,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
|||||||
onPointerDown={startZoom(1)}
|
onPointerDown={startZoom(1)}
|
||||||
onPointerUp={stopFromPointer}
|
onPointerUp={stopFromPointer}
|
||||||
onPointerCancel={stopFromPointer}
|
onPointerCancel={stopFromPointer}
|
||||||
onPointerLeave={stopFromPointer}
|
onLostPointerCapture={stopFromPointer}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
Zoom in
|
Zoom in
|
||||||
@@ -562,7 +544,10 @@ function PtzDesktopFullscreen({ ptz, releasePending }) {
|
|||||||
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
<main className="min-h-0 shrink-0 overflow-hidden bg-black" style={{ aspectRatio: '16 / 9' }}>
|
||||||
<PtzMediaPane ptz={ptz} open framed />
|
<PtzMediaPane ptz={ptz} open framed />
|
||||||
</main>
|
</main>
|
||||||
<aside className="flex min-h-0 min-w-56 flex-1 flex-col gap-0.5 overflow-y-auto bg-neutral-950 text-sm">
|
{/* 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} />
|
<PtzQueueSummary ptz={ptz} />
|
||||||
{ptz?.isOperator ? (
|
{ptz?.isOperator ? (
|
||||||
<PtzLightingControls ptz={ptz} />
|
<PtzLightingControls ptz={ptz} />
|
||||||
@@ -717,9 +702,16 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
|||||||
const routeExitReleaseTimerRef = useRef(null);
|
const routeExitReleaseTimerRef = useRef(null);
|
||||||
const participantRef = useRef(false);
|
const participantRef = useRef(false);
|
||||||
const closingThroughButtonRef = useRef(false);
|
const closingThroughButtonRef = useRef(false);
|
||||||
|
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||||
|
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||||
|
});
|
||||||
const isMobile = layout !== 'desktop';
|
const isMobile = layout !== 'desktop';
|
||||||
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
const canUse = Boolean(ptz?.canUse || isVerified || role === 'admin' || role === 'lockdown');
|
||||||
const isParticipant = Boolean(ptz?.isOperator || ptz?.queuedPosition);
|
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 = getPageThemeClass(pageSettings?.backgroundTheme);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Route-exit cleanup runs after the last render, so retain the latest
|
// Route-exit cleanup runs after the last render, so retain the latest
|
||||||
@@ -825,7 +817,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
|||||||
|
|
||||||
if (!featureEnabled) {
|
if (!featureEnabled) {
|
||||||
return (
|
return (
|
||||||
<main className="flex min-h-[100dvh] items-center justify-center bg-black p-2 text-slate-100">
|
<main className={`flex min-h-[100dvh] items-center justify-center p-2 text-slate-100 ${pageBackgroundClass}`}>
|
||||||
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
<CardFrame title="PTZ camera" bodyClassName="space-y-1 p-2 text-sm">
|
||||||
<p>The PTZ camera is not available.</p>
|
<p>The PTZ camera is not available.</p>
|
||||||
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
<button type="button" className="button-dark w-full" onClick={() => navigate('/')}>Return to driver page</button>
|
||||||
@@ -835,7 +827,10 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="h-[100dvh] w-full overflow-hidden bg-black text-slate-100">
|
<main className={`h-[100dvh] 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
|
<CardFrame
|
||||||
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
title={isMobile ? '' : ptz?.name || 'PTZ Camera'}
|
||||||
actions={isMobile ? null : (
|
actions={isMobile ? null : (
|
||||||
@@ -847,7 +842,7 @@ export function PtzControllerPage({ layout = 'desktop' }) {
|
|||||||
fillHeight
|
fillHeight
|
||||||
clipOverflow={false}
|
clipOverflow={false}
|
||||||
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
|
className="h-[100dvh] w-[100vw] rounded-none border-0 !bg-black"
|
||||||
bodyClassName="relative min-h-0 flex-1"
|
bodyClassName={`relative min-h-0 flex-1 ${pageBackgroundClass}`}
|
||||||
>
|
>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
layout === 'mobile-landscape' ? (
|
layout === 'mobile-landscape' ? (
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import OverseerPreferencePanel from '../OverseerPreferencePanel/index.jsx';
|
|||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
import { useManualDockAssist } from '../../features/manualDockAssist/useManualDockAssist.js';
|
||||||
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
import { themeGapClass, themeStackClass } from '../../themes/index.js';
|
||||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
|
|
||||||
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
const CHAT_DOCK_INITIAL_HEIGHT = 224;
|
||||||
@@ -196,9 +196,18 @@ function QueueReplayLinksRow() {
|
|||||||
removes that item instead of preserving an empty grid column.
|
removes that item instead of preserving an empty grid column.
|
||||||
*/
|
*/
|
||||||
return (
|
return (
|
||||||
<div className={`flex ${themeGapClass}`}>
|
<div className={`flex items-stretch ${themeGapClass}`}>
|
||||||
<div className={`min-w-0 basis-0 grow-[1] space-y-0.5`}>
|
<div className="relative min-w-0 basis-0 grow-[1]">
|
||||||
<RoverQueuesPanel />
|
{/*
|
||||||
|
The absolutely positioned queue card is removed from flex cross-size
|
||||||
|
calculation. Replay and the links/PTZ stack therefore define the row
|
||||||
|
height entirely through normal CSS layout; this relative column then
|
||||||
|
stretches to that established height and gives the queue card an exact
|
||||||
|
containing block to fill without any JavaScript measurement.
|
||||||
|
*/}
|
||||||
|
<div className="absolute inset-0 min-h-0">
|
||||||
|
<RoverQueuesPanel fillHeight />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0 basis-0 grow-[0.9]">
|
<div className="min-w-0 basis-0 grow-[0.9]">
|
||||||
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
<ReplaySourcesPanel panelId="replay-sources-desktop" fillHeight />
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
// Rover Queues Panel
|
// Rover Queues Panel
|
||||||
// Purpose: Defines the Rover Queues Panel module and the local helpers/components used in this file.
|
// Purpose: Defines the Rover Queues Panel 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.
|
||||||
import { useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
import { useSharedClock } from '../../hooks/useSharedClock.js';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
import QueueTargetRow from '../QueueTargetRow/index.jsx';
|
||||||
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
import { trackAnalyticsEvent } from '../../analytics/index.js';
|
||||||
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
|
import { openExternalRover } from '../../lib/interInstanceTransfer.js';
|
||||||
import { ExternalInstancesCompact } from '../InterInstancePanel/index.jsx';
|
import { ExternalInstancesCompact, InterInstancePopup } from '../InterInstancePanel/index.jsx';
|
||||||
import { isFeatureEnabled } from '../../lib/features.js';
|
import { isFeatureEnabled } from '../../lib/features.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
|
|
||||||
@@ -25,6 +25,74 @@ function batteryClass(rover) {
|
|||||||
return 'text-emerald-300';
|
return 'text-emerald-300';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ScrollableQueueContent({ enabled = false, children }) {
|
||||||
|
const viewportRef = useRef(null);
|
||||||
|
const contentRef = useRef(null);
|
||||||
|
const [canScrollDown, setCanScrollDown] = useState(false);
|
||||||
|
|
||||||
|
const measureScrollRemainder = useCallback(() => {
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
if (!viewport) return;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Fractional layout measurements can leave a sub-pixel remainder even at
|
||||||
|
the bottom. The tolerance keeps the cue from flickering there while still
|
||||||
|
showing it for any meaningful hidden queue content.
|
||||||
|
*/
|
||||||
|
const remaining = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight;
|
||||||
|
setCanScrollDown(remaining > 2);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return undefined;
|
||||||
|
const viewport = viewportRef.current;
|
||||||
|
const content = contentRef.current;
|
||||||
|
if (!viewport || !content) return undefined;
|
||||||
|
|
||||||
|
/*
|
||||||
|
Queue membership, user chips, and remote instances all update from live
|
||||||
|
session state and may change the content height without resizing the
|
||||||
|
window. Observing both boxes keeps the overflow cue accurate without
|
||||||
|
using JavaScript to calculate or assign the panel's actual height.
|
||||||
|
*/
|
||||||
|
const observer = new ResizeObserver(measureScrollRemainder);
|
||||||
|
observer.observe(viewport);
|
||||||
|
observer.observe(content);
|
||||||
|
const animationFrame = window.requestAnimationFrame(measureScrollRemainder);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.cancelAnimationFrame(animationFrame);
|
||||||
|
observer.disconnect();
|
||||||
|
};
|
||||||
|
}, [enabled, measureScrollRemainder]);
|
||||||
|
|
||||||
|
if (!enabled) return children;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative flex min-h-0 flex-1 flex-col">
|
||||||
|
<div
|
||||||
|
ref={viewportRef}
|
||||||
|
className="min-h-0 flex-1 overflow-y-auto"
|
||||||
|
onScroll={measureScrollRemainder}
|
||||||
|
>
|
||||||
|
<div ref={contentRef}>{children}</div>
|
||||||
|
</div>
|
||||||
|
{canScrollDown ? (
|
||||||
|
/*
|
||||||
|
This indicator is deliberately removed from layout so it consumes no
|
||||||
|
permanent panel height. It also adds no padding to the scroll content,
|
||||||
|
keeping scrollHeight stable when the indicator disappears at the
|
||||||
|
bottom. The explicit stacking level and opaque background keep queue
|
||||||
|
cards from painting through or over the message.
|
||||||
|
*/
|
||||||
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 bg-neutral-950 px-1 py-0.5 text-center text-xs font-semibold text-slate-200">
|
||||||
|
Scroll for more ↓
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function RoverQueuesPanel({
|
export default function RoverQueuesPanel({
|
||||||
title = 'Rovers',
|
title = 'Rovers',
|
||||||
roster: rosterOverride = null,
|
roster: rosterOverride = null,
|
||||||
@@ -32,6 +100,7 @@ export default function RoverQueuesPanel({
|
|||||||
users: usersOverride = null,
|
users: usersOverride = null,
|
||||||
externalInstance = null,
|
externalInstance = null,
|
||||||
disabledOverlay = '',
|
disabledOverlay = '',
|
||||||
|
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 ?? []);
|
||||||
@@ -50,6 +119,7 @@ export default function RoverQueuesPanel({
|
|||||||
const { requestControl, rebootOwnRover } = useSessionActions();
|
const { requestControl, rebootOwnRover } = useSessionActions();
|
||||||
const [pending, setPending] = useState({});
|
const [pending, setPending] = useState({});
|
||||||
const [rebootPending, setRebootPending] = useState(false);
|
const [rebootPending, setRebootPending] = useState(false);
|
||||||
|
const [interInstancePopupOpen, setInterInstancePopupOpen] = useState(false);
|
||||||
const externalMode = Boolean(externalInstance);
|
const externalMode = Boolean(externalInstance);
|
||||||
const externalBlocked = Boolean(externalMode && disabledOverlay);
|
const externalBlocked = Boolean(externalMode && disabledOverlay);
|
||||||
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
|
const includeInterInstanceSettings = pageSettings?.interInstanceTransferSettings !== false;
|
||||||
@@ -146,8 +216,8 @@ export default function RoverQueuesPanel({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const headerActions =
|
const rebootAction =
|
||||||
!externalMode && role !== 'spectator' && assignedRoverId ? (
|
role !== 'spectator' && assignedRoverId ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={handleRebootOwnRover}
|
onClick={handleRebootOwnRover}
|
||||||
@@ -159,77 +229,98 @@ export default function RoverQueuesPanel({
|
|||||||
</button>
|
</button>
|
||||||
) : null;
|
) : null;
|
||||||
|
|
||||||
|
const headerActions = !externalMode ? rebootAction : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CardFrame title={title} actions={headerActions} bodyClassName="space-y-0.5 text-sm">
|
<>
|
||||||
<div className="relative space-y-0.5">
|
<CardFrame
|
||||||
{rosterItems.length === 0 ? (
|
title={title}
|
||||||
<p className="text-sm text-slate-500">No rovers registered.</p>
|
actions={headerActions}
|
||||||
) : (
|
fillHeight={fillHeight}
|
||||||
<ul className="space-y-0.5 text-sm">
|
bodyClassName="space-y-0.5 text-sm"
|
||||||
{rosterItems.map((rover) => {
|
>
|
||||||
const roverId = String(rover.id);
|
<ScrollableQueueContent enabled={fillHeight}>
|
||||||
const info = turnQueues?.[roverId] || null;
|
<div className="relative space-y-0.5">
|
||||||
const queue = info?.queue || [];
|
{rosterItems.length === 0 ? (
|
||||||
const deadline = info?.idleDeadline || info?.deadline || null;
|
<p className="text-sm text-slate-500">No rovers registered.</p>
|
||||||
const remainingSeconds =
|
) : (
|
||||||
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
<ul className="space-y-0.5 text-sm">
|
||||||
const currentId = info?.current || null;
|
{rosterItems.map((rover) => {
|
||||||
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
const roverId = String(rover.id);
|
||||||
const nextId =
|
const info = turnQueues?.[roverId] || null;
|
||||||
queue.length > 1
|
const queue = info?.queue || [];
|
||||||
? currentIdx >= 0
|
const deadline = info?.idleDeadline || info?.deadline || null;
|
||||||
? queue[(currentIdx + 1) % queue.length]
|
const remainingSeconds =
|
||||||
: queue[0]
|
deadline && deadline > now ? Math.ceil((deadline - now) / 1000) : deadline ? 0 : null;
|
||||||
: null;
|
const currentId = info?.current || null;
|
||||||
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
const currentIdx = currentId ? queue.findIndex((id) => id === currentId) : -1;
|
||||||
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
const nextId =
|
||||||
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
queue.length > 1
|
||||||
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
? currentIdx >= 0
|
||||||
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
? queue[(currentIdx + 1) % queue.length]
|
||||||
const locked = Boolean(rover.locked);
|
: queue[0]
|
||||||
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
: null;
|
||||||
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
const isSelfCurrent = Boolean(selfId && currentId && currentId === selfId);
|
||||||
const buttonLabel = pending[roverId]
|
const isSelfNext = Boolean(selfId && nextId && nextId === selfId);
|
||||||
? '...'
|
const showTimer = remainingSeconds != null && (isSelfCurrent || isSelfNext);
|
||||||
: lockedBlocked
|
const isPrivateOpen = Boolean(rover?.private?.enabled && rover?.private?.open);
|
||||||
? lockLabel
|
const isGrantedClosedPrivate = Boolean(rover?.private?.enabled && !rover?.private?.open);
|
||||||
: externalMode
|
const locked = Boolean(rover.locked);
|
||||||
? 'Open'
|
const lockedBlocked = locked && (externalMode || (!adminCapable && !isGrantedClosedPrivate));
|
||||||
: 'request';
|
const lockLabel = rover.lockReason ? `locked: ${rover.lockReason}` : 'locked';
|
||||||
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
const buttonLabel = pending[roverId]
|
||||||
return (
|
? '...'
|
||||||
<QueueTargetRow
|
: lockedBlocked
|
||||||
key={rover.id}
|
? lockLabel
|
||||||
target={{ ...rover, rover, roverId, id: roverId }}
|
: externalMode
|
||||||
queue={queue}
|
? 'Open'
|
||||||
currentId={currentId}
|
: 'request';
|
||||||
nextId={nextId}
|
const canClickRow = canRequest && !lockedBlocked && !pending[roverId];
|
||||||
selfId={selfId}
|
return (
|
||||||
lookupUser={lookupUser}
|
<QueueTargetRow
|
||||||
canClick={canClickRow}
|
key={rover.id}
|
||||||
pending={Boolean(pending[roverId])}
|
target={{ ...rover, rover, roverId, id: roverId }}
|
||||||
locked={locked}
|
queue={queue}
|
||||||
lockedBlocked={lockedBlocked}
|
currentId={currentId}
|
||||||
privateOpen={isPrivateOpen}
|
nextId={nextId}
|
||||||
buttonLabel={buttonLabel}
|
selfId={selfId}
|
||||||
batteryLabel={formatBattery(rover)}
|
lookupUser={lookupUser}
|
||||||
batteryClassName={batteryClass(rover)}
|
canClick={canClickRow}
|
||||||
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
|
pending={Boolean(pending[roverId])}
|
||||||
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
|
locked={locked}
|
||||||
onRequest={handleRequest}
|
lockedBlocked={lockedBlocked}
|
||||||
showAction={Boolean(canRequest)}
|
privateOpen={isPrivateOpen}
|
||||||
/>
|
buttonLabel={buttonLabel}
|
||||||
);
|
batteryLabel={formatBattery(rover)}
|
||||||
})}
|
batteryClassName={batteryClass(rover)}
|
||||||
</ul>
|
timerLabel={showTimer ? (isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`) : ''}
|
||||||
)}
|
thumbnailUrl={externalMode ? rover?.snapshots?.latestUrl : ''}
|
||||||
{externalBlocked ? (
|
onRequest={handleRequest}
|
||||||
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
showAction={Boolean(canRequest)}
|
||||||
{disabledOverlay}
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
{externalBlocked ? (
|
||||||
|
<div className="absolute inset-0 z-10 flex items-center justify-center rounded bg-black/70 px-2 text-center text-sm font-semibold text-slate-100">
|
||||||
|
{disabledOverlay}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{!externalMode && interInstanceEnabled ? (
|
||||||
|
<ExternalInstancesCompact onBrowse={() => setInterInstancePopupOpen(true)} />
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</ScrollableQueueContent>
|
||||||
{!externalMode && interInstanceEnabled ? <ExternalInstancesCompact /> : null}
|
</CardFrame>
|
||||||
</div>
|
{interInstancePopupOpen ? (
|
||||||
</CardFrame>
|
/*
|
||||||
|
The popup remains owned by the local Rover Queues panel because its
|
||||||
|
title-bar action opens it. External queue panels never render that
|
||||||
|
action, which prevents recursively opening browsers from remote rows.
|
||||||
|
*/
|
||||||
|
<InterInstancePopup onClose={() => setInterInstancePopupOpen(false)} />
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Settings Panel
|
// Settings Panel
|
||||||
// Purpose: Defines the Settings Panel module and the local helpers/components used in this file.
|
// Purpose: Defines the Settings Panel 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.
|
||||||
import { useMemo } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||||
import AuthPanel from '../AuthPanel/index.jsx';
|
import AuthPanel from '../AuthPanel/index.jsx';
|
||||||
import AdminPanel from '../AdminPanel/index.jsx';
|
import AdminPanel from '../AdminPanel/index.jsx';
|
||||||
@@ -19,6 +19,13 @@ import { useSocket } from '../../context/SocketContext.jsx';
|
|||||||
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
import { AUDIO_SETTINGS_DEFAULTS, VIDEO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
import { trackAnalyticsEvent, trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||||
|
import {
|
||||||
|
DEFAULT_PAGE_THEME_KEY,
|
||||||
|
PAGE_THEME_OPTIONS,
|
||||||
|
getPageTheme,
|
||||||
|
getPageThemeClass,
|
||||||
|
normalizePageThemeKey,
|
||||||
|
} from '../../themes/index.js';
|
||||||
|
|
||||||
const manualTabs = [
|
const manualTabs = [
|
||||||
{ key: 'start', label: 'Start OI' },
|
{ key: 'start', label: 'Start OI' },
|
||||||
@@ -66,6 +73,24 @@ function SettingHelp({ children }) {
|
|||||||
return <p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">{children}</p>;
|
return <p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">{children}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ThemePreviewCard({ title, className = '' }) {
|
||||||
|
// Use the production CardFrame rather than a lookalike rectangle. This makes the demonstration
|
||||||
|
// honest about borders, opaque card surfaces, and the exact amount of theme visible in a gap.
|
||||||
|
return (
|
||||||
|
<CardFrame
|
||||||
|
title={title}
|
||||||
|
className={className}
|
||||||
|
bodyClassName="flex min-h-0 flex-1 flex-col justify-center gap-1 p-1"
|
||||||
|
fillHeight
|
||||||
|
>
|
||||||
|
{/* Neutral placeholder lines suggest real panel content without making the preview look like
|
||||||
|
an interactive control surface or tying it to any one driver/PTZ layout. */}
|
||||||
|
<div className="h-1.5 w-4/5 rounded-full bg-neutral-600/80" />
|
||||||
|
<div className="h-1.5 w-3/5 rounded-full bg-neutral-700/90" />
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function RangeSetting({ label, value, disabled = false, onChange }) {
|
function RangeSetting({ label, value, disabled = false, onChange }) {
|
||||||
// Range settings need enough horizontal room for accurate pointer input, so the slider spans
|
// Range settings need enough horizontal room for accurate pointer input, so the slider spans
|
||||||
// the row while the percentage value stays beside the label for quick feedback.
|
// the row while the percentage value stays beside the label for quick feedback.
|
||||||
@@ -116,6 +141,7 @@ export default function SettingsPanel() {
|
|||||||
swapMobileControlColumns: false,
|
swapMobileControlColumns: false,
|
||||||
driveMacroBackoffEnabled: true,
|
driveMacroBackoffEnabled: true,
|
||||||
interInstanceTransferSettings: true,
|
interInstanceTransferSettings: true,
|
||||||
|
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||||
});
|
});
|
||||||
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||||
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
|
const { value: videoSettings, save: saveVideoSettings } = useSettingsNamespace('video', VIDEO_SETTINGS_DEFAULTS);
|
||||||
@@ -126,6 +152,11 @@ export default function SettingsPanel() {
|
|||||||
? pageSettings.driveMacroBackoffEnabled
|
? pageSettings.driveMacroBackoffEnabled
|
||||||
: true;
|
: true;
|
||||||
const interInstanceTransferSettings = pageSettings?.interInstanceTransferSettings !== false;
|
const interInstanceTransferSettings = pageSettings?.interInstanceTransferSettings !== false;
|
||||||
|
const savedPageThemeKey = normalizePageThemeKey(pageSettings?.backgroundTheme);
|
||||||
|
const [previewPageThemeKey, setPreviewPageThemeKey] = useState(savedPageThemeKey);
|
||||||
|
const previewPageTheme = getPageTheme(previewPageThemeKey);
|
||||||
|
const savedPageTheme = getPageTheme(savedPageThemeKey);
|
||||||
|
const hasUnsavedPageTheme = previewPageThemeKey !== savedPageThemeKey;
|
||||||
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
|
||||||
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
|
||||||
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
|
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
|
||||||
@@ -141,6 +172,13 @@ export default function SettingsPanel() {
|
|||||||
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
|
const videoColorFilter = normalizeVideoFilter(videoSettings?.colorFilter);
|
||||||
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
|
const videoFilterCycleKeyLabel = formatKeyLabel(keymap?.videoFilterCycle?.[0]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Settings load after the provider mounts and can also be replaced by an incoming inter-instance
|
||||||
|
// transfer. Resynchronize only when the persisted key changes; browsing the local preview does
|
||||||
|
// not touch pageSettings, so Previous/Next choices are not accidentally reset.
|
||||||
|
setPreviewPageThemeKey(savedPageThemeKey);
|
||||||
|
}, [savedPageThemeKey]);
|
||||||
|
|
||||||
const sensorButtons = useMemo(
|
const sensorButtons = useMemo(
|
||||||
() => [
|
() => [
|
||||||
{ key: 'start', label: 'Enable stream', enable: true },
|
{ key: 'start', label: 'Enable stream', enable: true },
|
||||||
@@ -202,6 +240,32 @@ export default function SettingsPanel() {
|
|||||||
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
|
trackAnalyticsEvent('settings_change', { setting: 'interInstanceTransferSettings', value: checked });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const movePageThemePreview = (direction) => {
|
||||||
|
const currentIndex = PAGE_THEME_OPTIONS.findIndex((theme) => theme.key === previewPageThemeKey);
|
||||||
|
const safeIndex = currentIndex >= 0 ? currentIndex : 0;
|
||||||
|
// Theme browsing wraps in both directions so the Back and Next buttons remain useful at the
|
||||||
|
// ends of the catalog instead of forcing the user to reverse through every previous option.
|
||||||
|
const nextIndex = (safeIndex + direction + PAGE_THEME_OPTIONS.length) % PAGE_THEME_OPTIONS.length;
|
||||||
|
setPreviewPageThemeKey(PAGE_THEME_OPTIONS[nextIndex].key);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageThemeSelect = (event) => {
|
||||||
|
setPreviewPageThemeKey(normalizePageThemeKey(event.target.value));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePageThemeSave = () => {
|
||||||
|
// Browsing is intentionally local. Persist only this explicit choice so opening Page settings
|
||||||
|
// and experimenting with patterns cannot unexpectedly alter the driver or PTZ page background.
|
||||||
|
savePageSettings((current) => ({
|
||||||
|
...(current ?? {}),
|
||||||
|
backgroundTheme: previewPageThemeKey,
|
||||||
|
}));
|
||||||
|
trackAnalyticsEvent('settings_change', {
|
||||||
|
setting: 'backgroundTheme',
|
||||||
|
value: previewPageThemeKey,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleVideoFilterChange = (event) => {
|
const handleVideoFilterChange = (event) => {
|
||||||
const nextFilter = normalizeVideoFilter(event.target.value);
|
const nextFilter = normalizeVideoFilter(event.target.value);
|
||||||
|
|
||||||
@@ -238,6 +302,73 @@ export default function SettingsPanel() {
|
|||||||
stretched column. Audio spans both columns because volume sliders need extra width
|
stretched column. Audio spans both columns because volume sliders need extra width
|
||||||
for comfortable pointer control. */}
|
for comfortable pointer control. */}
|
||||||
<div className="grid gap-1.5 lg:grid-cols-2">
|
<div className="grid gap-1.5 lg:grid-cols-2">
|
||||||
|
<CardFrame
|
||||||
|
title="Background theme"
|
||||||
|
className="lg:col-span-2"
|
||||||
|
bodyClassName="space-y-1.5 p-1 text-sm"
|
||||||
|
>
|
||||||
|
{/* Back/Next provide fast visual browsing, while the dropdown remains the direct
|
||||||
|
route to a known theme in a growing catalog. Neither control saves implicitly. */}
|
||||||
|
<div className="grid grid-cols-[auto_minmax(0,1fr)_auto] items-center gap-1.5">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-dark min-w-16 text-sm"
|
||||||
|
onClick={() => movePageThemePreview(-1)}
|
||||||
|
>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<select
|
||||||
|
aria-label="Preview background theme"
|
||||||
|
value={previewPageThemeKey}
|
||||||
|
onChange={handlePageThemeSelect}
|
||||||
|
className="field-input min-w-0 px-1 py-0.5 text-sm"
|
||||||
|
>
|
||||||
|
{PAGE_THEME_OPTIONS.map((theme) => (
|
||||||
|
<option key={theme.key} value={theme.key}>
|
||||||
|
{theme.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-dark min-w-16 text-sm"
|
||||||
|
onClick={() => movePageThemePreview(1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* This miniature layout contains both a full-height vertical seam and horizontal
|
||||||
|
seams between stacked cards. It exercises the exact gap directions the artwork
|
||||||
|
must serve on the driver and PTZ pages. */}
|
||||||
|
<div
|
||||||
|
className={`page-theme-preview grid h-32 grid-cols-[minmax(0,1.15fr)_minmax(0,0.85fr)_minmax(0,0.85fr)] grid-rows-2 gap-0.5 overflow-hidden rounded border border-neutral-500/70 p-0.5 ${getPageThemeClass(previewPageThemeKey)}`}
|
||||||
|
>
|
||||||
|
<ThemePreviewCard title="Video" className="row-span-2" />
|
||||||
|
<ThemePreviewCard title="Controls" />
|
||||||
|
<ThemePreviewCard title="Queue" />
|
||||||
|
<ThemePreviewCard title="Chat" className="col-span-2" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-1.5">
|
||||||
|
<p className="min-w-0 text-xs text-white">
|
||||||
|
Previewing <span className="font-semibold">{previewPageTheme.label}</span>
|
||||||
|
{hasUnsavedPageTheme ? (
|
||||||
|
<span className="text-slate-300">; saved theme is {savedPageTheme.label}.</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-slate-300">; this is your saved theme.</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="button-dark min-w-24 text-sm disabled:cursor-default disabled:opacity-45"
|
||||||
|
disabled={!hasUnsavedPageTheme}
|
||||||
|
onClick={handlePageThemeSave}
|
||||||
|
>
|
||||||
|
Save theme
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</CardFrame>
|
||||||
<CardFrame title="HUD" bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="HUD" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
|
<SettingRow className="grid-cols-[auto_minmax(0,1fr)] max-[420px]:grid-cols-[auto_minmax(0,1fr)]">
|
||||||
<input
|
<input
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
// Purpose: Defines the Tabs module and the local helpers/components used in this file.
|
// Purpose: Defines the Tabs 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.
|
||||||
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
import { createContext, useCallback, useContext, useMemo, useState, useEffect } from 'react';
|
||||||
import { themeGapClass, themeStackClass } from '../../themeFlags.js';
|
import { themeGapClass, themeStackClass } from '../../themes/index.js';
|
||||||
|
|
||||||
const TabsContext = createContext(null);
|
const TabsContext = createContext(null);
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Vip PTZ Camera Card
|
// Vip PTZ Camera Card
|
||||||
// Purpose: Provides the verified-user entry point and fullscreen controller for the single Reolink PTZ camera.
|
// 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.
|
// Scope: Owns PTZ UI state only; server-side PTZ ownership, rover handoff, and command authorization remain authoritative.
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import ChatPanel from '../ChatPanel/index.jsx';
|
import ChatPanel from '../ChatPanel/index.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
@@ -11,13 +11,12 @@ import PtzLiveVideo from '../PtzLiveVideo/index.jsx';
|
|||||||
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
import ReplaySourcesPanel from '../ReplaySourcesPanel/index.jsx';
|
||||||
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
import KeyPill from './VipAudioUploadCard/KeyPill.jsx';
|
||||||
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../../context/SessionContext.jsx';
|
||||||
import { useControlSelector } from '../../controls/index.js';
|
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||||
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
import { formatKeyLabel } from '../../controls/keymapUtils.js';
|
||||||
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
import { usePtzCameraSnapshots } from '../../hooks/usePtzCameraSnapshot.js';
|
||||||
import { isFeatureEnabled } from '../../lib/features.js';
|
import { isFeatureEnabled } from '../../lib/features.js';
|
||||||
|
|
||||||
const PTZ_CAMERA_ID = 'ptz-camera';
|
const PTZ_CAMERA_ID = 'ptz-camera';
|
||||||
const PTZ_ZOOM_SPEED = 0.55;
|
|
||||||
|
|
||||||
function formatRemaining(deadline) {
|
function formatRemaining(deadline) {
|
||||||
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
const remaining = Math.max(0, Math.ceil((Number(deadline || 0) - Date.now()) / 1000));
|
||||||
@@ -218,22 +217,25 @@ function PtzLightingControls({ ptz, disabled = false }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PtzMobileZoomButtons({ disabled = false }) {
|
function PtzMobileZoomButtons({ disabled = false }) {
|
||||||
const { ptzMove, ptzStop } = useSessionActions();
|
const { setCameraAxisIntent } = useControlActions();
|
||||||
const stopZoom = useCallback(() => {
|
const stopZoom = useCallback(() => {
|
||||||
ptzStop().catch(() => {});
|
// This is a zoom-only release; the shared adapter retains any simultaneous
|
||||||
}, [ptzStop]);
|
// pan/tilt intent from the movement pad in its next combined motion state.
|
||||||
|
setCameraAxisIntent(0);
|
||||||
|
}, [setCameraAxisIntent]);
|
||||||
const startZoom = useCallback(
|
const startZoom = useCallback(
|
||||||
(direction) => (event) => {
|
(direction) => (event) => {
|
||||||
/*
|
/*
|
||||||
Mobile needs explicit zoom targets because the regular mobile drive pad
|
The adapter owns renewal for held PTZ state. Publishing the direction
|
||||||
is already used for pan/tilt. Desktop does not render these buttons; it
|
once avoids a component-local repeat timer and keeps this legacy card on
|
||||||
uses the mapped camera up/down controls shown in the reference panel.
|
the exact same motion path as the dedicated PTZ route.
|
||||||
*/
|
*/
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (disabled) return;
|
if (disabled) return;
|
||||||
ptzMove({ pan: 0, tilt: 0, zoom: direction * PTZ_ZOOM_SPEED }).catch(() => {});
|
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||||
|
setCameraAxisIntent(direction);
|
||||||
},
|
},
|
||||||
[disabled, ptzMove],
|
[disabled, setCameraAxisIntent],
|
||||||
);
|
);
|
||||||
const stopFromPointer = useCallback(
|
const stopFromPointer = useCallback(
|
||||||
(event) => {
|
(event) => {
|
||||||
@@ -244,6 +246,15 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
|||||||
[disabled, 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 (
|
return (
|
||||||
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
<div className="mobile-touch-control grid grid-cols-2 gap-0.5 text-sm">
|
||||||
<button
|
<button
|
||||||
@@ -253,7 +264,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
|||||||
onPointerDown={startZoom(-1)}
|
onPointerDown={startZoom(-1)}
|
||||||
onPointerUp={stopFromPointer}
|
onPointerUp={stopFromPointer}
|
||||||
onPointerCancel={stopFromPointer}
|
onPointerCancel={stopFromPointer}
|
||||||
onPointerLeave={stopFromPointer}
|
onLostPointerCapture={stopFromPointer}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
Zoom out
|
Zoom out
|
||||||
@@ -265,7 +276,7 @@ function PtzMobileZoomButtons({ disabled = false }) {
|
|||||||
onPointerDown={startZoom(1)}
|
onPointerDown={startZoom(1)}
|
||||||
onPointerUp={stopFromPointer}
|
onPointerUp={stopFromPointer}
|
||||||
onPointerCancel={stopFromPointer}
|
onPointerCancel={stopFromPointer}
|
||||||
onPointerLeave={stopFromPointer}
|
onLostPointerCapture={stopFromPointer}
|
||||||
onContextMenu={(event) => event.preventDefault()}
|
onContextMenu={(event) => event.preventDefault()}
|
||||||
>
|
>
|
||||||
Zoom in
|
Zoom in
|
||||||
|
|||||||
@@ -30,6 +30,12 @@ const CHAT_FOCUS_DEFAULT = {
|
|||||||
selfSocketId: null,
|
selfSocketId: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const CHAT_HISTORY_DEFAULT = {
|
||||||
|
messageHistory: [],
|
||||||
|
};
|
||||||
|
const CHAT_HISTORY_LIMIT = 10;
|
||||||
|
const CHAT_HISTORY_ENTRY_LIMIT = 200;
|
||||||
|
|
||||||
// Chat messages and typing indicators are the highest-churn chat data. Keeping
|
// Chat messages and typing indicators are the highest-churn chat data. Keeping
|
||||||
// them in their own context lets transcript components update without forcing
|
// them in their own context lets transcript components update without forcing
|
||||||
// controlled composer inputs to re-render and re-commit unchanged attributes.
|
// controlled composer inputs to re-render and re-commit unchanged attributes.
|
||||||
@@ -44,6 +50,12 @@ const ChatActionsContext = createContext(CHAT_ACTIONS_DEFAULT);
|
|||||||
// blur events, but it should not be tied to incoming chat traffic either.
|
// blur events, but it should not be tied to incoming chat traffic either.
|
||||||
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
|
const ChatFocusContext = createContext(CHAT_FOCUS_DEFAULT);
|
||||||
|
|
||||||
|
// Sent-message history has its own subscription because it changes only when
|
||||||
|
// this browser successfully posts a message. Keeping it separate prevents the
|
||||||
|
// transcript and focus consumers from re-rendering when the persisted history
|
||||||
|
// changes, while still giving every mounted composer one shared history source.
|
||||||
|
const ChatHistoryContext = createContext(CHAT_HISTORY_DEFAULT);
|
||||||
|
|
||||||
const ChatContext = createContext({
|
const ChatContext = createContext({
|
||||||
...CHAT_TIMELINE_DEFAULT,
|
...CHAT_TIMELINE_DEFAULT,
|
||||||
...CHAT_ACTIONS_DEFAULT,
|
...CHAT_ACTIONS_DEFAULT,
|
||||||
@@ -56,6 +68,7 @@ export function ChatProvider({ children }) {
|
|||||||
const { pushAlert } = useSessionActions();
|
const { pushAlert } = useSessionActions();
|
||||||
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||||
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
const { value: profileSettings } = useSettingsNamespace('profile', { nickname: '', profileImageUrl: '' });
|
||||||
|
const { value: chatSettings, save: saveChatSettings } = useSettingsNamespace('chat', CHAT_HISTORY_DEFAULT);
|
||||||
const [messages, setMessages] = useState([]);
|
const [messages, setMessages] = useState([]);
|
||||||
const [typing, setTyping] = useState([]);
|
const [typing, setTyping] = useState([]);
|
||||||
const [isChatFocused, setIsChatFocused] = useState(false);
|
const [isChatFocused, setIsChatFocused] = useState(false);
|
||||||
@@ -229,11 +242,29 @@ export function ChatProvider({ children }) {
|
|||||||
hasTts: Boolean(tts),
|
hasTts: Boolean(tts),
|
||||||
length: typeof text === 'string' ? text.trim().length : 0,
|
length: typeof text === 'string' ? text.trim().length : 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Record only messages accepted by the server so ArrowUp never
|
||||||
|
recalls a draft that failed to send. The settings subsystem uses
|
||||||
|
one browser cookie for every namespace, so both the entry length
|
||||||
|
and history count are deliberately bounded to leave room for the
|
||||||
|
user's other persisted preferences.
|
||||||
|
*/
|
||||||
|
const historyEntry = typeof text === 'string' ? text.slice(0, CHAT_HISTORY_ENTRY_LIMIT) : '';
|
||||||
|
if (historyEntry) {
|
||||||
|
saveChatSettings((current) => {
|
||||||
|
const currentHistory = Array.isArray(current?.messageHistory) ? current.messageHistory : [];
|
||||||
|
return {
|
||||||
|
...(current || {}),
|
||||||
|
messageHistory: [...currentHistory.slice(-(CHAT_HISTORY_LIMIT - 1)), historyEntry],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
resolve(resp);
|
resolve(resp);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
[profileImage, socket],
|
[profileImage, saveChatSettings, socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const registerInputRef = useCallback((el, options = {}) => {
|
const registerInputRef = useCallback((el, options = {}) => {
|
||||||
@@ -301,13 +332,23 @@ export function ChatProvider({ children }) {
|
|||||||
[isChatFocused, session?.socketId],
|
[isChatFocused, session?.socketId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const historyValue = useMemo(
|
||||||
|
() => ({
|
||||||
|
// Treat malformed or hand-edited cookie data as an empty history. This
|
||||||
|
// keeps keyboard navigation safe without mutating unrelated settings.
|
||||||
|
messageHistory: Array.isArray(chatSettings?.messageHistory) ? chatSettings.messageHistory : [],
|
||||||
|
}),
|
||||||
|
[chatSettings],
|
||||||
|
);
|
||||||
|
|
||||||
const value = useMemo(
|
const value = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
...timelineValue,
|
...timelineValue,
|
||||||
...actionsValue,
|
...actionsValue,
|
||||||
...focusValue,
|
...focusValue,
|
||||||
|
...historyValue,
|
||||||
}),
|
}),
|
||||||
[actionsValue, focusValue, timelineValue],
|
[actionsValue, focusValue, historyValue, timelineValue],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -316,7 +357,9 @@ export function ChatProvider({ children }) {
|
|||||||
<ChatTimelineContext.Provider value={timelineValue}>
|
<ChatTimelineContext.Provider value={timelineValue}>
|
||||||
<ChatActionsContext.Provider value={actionsValue}>
|
<ChatActionsContext.Provider value={actionsValue}>
|
||||||
<ChatFocusContext.Provider value={focusValue}>
|
<ChatFocusContext.Provider value={focusValue}>
|
||||||
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
<ChatHistoryContext.Provider value={historyValue}>
|
||||||
|
<ChatContext.Provider value={value}>{children}</ChatContext.Provider>
|
||||||
|
</ChatHistoryContext.Provider>
|
||||||
</ChatFocusContext.Provider>
|
</ChatFocusContext.Provider>
|
||||||
</ChatActionsContext.Provider>
|
</ChatActionsContext.Provider>
|
||||||
</ChatTimelineContext.Provider>
|
</ChatTimelineContext.Provider>
|
||||||
@@ -354,3 +397,11 @@ export function useChatFocus() {
|
|||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useChatHistory() {
|
||||||
|
const ctx = useContext(ChatHistoryContext);
|
||||||
|
if (!ctx) {
|
||||||
|
throw new Error('useChatHistory must be used inside ChatProvider');
|
||||||
|
}
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
|||||||
@@ -426,8 +426,6 @@ export function SessionProvider({ children }) {
|
|||||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||||
ptzRelease: () => emitWithAck('ptzCamera:release'),
|
ptzRelease: () => emitWithAck('ptzCamera:release'),
|
||||||
ptzMove: (payload = {}) => emitWithAck('ptzCamera:move', payload),
|
|
||||||
ptzStop: () => emitWithAck('ptzCamera:stop'),
|
|
||||||
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
|
ptzSpotlight: (payload = {}) => emitWithAck('ptzCamera:spotlight', payload),
|
||||||
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
|
ptzIr: (payload = {}) => emitWithAck('ptzCamera:ir', payload),
|
||||||
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
|
ptzListPresets: () => emitWithAck('ptzCamera:presets:list'),
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ export function TelemetryProvider({ children }) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function handleSensorFrame({ roverId, sensors = {}, frame = {} }) {
|
function handleSensorFrame({ roverId, sensors = {}, frame = {}, overcurrentProtection = null }) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
const previous = framesRef.current[roverId] ?? {};
|
const previous = framesRef.current[roverId] ?? {};
|
||||||
framesRef.current = {
|
framesRef.current = {
|
||||||
@@ -240,6 +240,10 @@ export function TelemetryProvider({ children }) {
|
|||||||
...previous,
|
...previous,
|
||||||
roverId,
|
roverId,
|
||||||
sensors,
|
sensors,
|
||||||
|
// Protection is server-calculated policy state, not a native Roomba
|
||||||
|
// sensor. Keeping it beside `sensors` preserves that distinction while
|
||||||
|
// allowing selectors to read one coherent telemetry snapshot.
|
||||||
|
overcurrentProtection,
|
||||||
raw: frame?.data || null,
|
raw: frame?.data || null,
|
||||||
receivedAt: Date.now(),
|
receivedAt: Date.now(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -30,11 +30,7 @@ import { canonicalizeKeyInput } from './keymapUtils.js';
|
|||||||
import { useSettingsNamespace } from '../settings/index.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionActions, useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
import { HORN_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
|
||||||
import {
|
import { useOvercurrentLimiter } from './overcurrentLimiter.js';
|
||||||
applyAuxOvercurrentScale,
|
|
||||||
applyDriveOvercurrentScale,
|
|
||||||
useOvercurrentLimiter,
|
|
||||||
} from './overcurrentLimiter.js';
|
|
||||||
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
import { usePtzControlAdapter } from './ptzControlAdapter.js';
|
||||||
|
|
||||||
const ControlSystemContext = createContext(null);
|
const ControlSystemContext = createContext(null);
|
||||||
@@ -49,6 +45,7 @@ const CONTROL_ACTION_NAMES = [
|
|||||||
'setAuxMotors',
|
'setAuxMotors',
|
||||||
'setServoAngle',
|
'setServoAngle',
|
||||||
'nudgeServo',
|
'nudgeServo',
|
||||||
|
'setCameraAxisIntent',
|
||||||
'goServoHome',
|
'goServoHome',
|
||||||
'setCameraPrecisionMode',
|
'setCameraPrecisionMode',
|
||||||
'runMacro',
|
'runMacro',
|
||||||
@@ -176,15 +173,13 @@ export function ControlSystemProvider({ children }) {
|
|||||||
);
|
);
|
||||||
const { homeAssistantSetState } = useSessionActions();
|
const { homeAssistantSetState } = useSessionActions();
|
||||||
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
const overcurrentLimiter = useOvercurrentLimiter(roverId);
|
||||||
const driveTransform = useCallback(
|
/*
|
||||||
(speeds) => applyDriveOvercurrentScale(speeds, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
|
Motor commands now remain raw until they reach the server-owned protection
|
||||||
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
|
service. Applying another transform here would make non-admin commands pass
|
||||||
);
|
through two independent limiters and would let browser lifecycle determine
|
||||||
const auxTransform = useCallback(
|
whether protection exists at all.
|
||||||
(values) => applyAuxOvercurrentScale(values, overcurrentLimiter.scales, overcurrentLimiter.adminImmune),
|
*/
|
||||||
[overcurrentLimiter.adminImmune, overcurrentLimiter.scales],
|
const pipeline = useCommandPipeline();
|
||||||
);
|
|
||||||
const pipeline = useCommandPipeline({ driveTransform, auxTransform });
|
|
||||||
const ptzControls = usePtzControlAdapter();
|
const ptzControls = usePtzControlAdapter();
|
||||||
|
|
||||||
const turnOnAllLights = useCallback(() => {
|
const turnOnAllLights = useCallback(() => {
|
||||||
@@ -288,39 +283,6 @@ export function ControlSystemProvider({ children }) {
|
|||||||
dispatch({ type: 'control/record-intent' });
|
dispatch({ type: 'control/record-intent' });
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const driveSpeedsRef = useRef(state.drive.speeds);
|
|
||||||
const auxValuesRef = useRef(state.aux);
|
|
||||||
const limiterScaleToken = useMemo(() => JSON.stringify(overcurrentLimiter.scales), [overcurrentLimiter.scales]);
|
|
||||||
const limiterDriveSentAtRef = useRef(0);
|
|
||||||
const limiterAuxSentAtRef = useRef(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
driveSpeedsRef.current = state.drive.speeds;
|
|
||||||
}, [state.drive.speeds]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
auxValuesRef.current = state.aux;
|
|
||||||
}, [state.aux]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!pipeline.roverId || overcurrentLimiter.adminImmune || !overcurrentLimiter.isActive) return;
|
|
||||||
const outputRateMs = Math.max(0, Number(overcurrentLimiter?.config?.outputRateMs) || 0);
|
|
||||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
||||||
const drive = driveSpeedsRef.current || { left: 0, right: 0 };
|
|
||||||
const aux = auxValuesRef.current || { main: 0, side: 0, vacuum: 0 };
|
|
||||||
const driveActive = Boolean(drive.left || drive.right);
|
|
||||||
const auxActive = Boolean(aux.main || aux.side || aux.vacuum);
|
|
||||||
if (!driveActive && !auxActive) return;
|
|
||||||
if (driveActive && now - limiterDriveSentAtRef.current >= outputRateMs) {
|
|
||||||
limiterDriveSentAtRef.current = now;
|
|
||||||
pipeline.sendDriveDirect(drive);
|
|
||||||
}
|
|
||||||
if (auxActive && now - limiterAuxSentAtRef.current >= outputRateMs) {
|
|
||||||
limiterAuxSentAtRef.current = now;
|
|
||||||
pipeline.sendAuxMotors(aux);
|
|
||||||
}
|
|
||||||
}, [limiterScaleToken, overcurrentLimiter.adminImmune, overcurrentLimiter.config, overcurrentLimiter.isActive, pipeline]);
|
|
||||||
|
|
||||||
const setDriveVector = useCallback(
|
const setDriveVector = useCallback(
|
||||||
(vector, meta = {}) => {
|
(vector, meta = {}) => {
|
||||||
const speedOptions = { ...(meta.speedOptions || {}) };
|
const speedOptions = { ...(meta.speedOptions || {}) };
|
||||||
@@ -391,21 +353,12 @@ export function ControlSystemProvider({ children }) {
|
|||||||
|
|
||||||
const setServoAngle = useCallback(
|
const setServoAngle = useCallback(
|
||||||
(value, options = {}) => {
|
(value, options = {}) => {
|
||||||
if (ptzControls.isActive) {
|
/*
|
||||||
/*
|
Absolute servo positions belong only to rover hardware. PTZ zoom now
|
||||||
Servo-capable rover controls converge here from keyboard, gamepad,
|
enters through setCameraAxisIntent as a signed held velocity, so this
|
||||||
desktop, and mobile. When the active control target is the PTZ camera,
|
function must not infer zoom direction by comparing unrelated absolute
|
||||||
route the intent through the PTZ adapter instead of making the rover
|
angle values from gamepad/manual-dock callers.
|
||||||
command pipeline understand camera zoom semantics.
|
*/
|
||||||
*/
|
|
||||||
const baseline = typeof servoAngleRef.current === 'number' ? servoAngleRef.current : 0;
|
|
||||||
const numeric = Number(value);
|
|
||||||
if (!Number.isFinite(numeric)) return;
|
|
||||||
ptzControls.pulseZoom(numeric - baseline);
|
|
||||||
servoAngleRef.current = numeric;
|
|
||||||
recordControlIntent();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!pipeline.servoConfig) return;
|
if (!pipeline.servoConfig) return;
|
||||||
const force = Boolean(options?.force);
|
const force = Boolean(options?.force);
|
||||||
if (state.manualDockAssist?.active && !force) return;
|
if (state.manualDockAssist?.active && !force) return;
|
||||||
@@ -415,13 +368,24 @@ export function ControlSystemProvider({ children }) {
|
|||||||
servoAngleRef.current = clamped;
|
servoAngleRef.current = clamped;
|
||||||
recordControlIntent();
|
recordControlIntent();
|
||||||
},
|
},
|
||||||
[pipeline, ptzControls, recordControlIntent, state.manualDockAssist?.active],
|
[pipeline, recordControlIntent, state.manualDockAssist?.active],
|
||||||
);
|
);
|
||||||
|
|
||||||
const nudgeServo = useCallback(
|
const nudgeServo = useCallback(
|
||||||
(delta = 0) => {
|
(delta = 0) => {
|
||||||
|
if (ptzControls.isActive) {
|
||||||
|
/*
|
||||||
|
A PTZ camera has no absolute browser-side servo angle. Treat a nudge
|
||||||
|
as held zoom direction and, importantly, preserve zero as an explicit
|
||||||
|
release. The previous fallback converted nudgeServo(0) into a positive
|
||||||
|
default step, so releasing the mobile zoom button could zoom in again.
|
||||||
|
*/
|
||||||
|
ptzControls.setZoomIntent(delta);
|
||||||
|
recordControlIntent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const config = pipeline.servoConfig;
|
const config = pipeline.servoConfig;
|
||||||
if (!config && !ptzControls.isActive) return;
|
if (!config) return;
|
||||||
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
|
const step = typeof delta === 'number' && delta !== 0 ? delta : config?.nudgeDegrees || 1;
|
||||||
const baseline =
|
const baseline =
|
||||||
typeof servoAngleRef.current === 'number'
|
typeof servoAngleRef.current === 'number'
|
||||||
@@ -431,7 +395,28 @@ export function ControlSystemProvider({ children }) {
|
|||||||
: 0;
|
: 0;
|
||||||
setServoAngle(baseline + step);
|
setServoAngle(baseline + step);
|
||||||
},
|
},
|
||||||
[pipeline.servoConfig, ptzControls.isActive, setServoAngle],
|
[pipeline.servoConfig, ptzControls, recordControlIntent, setServoAngle],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCameraAxisIntent = useCallback(
|
||||||
|
(direction = 0) => {
|
||||||
|
/*
|
||||||
|
Keyboard, touch, and gamepad all need an explicit way to say that a
|
||||||
|
camera axis returned to neutral. Rover servos remain position/nudge
|
||||||
|
based, so returning false tells those callers to continue through their
|
||||||
|
existing rover implementation without introducing PTZ rules there.
|
||||||
|
*/
|
||||||
|
if (!ptzControls.isActive) return false;
|
||||||
|
ptzControls.setZoomIntent(direction);
|
||||||
|
/*
|
||||||
|
Do not dispatch recordControlIntent here. Gamepads publish their neutral
|
||||||
|
and held axes every animation frame; the PTZ adapter deduplicates state
|
||||||
|
and owns its 250 ms heartbeat, so a React reducer update per frame would
|
||||||
|
add churn without representing a new user action.
|
||||||
|
*/
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
[ptzControls],
|
||||||
);
|
);
|
||||||
|
|
||||||
const goServoHome = useCallback(() => {
|
const goServoHome = useCallback(() => {
|
||||||
@@ -730,6 +715,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
nudgeServo,
|
nudgeServo,
|
||||||
|
setCameraAxisIntent,
|
||||||
goServoHome,
|
goServoHome,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
runMacro,
|
runMacro,
|
||||||
@@ -757,6 +743,7 @@ export function ControlSystemProvider({ children }) {
|
|||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
nudgeServo,
|
nudgeServo,
|
||||||
|
setCameraAxisIntent,
|
||||||
goServoHome,
|
goServoHome,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
runMacro,
|
runMacro,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ export default function GamepadInputManager() {
|
|||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
|
setCameraAxisIntent,
|
||||||
runMacro,
|
runMacro,
|
||||||
toggleHeadlight,
|
toggleHeadlight,
|
||||||
toggleLaser,
|
toggleLaser,
|
||||||
@@ -173,6 +174,7 @@ export default function GamepadInputManager() {
|
|||||||
runMacro,
|
runMacro,
|
||||||
saveGamepadSettings,
|
saveGamepadSettings,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
|
setCameraAxisIntent,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMode,
|
setMode,
|
||||||
setServoAngle,
|
setServoAngle,
|
||||||
@@ -187,6 +189,9 @@ export default function GamepadInputManager() {
|
|||||||
if (!latest) return;
|
if (!latest) return;
|
||||||
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
|
const activePad = pickActivePad(hubState.pads, latest.activeSignature);
|
||||||
if (!activePad) {
|
if (!activePad) {
|
||||||
|
// A disconnected controller cannot deliver a final neutral axis sample.
|
||||||
|
// Publish it here so PTZ zoom never depends on the browser doing so.
|
||||||
|
latest.setCameraAxisIntent(0);
|
||||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||||
lastVectorRef.current = ZERO_VECTOR;
|
lastVectorRef.current = ZERO_VECTOR;
|
||||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||||
@@ -204,6 +209,9 @@ export default function GamepadInputManager() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isTextEntryActive()) {
|
if (isTextEntryActive()) {
|
||||||
|
// Entering text blocks gamepad control immediately, including a held
|
||||||
|
// camera axis that otherwise would keep its last PTZ zoom direction.
|
||||||
|
latest.setCameraAxisIntent(0);
|
||||||
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
if (!areVectorsEqual(lastVectorRef.current, ZERO_VECTOR)) {
|
||||||
lastVectorRef.current = ZERO_VECTOR;
|
lastVectorRef.current = ZERO_VECTOR;
|
||||||
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
latest.setDriveVector(ZERO_VECTOR, { source: SOURCE });
|
||||||
@@ -302,7 +310,14 @@ export default function GamepadInputManager() {
|
|||||||
handleButtonEdge('laserToggle', false);
|
handleButtonEdge('laserToggle', false);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Math.abs(outputs.cameraAxis) > 0.001) {
|
/*
|
||||||
|
PTZ zoom consumes the live signed gamepad axis, including its zero
|
||||||
|
position, so releasing the stick is an explicit stop instead of merely
|
||||||
|
ending calls to the old servo updater. Rover camera servos return false
|
||||||
|
here and continue through their established absolute/velocity mapping.
|
||||||
|
*/
|
||||||
|
const handledAsPtzZoom = latest.setCameraAxisIntent(outputs.cameraAxis);
|
||||||
|
if (!handledAsPtzZoom && Math.abs(outputs.cameraAxis) > 0.001) {
|
||||||
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
handleCameraAxis(outputs.cameraAxis, profile.calibration);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ export default function KeyboardInputManager() {
|
|||||||
setDriveVector,
|
setDriveVector,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
nudgeServo,
|
nudgeServo,
|
||||||
|
setCameraAxisIntent,
|
||||||
runMacro,
|
runMacro,
|
||||||
stopAllMotion,
|
stopAllMotion,
|
||||||
registerInputState,
|
registerInputState,
|
||||||
@@ -215,6 +216,13 @@ export default function KeyboardInputManager() {
|
|||||||
const ensureServoLoop = useCallback(() => {
|
const ensureServoLoop = useCallback(() => {
|
||||||
const direction = computeServoDirection();
|
const direction = computeServoDirection();
|
||||||
if (direction === 0) {
|
if (direction === 0) {
|
||||||
|
/*
|
||||||
|
Keyup must publish a real neutral PTZ zoom intent before the repeat loop
|
||||||
|
disappears. Rover servos ignore this generic axis release and retain
|
||||||
|
their existing nudge behavior because setCameraAxisIntent returns false
|
||||||
|
whenever PTZ is not the active target.
|
||||||
|
*/
|
||||||
|
latestRef.current?.setCameraAxisIntent(0);
|
||||||
stopServoLoop();
|
stopServoLoop();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -235,7 +243,9 @@ export default function KeyboardInputManager() {
|
|||||||
const tokensSnapshot = new Set(activeTokensRef.current);
|
const tokensSnapshot = new Set(activeTokensRef.current);
|
||||||
const precisionActive = isPrecisionDriveActive(tokensSnapshot, latest.keymap);
|
const precisionActive = isPrecisionDriveActive(tokensSnapshot, latest.keymap);
|
||||||
const servoStep = precisionActive ? PRECISION_SERVO_NUDGE_DEGREES : latest.servoStep;
|
const servoStep = precisionActive ? PRECISION_SERVO_NUDGE_DEGREES : latest.servoStep;
|
||||||
latest.nudgeServo(nextDirection * servoStep);
|
if (!latest.setCameraAxisIntent(nextDirection)) {
|
||||||
|
latest.nudgeServo(nextDirection * servoStep);
|
||||||
|
}
|
||||||
servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs);
|
servoIntervalRef.current = setTimeout(tick, latest.servoRepeatMs);
|
||||||
};
|
};
|
||||||
servoIntervalRef.current = setTimeout(tick, 0);
|
servoIntervalRef.current = setTimeout(tick, 0);
|
||||||
@@ -394,6 +404,7 @@ export default function KeyboardInputManager() {
|
|||||||
servoRepeatMs,
|
servoRepeatMs,
|
||||||
servoStep,
|
servoStep,
|
||||||
setAuxMotors,
|
setAuxMotors,
|
||||||
|
setCameraAxisIntent,
|
||||||
setCameraPrecisionMode,
|
setCameraPrecisionMode,
|
||||||
setDriveVector,
|
setDriveVector,
|
||||||
setMicPttActive,
|
setMicPttActive,
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
// Overcurrent Limiter Hook/Utility
|
// Overcurrent Protection View Hook
|
||||||
// Purpose: Applies client-side overcurrent guard logic to reduce harmful command spikes. Scope: Tracks limiter state and exposes gated dispatch behavior to controls.
|
// Purpose: Adapts server-authoritative protection telemetry for existing control and HUD consumers.
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
// Scope: Contains no protection timers or command scaling; enforcement belongs exclusively to the server service.
|
||||||
|
|
||||||
|
import { useMemo } from 'react';
|
||||||
import { useTelemetrySelector } from '../context/TelemetryContext.jsx';
|
import { useTelemetrySelector } from '../context/TelemetryContext.jsx';
|
||||||
import { overcurrentFlagsEqual, selectOvercurrentFlags } from '../context/telemetryViews.js';
|
|
||||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
|
|
||||||
export const OVERCURRENT_GROUPS = [
|
export const OVERCURRENT_GROUPS = [
|
||||||
@@ -10,189 +11,63 @@ export const OVERCURRENT_GROUPS = [
|
|||||||
{ key: 'aux', motors: ['mainBrush', 'sideBrush'] },
|
{ key: 'aux', motors: ['mainBrush', 'sideBrush'] },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_OVERCURRENT_LIMITS = {
|
const EMPTY_PROTECTION = Object.freeze({
|
||||||
downRatePerSec: 0.4,
|
status: 'idle',
|
||||||
upRatePerSec: 0.5,
|
bypassed: false,
|
||||||
releaseDelaySec: 2.5,
|
drive: Object.freeze({ cap: 1, blocked: false, requiresNeutral: false, stopReason: null }),
|
||||||
outputRateMs: 250,
|
motors: Object.freeze({}),
|
||||||
};
|
config: Object.freeze({}),
|
||||||
|
});
|
||||||
|
|
||||||
const RECOVERED_CAP_THRESHOLD = 0.999;
|
function selectOvercurrentProtection(frame) {
|
||||||
|
return frame?.overcurrentProtection || EMPTY_PROTECTION;
|
||||||
function createInitialCaps() {
|
|
||||||
return OVERCURRENT_GROUPS.reduce((acc, group) => {
|
|
||||||
acc[group.key] = { cap: 1, clearSec: 0 };
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampUnit(value) {
|
export function useOvercurrentLimiter(roverId) {
|
||||||
if (!Number.isFinite(value)) return 0;
|
|
||||||
return Math.max(0, Math.min(1, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useOvercurrentLimiter(roverId, options = {}) {
|
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const overcurrentFlags = useTelemetrySelector(roverId, selectOvercurrentFlags, overcurrentFlagsEqual);
|
const protection = useTelemetrySelector(roverId, selectOvercurrentProtection);
|
||||||
const config = useMemo(
|
|
||||||
() => ({ ...DEFAULT_OVERCURRENT_LIMITS, ...(options.config || {}) }),
|
|
||||||
[options.config],
|
|
||||||
);
|
|
||||||
const [caps, setCaps] = useState(() => createInitialCaps());
|
|
||||||
const lastTickRef = useRef(0);
|
|
||||||
const flagsRef = useRef(overcurrentFlags);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
flagsRef.current = overcurrentFlags || {};
|
|
||||||
}, [overcurrentFlags]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setCaps(createInitialCaps());
|
|
||||||
lastTickRef.current = 0;
|
|
||||||
}, [roverId]);
|
|
||||||
|
|
||||||
const hasAnyOvercurrent = useMemo(
|
|
||||||
() => OVERCURRENT_GROUPS.some((group) => group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]))),
|
|
||||||
[overcurrentFlags],
|
|
||||||
);
|
|
||||||
const needsRecoveryTick = useMemo(
|
|
||||||
() =>
|
|
||||||
Object.values(caps || {}).some((entry) => {
|
|
||||||
/*
|
|
||||||
Recovery intentionally completes at a tiny tolerance below exactly 1.
|
|
||||||
The limiter advances in timed floating-point steps, so requiring an
|
|
||||||
exact 1 can strand the UI at a visually empty bar while the limiter is
|
|
||||||
still technically active at a value like 0.9992.
|
|
||||||
*/
|
|
||||||
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
|
|
||||||
return cap < RECOVERED_CAP_THRESHOLD;
|
|
||||||
}),
|
|
||||||
[caps],
|
|
||||||
);
|
|
||||||
const shouldTick = Boolean(roverId) && (hasAnyOvercurrent || needsRecoveryTick);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldTick) return undefined;
|
|
||||||
lastTickRef.current = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
||||||
const interval = setInterval(() => {
|
|
||||||
const now = typeof performance !== 'undefined' ? performance.now() : Date.now();
|
|
||||||
const deltaMs = Math.max(0, now - lastTickRef.current);
|
|
||||||
lastTickRef.current = now;
|
|
||||||
const deltaSec = Math.min(0.25, deltaMs / 1000);
|
|
||||||
if (deltaSec <= 0) return;
|
|
||||||
setCaps((prev) => {
|
|
||||||
let changed = false;
|
|
||||||
const next = {};
|
|
||||||
const downRate = Number.isFinite(config.downRatePerSec) ? Math.max(0, config.downRatePerSec) : 0;
|
|
||||||
const upRate = Number.isFinite(config.upRatePerSec) ? Math.max(0, config.upRatePerSec) : 0;
|
|
||||||
const releaseDelay = Number.isFinite(config.releaseDelaySec) ? Math.max(0, config.releaseDelaySec) : 0;
|
|
||||||
OVERCURRENT_GROUPS.forEach((group) => {
|
|
||||||
const prevEntry = prev[group.key] || { cap: 1, clearSec: 0 };
|
|
||||||
const prevCap = Number.isFinite(prevEntry.cap) ? prevEntry.cap : 1;
|
|
||||||
const prevClear = Number.isFinite(prevEntry.clearSec) ? prevEntry.clearSec : 0;
|
|
||||||
const over = group.motors.some((motor) => Boolean(flagsRef.current?.[motor]));
|
|
||||||
const nextClear = over ? 0 : prevClear + deltaSec;
|
|
||||||
const allowRecover = !over && nextClear >= releaseDelay;
|
|
||||||
const rawNextCap = clampUnit(
|
|
||||||
over ? prevCap - downRate * deltaSec : allowRecover ? prevCap + upRate * deltaSec : prevCap,
|
|
||||||
);
|
|
||||||
/*
|
|
||||||
Once recovery reaches the shared completion threshold, snap the cap
|
|
||||||
to exactly full strength. This keeps the tick loop, command scaling,
|
|
||||||
and HUD visibility from disagreeing over a harmless fractional tail.
|
|
||||||
*/
|
|
||||||
const nextCap = !over && rawNextCap >= RECOVERED_CAP_THRESHOLD ? 1 : rawNextCap;
|
|
||||||
if (Math.abs(nextCap - prevCap) > 0.0001 || Math.abs(nextClear - prevClear) > 0.0001) {
|
|
||||||
changed = true;
|
|
||||||
}
|
|
||||||
next[group.key] = { cap: nextCap, clearSec: nextClear };
|
|
||||||
});
|
|
||||||
return changed ? next : prev;
|
|
||||||
});
|
|
||||||
}, 100);
|
|
||||||
return () => clearInterval(interval);
|
|
||||||
}, [config.downRatePerSec, config.releaseDelaySec, config.upRatePerSec, roverId, shouldTick]);
|
|
||||||
|
|
||||||
const scales = useMemo(() => {
|
|
||||||
const perGroup = OVERCURRENT_GROUPS.reduce((acc, group) => {
|
|
||||||
const entry = caps?.[group.key];
|
|
||||||
const cap = Number.isFinite(entry?.cap) ? entry.cap : 1;
|
|
||||||
acc[group.key] = clampUnit(cap);
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
return {
|
|
||||||
perGroup,
|
|
||||||
drive: {
|
|
||||||
left: perGroup.drive ?? 1,
|
|
||||||
right: perGroup.drive ?? 1,
|
|
||||||
},
|
|
||||||
aux: {
|
|
||||||
main: perGroup.aux ?? 1,
|
|
||||||
side: perGroup.aux ?? 1,
|
|
||||||
vacuum: 1,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}, [caps]);
|
|
||||||
|
|
||||||
const overcurrent = useMemo(() => {
|
|
||||||
const motors = {};
|
|
||||||
OVERCURRENT_GROUPS.forEach((group) => {
|
|
||||||
group.motors.forEach((motor) => {
|
|
||||||
motors[motor] = Boolean(overcurrentFlags?.[motor]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const groups = OVERCURRENT_GROUPS.reduce((acc, group) => {
|
|
||||||
acc[group.key] = group.motors.some((motor) => Boolean(overcurrentFlags?.[motor]));
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
return { motors, groups };
|
|
||||||
}, [overcurrentFlags]);
|
|
||||||
|
|
||||||
const adminImmune = role === 'admin' || role === 'lockdown';
|
const adminImmune = role === 'admin' || role === 'lockdown';
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(() => {
|
||||||
() => ({
|
const motors = protection?.motors || {};
|
||||||
caps,
|
const driveCap = Number.isFinite(protection?.drive?.cap) ? protection.drive.cap : 1;
|
||||||
overcurrent,
|
const mainCap = Number.isFinite(motors?.mainBrush?.cap) ? motors.mainBrush.cap : 1;
|
||||||
scales,
|
const sideCap = Number.isFinite(motors?.sideBrush?.cap) ? motors.sideBrush.cap : 1;
|
||||||
/*
|
const auxCap = Math.min(mainCap, sideCap);
|
||||||
HUD and resend behavior should only remain active while the limiter has
|
const motorFlags = OVERCURRENT_GROUPS.reduce((result, group) => {
|
||||||
meaningful scale left to recover. Using the same threshold as the tick
|
group.motors.forEach((motor) => {
|
||||||
loop prevents an empty overcurrent overlay from staying mounted after
|
result[motor] = Boolean(motors?.[motor]?.overcurrent);
|
||||||
recovery has already stopped.
|
});
|
||||||
*/
|
return result;
|
||||||
isActive:
|
}, {});
|
||||||
(scales?.drive?.left ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
const groupFlags = OVERCURRENT_GROUPS.reduce((result, group) => {
|
||||||
(scales?.drive?.right ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
result[group.key] = group.motors.some((motor) => motorFlags[motor]);
|
||||||
(scales?.aux?.main ?? 1) < RECOVERED_CAP_THRESHOLD ||
|
return result;
|
||||||
(scales?.aux?.side ?? 1) < RECOVERED_CAP_THRESHOLD,
|
}, {});
|
||||||
config,
|
|
||||||
|
/*
|
||||||
|
The compatibility-shaped fields keep existing control-context consumers
|
||||||
|
simple while every value now comes from the same server snapshot. There
|
||||||
|
is deliberately no local recovery loop: a stale or disconnected browser
|
||||||
|
must never invent a safer state than the server actually calculated.
|
||||||
|
*/
|
||||||
|
return {
|
||||||
|
...protection,
|
||||||
|
caps: {
|
||||||
|
drive: { cap: driveCap },
|
||||||
|
aux: { cap: auxCap },
|
||||||
|
},
|
||||||
|
scales: {
|
||||||
|
perGroup: { drive: driveCap, aux: auxCap },
|
||||||
|
drive: { left: driveCap, right: driveCap },
|
||||||
|
aux: { main: mainCap, side: sideCap, vacuum: 1 },
|
||||||
|
},
|
||||||
|
overcurrent: { motors: motorFlags, groups: groupFlags },
|
||||||
|
isActive: protection?.status === 'limiting'
|
||||||
|
|| protection?.status === 'overcurrent'
|
||||||
|
|| protection?.status === 'stopped'
|
||||||
|
|| protection?.status === 'recovering',
|
||||||
adminImmune,
|
adminImmune,
|
||||||
}),
|
};
|
||||||
[caps, overcurrent, scales, config, adminImmune],
|
}, [adminImmune, protection]);
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyDriveOvercurrentScale(speeds = {}, scales, adminImmune = false) {
|
|
||||||
if (adminImmune || !scales?.drive) return speeds;
|
|
||||||
const leftScale = typeof scales.drive.left === 'number' ? scales.drive.left : 1;
|
|
||||||
const rightScale = typeof scales.drive.right === 'number' ? scales.drive.right : 1;
|
|
||||||
if (leftScale >= 0.999 && rightScale >= 0.999) return speeds;
|
|
||||||
return {
|
|
||||||
left: Math.round((speeds.left ?? 0) * leftScale),
|
|
||||||
right: Math.round((speeds.right ?? 0) * rightScale),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function applyAuxOvercurrentScale(values = {}, scales, adminImmune = false) {
|
|
||||||
if (adminImmune || !scales?.aux) return values;
|
|
||||||
const mainScale = typeof scales.aux.main === 'number' ? scales.aux.main : 1;
|
|
||||||
const sideScale = typeof scales.aux.side === 'number' ? scales.aux.side : 1;
|
|
||||||
const vacuumScale = typeof scales.aux.vacuum === 'number' ? scales.aux.vacuum : 1;
|
|
||||||
if (mainScale >= 0.999 && sideScale >= 0.999 && vacuumScale >= 0.999) return values;
|
|
||||||
return {
|
|
||||||
main: Math.round((values.main ?? 0) * mainScale),
|
|
||||||
side: Math.round((values.side ?? 0) * sideScale),
|
|
||||||
vacuum: Math.round((values.vacuum ?? 0) * vacuumScale),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,11 @@ const PTZ_SPEEDS = {
|
|||||||
medium: 0.5,
|
medium: 0.5,
|
||||||
fast: 1,
|
fast: 1,
|
||||||
};
|
};
|
||||||
const ZOOM_PULSE_MS = 220;
|
// The TrackMix advertises a minimum ONVIF movement timeout of one second. A
|
||||||
|
// quarter-second browser heartbeat gives the server several opportunities to
|
||||||
|
// renew a genuinely held intent while still letting its watchdog distinguish a
|
||||||
|
// live control from a browser that disappeared without delivering a release.
|
||||||
|
const MOTION_HEARTBEAT_MS = 250;
|
||||||
|
|
||||||
function clampUnit(value) {
|
function clampUnit(value) {
|
||||||
const number = Number(value) || 0;
|
const number = Number(value) || 0;
|
||||||
@@ -102,9 +106,10 @@ export function usePtzControlAdapter() {
|
|||||||
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
const ptz = useSessionSelector((state) => state.session?.ptzCamera || null);
|
||||||
const isActive = Boolean(ptz?.isOperator);
|
const isActive = Boolean(ptz?.isOperator);
|
||||||
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
|
const lastMotionSignatureRef = useRef(payloadSignature(PTZ_STOP));
|
||||||
|
const desiredMotionRef = useRef(PTZ_STOP);
|
||||||
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
|
const panTiltIntentRef = useRef({ pan: 0, tilt: 0 });
|
||||||
const zoomIntentRef = useRef(0);
|
const zoomIntentRef = useRef(0);
|
||||||
const zoomStopTimerRef = useRef(null);
|
const heartbeatTimerRef = useRef(null);
|
||||||
|
|
||||||
const emitPtz = useCallback(
|
const emitPtz = useCallback(
|
||||||
(eventName, payload = {}) => {
|
(eventName, payload = {}) => {
|
||||||
@@ -114,22 +119,13 @@ export function usePtzControlAdapter() {
|
|||||||
[isActive, socket],
|
[isActive, socket],
|
||||||
);
|
);
|
||||||
|
|
||||||
const stopMotion = useCallback(() => {
|
const clearHeartbeat = useCallback(() => {
|
||||||
if (zoomStopTimerRef.current) {
|
if (!heartbeatTimerRef.current) return;
|
||||||
clearTimeout(zoomStopTimerRef.current);
|
clearInterval(heartbeatTimerRef.current);
|
||||||
zoomStopTimerRef.current = null;
|
heartbeatTimerRef.current = null;
|
||||||
}
|
}, []);
|
||||||
// A true global stop is used for blur, route close, and control release, so
|
|
||||||
// it deliberately clears every independently tracked PTZ axis intent.
|
|
||||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
|
||||||
zoomIntentRef.current = 0;
|
|
||||||
const stopSignature = payloadSignature(PTZ_STOP);
|
|
||||||
if (lastMotionSignatureRef.current === stopSignature) return;
|
|
||||||
lastMotionSignatureRef.current = stopSignature;
|
|
||||||
emitPtz('ptzCamera:stop');
|
|
||||||
}, [emitPtz]);
|
|
||||||
|
|
||||||
const sendMotion = useCallback(
|
const publishMotion = useCallback(
|
||||||
(payload, options = {}) => {
|
(payload, options = {}) => {
|
||||||
if (!isActive) return false;
|
if (!isActive) return false;
|
||||||
const nextPayload = {
|
const nextPayload = {
|
||||||
@@ -138,16 +134,49 @@ export function usePtzControlAdapter() {
|
|||||||
zoom: clampUnit(payload?.zoom),
|
zoom: clampUnit(payload?.zoom),
|
||||||
};
|
};
|
||||||
const nextSignature = payloadSignature(nextPayload);
|
const nextSignature = payloadSignature(nextPayload);
|
||||||
|
desiredMotionRef.current = nextPayload;
|
||||||
|
|
||||||
|
if (isIdlePayload(nextPayload)) {
|
||||||
|
clearHeartbeat();
|
||||||
|
} else if (!heartbeatTimerRef.current) {
|
||||||
|
/*
|
||||||
|
Movement is renewed from the complete desired vector, not from the
|
||||||
|
individual input event that happened to start it. This is what makes
|
||||||
|
pan/tilt and zoom independent: every heartbeat describes all axes as
|
||||||
|
they should be now, and no delayed zoom pulse can resurrect an older
|
||||||
|
direction after a release.
|
||||||
|
*/
|
||||||
|
heartbeatTimerRef.current = setInterval(() => {
|
||||||
|
const current = desiredMotionRef.current;
|
||||||
|
if (isIdlePayload(current)) {
|
||||||
|
clearHeartbeat();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitPtz('ptzCamera:motion', current);
|
||||||
|
}, MOTION_HEARTBEAT_MS);
|
||||||
|
}
|
||||||
|
|
||||||
if (!options.force && lastMotionSignatureRef.current === nextSignature) return true;
|
if (!options.force && lastMotionSignatureRef.current === nextSignature) return true;
|
||||||
lastMotionSignatureRef.current = nextSignature;
|
lastMotionSignatureRef.current = nextSignature;
|
||||||
if (isIdlePayload(nextPayload)) {
|
// Zero is a first-class desired state. The server translates the complete
|
||||||
emitPtz('ptzCamera:stop');
|
// idle vector into ONVIF Stop inside the same serialized command stream as
|
||||||
} else {
|
// movement, which prevents separate move/stop handlers from racing.
|
||||||
emitPtz('ptzCamera:move', nextPayload);
|
emitPtz('ptzCamera:motion', nextPayload);
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[emitPtz, isActive],
|
[clearHeartbeat, emitPtz, isActive],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopMotion = useCallback(
|
||||||
|
(options = {}) => {
|
||||||
|
// A global stop deliberately clears every axis. Safety/lifecycle callers
|
||||||
|
// use force so the server receives a fresh stop even when the browser's
|
||||||
|
// local signature already says it is idle after a dropped connection.
|
||||||
|
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||||
|
zoomIntentRef.current = 0;
|
||||||
|
return publishMotion(PTZ_STOP, { force: Boolean(options.force) });
|
||||||
|
},
|
||||||
|
[publishMotion],
|
||||||
);
|
);
|
||||||
|
|
||||||
const applyDriveVector = useCallback(
|
const applyDriveVector = useCallback(
|
||||||
@@ -163,60 +192,36 @@ export function usePtzControlAdapter() {
|
|||||||
Preserve the current zoom intent when a direction update arrives so a
|
Preserve the current zoom intent when a direction update arrives so a
|
||||||
keyboard or touch event on one axis cannot erase another held axis.
|
keyboard or touch event on one axis cannot erase another held axis.
|
||||||
*/
|
*/
|
||||||
sendMotion({
|
publishMotion({
|
||||||
...panTiltIntentRef.current,
|
...panTiltIntentRef.current,
|
||||||
zoom: zoomIntentRef.current,
|
zoom: zoomIntentRef.current,
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[isActive, sendMotion],
|
[isActive, publishMotion],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pulseZoom = useCallback(
|
const setZoomIntent = useCallback(
|
||||||
(direction) => {
|
(direction) => {
|
||||||
if (!isActive) return false;
|
if (!isActive) return false;
|
||||||
const sign = axisSign(direction);
|
const numeric = clampUnit(direction);
|
||||||
if (!sign) {
|
const sign = axisSign(numeric);
|
||||||
if (zoomStopTimerRef.current) {
|
|
||||||
clearTimeout(zoomStopTimerRef.current);
|
|
||||||
zoomStopTimerRef.current = null;
|
|
||||||
}
|
|
||||||
zoomIntentRef.current = 0;
|
|
||||||
/*
|
|
||||||
Releasing zoom must not call the global PTZ stop. Re-emit the retained
|
|
||||||
pan/tilt intent with zoom cleared so a held direction continues
|
|
||||||
immediately instead of waiting for another directional key event.
|
|
||||||
*/
|
|
||||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
/*
|
/*
|
||||||
Zoom is different from pan/tilt because it is driven by repeated nudge
|
PTZ zoom is a held velocity, not a rover servo nudge. Convert the input
|
||||||
events from existing camera controls. Force each pulse through even when
|
magnitude to the same precision/normal tiers used for pan and tilt, then
|
||||||
the payload is identical, otherwise holding "camera up" only sends the
|
retain it until the input surface explicitly publishes zero. The shared
|
||||||
first zoom command and every later nudge is de-duped away.
|
heartbeat renews that state; there are no per-button repeat or delayed
|
||||||
|
stop timers left to race with pointer/key release.
|
||||||
*/
|
*/
|
||||||
zoomIntentRef.current = sign * PTZ_SPEEDS.medium;
|
const speed = !sign ? 0 : Math.abs(numeric) <= 0.45 ? PTZ_SPEEDS.slow : PTZ_SPEEDS.medium;
|
||||||
sendMotion({
|
zoomIntentRef.current = sign * speed;
|
||||||
|
publishMotion({
|
||||||
...panTiltIntentRef.current,
|
...panTiltIntentRef.current,
|
||||||
zoom: zoomIntentRef.current,
|
zoom: zoomIntentRef.current,
|
||||||
}, { force: true });
|
});
|
||||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
|
||||||
/*
|
|
||||||
Existing rover camera controls are nudge/slider based, not hold-based.
|
|
||||||
Treat each nudge as a short PTZ zoom pulse, then stop from the adapter
|
|
||||||
so delayed stop behavior is owned by the camera layer only.
|
|
||||||
*/
|
|
||||||
zoomStopTimerRef.current = setTimeout(() => {
|
|
||||||
zoomStopTimerRef.current = null;
|
|
||||||
zoomIntentRef.current = 0;
|
|
||||||
// A zoom pulse ending restores, rather than stops, any direction that
|
|
||||||
// is still held in the independent pan/tilt intent.
|
|
||||||
sendMotion({ ...panTiltIntentRef.current, zoom: 0 }, { force: true });
|
|
||||||
}, ZOOM_PULSE_MS);
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
[isActive, sendMotion],
|
[isActive, publishMotion],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setSpotlight = useCallback(
|
const setSpotlight = useCallback(
|
||||||
@@ -253,20 +258,42 @@ export function usePtzControlAdapter() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isActive) return undefined;
|
if (isActive) return undefined;
|
||||||
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
|
lastMotionSignatureRef.current = payloadSignature(PTZ_STOP);
|
||||||
|
desiredMotionRef.current = PTZ_STOP;
|
||||||
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
panTiltIntentRef.current = { pan: 0, tilt: 0 };
|
||||||
zoomIntentRef.current = 0;
|
zoomIntentRef.current = 0;
|
||||||
if (zoomStopTimerRef.current) {
|
clearHeartbeat();
|
||||||
clearTimeout(zoomStopTimerRef.current);
|
|
||||||
zoomStopTimerRef.current = null;
|
|
||||||
}
|
|
||||||
return undefined;
|
return undefined;
|
||||||
}, [isActive]);
|
}, [clearHeartbeat, isActive]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isActive) return undefined;
|
||||||
|
|
||||||
|
const forceSafetyStop = () => stopMotion({ force: true });
|
||||||
|
const handleVisibility = () => {
|
||||||
|
if (document.visibilityState === 'hidden') forceSafetyStop();
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Input components handle ordinary pointer/key releases, but the adapter is
|
||||||
|
the only layer guaranteed to see every PTZ control surface. Centralizing
|
||||||
|
browser lifecycle stops here covers touch, keyboard, and gamepad equally
|
||||||
|
when a tab hides, a window blurs, or mobile navigation fires pagehide.
|
||||||
|
*/
|
||||||
|
window.addEventListener('blur', forceSafetyStop);
|
||||||
|
window.addEventListener('pagehide', forceSafetyStop);
|
||||||
|
document.addEventListener('visibilitychange', handleVisibility);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('blur', forceSafetyStop);
|
||||||
|
window.removeEventListener('pagehide', forceSafetyStop);
|
||||||
|
document.removeEventListener('visibilitychange', handleVisibility);
|
||||||
|
};
|
||||||
|
}, [isActive, stopMotion]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => () => {
|
() => () => {
|
||||||
if (zoomStopTimerRef.current) clearTimeout(zoomStopTimerRef.current);
|
clearHeartbeat();
|
||||||
},
|
},
|
||||||
[],
|
[clearHeartbeat],
|
||||||
);
|
);
|
||||||
|
|
||||||
return useMemo(
|
return useMemo(
|
||||||
@@ -274,11 +301,11 @@ export function usePtzControlAdapter() {
|
|||||||
isActive,
|
isActive,
|
||||||
state: ptz,
|
state: ptz,
|
||||||
applyDriveVector,
|
applyDriveVector,
|
||||||
pulseZoom,
|
setZoomIntent,
|
||||||
setSpotlight,
|
setSpotlight,
|
||||||
setIr,
|
setIr,
|
||||||
stopMotion,
|
stopMotion,
|
||||||
}),
|
}),
|
||||||
[applyDriveVector, isActive, ptz, pulseZoom, setIr, setSpotlight, stopMotion],
|
[applyDriveVector, isActive, ptz, setIr, setSpotlight, setZoomIntent, stopMotion],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ import CardFrame from '../components/CardFrame/index.jsx';
|
|||||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||||
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
|
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
|
||||||
import { pageBackgroundClass } from '../themeFlags.js';
|
import { useSettingsNamespace } from '../settings/index.js';
|
||||||
|
import { DEFAULT_PAGE_THEME_KEY, getPageThemeClass } from '../themes/index.js';
|
||||||
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
|
import IdentityDatabasePanel from './IdentityDatabasePanel.jsx';
|
||||||
|
|
||||||
function isLockdownAdminRole(role) {
|
function isLockdownAdminRole(role) {
|
||||||
@@ -17,6 +18,12 @@ export default function DatabaseAdminApp() {
|
|||||||
useUserIdentitySync({ identitySurface: 'passive' });
|
useUserIdentitySync({ identitySurface: 'passive' });
|
||||||
const role = useSessionSelector((state) => state.session?.role || null);
|
const role = useSessionSelector((state) => state.session?.role || null);
|
||||||
const connected = useSessionSelector((state) => state.connected);
|
const connected = useSessionSelector((state) => state.connected);
|
||||||
|
const { value: pageSettings } = useSettingsNamespace('page', {
|
||||||
|
backgroundTheme: DEFAULT_PAGE_THEME_KEY,
|
||||||
|
});
|
||||||
|
// Database cards use the same narrow seams as the driver page, so honoring the shared browser
|
||||||
|
// preference here keeps the existing route-level background behavior while making it dynamic.
|
||||||
|
const pageBackgroundClass = getPageThemeClass(pageSettings?.backgroundTheme);
|
||||||
|
|
||||||
const isLockdownAdmin = isLockdownAdminRole(role);
|
const isLockdownAdmin = isLockdownAdminRole(role);
|
||||||
const isLoggedInAdmin = role === 'admin' || isLockdownAdmin;
|
const isLoggedInAdmin = role === 'admin' || isLockdownAdmin;
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
// Chat Message History Navigation
|
||||||
|
// Purpose: Gives each chat input Bash-style traversal over the shared persisted send history.
|
||||||
|
// Scope: Owns only draft/navigation state; it does not register global keys or interact with rover controls.
|
||||||
|
import { useCallback, useRef } from 'react';
|
||||||
|
import { useChatHistory } from '../context/ChatContext.jsx';
|
||||||
|
|
||||||
|
export default function useChatMessageHistoryNavigation() {
|
||||||
|
const { messageHistory } = useChatHistory();
|
||||||
|
const historyIndexRef = useRef(null);
|
||||||
|
const preservedDraftRef = useRef('');
|
||||||
|
|
||||||
|
const resetHistoryNavigation = useCallback(() => {
|
||||||
|
// Manual edits and successful sends begin a new navigation session. The
|
||||||
|
// current input value remains owned by the composer and is not changed here.
|
||||||
|
historyIndexRef.current = null;
|
||||||
|
preservedDraftRef.current = '';
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const navigateHistory = useCallback(
|
||||||
|
(direction, currentDraft) => {
|
||||||
|
if (!messageHistory.length) return null;
|
||||||
|
|
||||||
|
if (direction === 'previous') {
|
||||||
|
if (historyIndexRef.current === null) {
|
||||||
|
// Save the in-progress draft exactly once so ArrowDown can restore it
|
||||||
|
// after the user reaches the newest edge of history, like a shell.
|
||||||
|
preservedDraftRef.current = currentDraft;
|
||||||
|
historyIndexRef.current = messageHistory.length - 1;
|
||||||
|
} else {
|
||||||
|
historyIndexRef.current = Math.max(0, historyIndexRef.current - 1);
|
||||||
|
}
|
||||||
|
return messageHistory[historyIndexRef.current];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (direction === 'next' && historyIndexRef.current !== null) {
|
||||||
|
if (historyIndexRef.current < messageHistory.length - 1) {
|
||||||
|
historyIndexRef.current += 1;
|
||||||
|
return messageHistory[historyIndexRef.current];
|
||||||
|
}
|
||||||
|
|
||||||
|
const preservedDraft = preservedDraftRef.current;
|
||||||
|
resetHistoryNavigation();
|
||||||
|
return preservedDraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
},
|
||||||
|
[messageHistory, resetHistoryNavigation],
|
||||||
|
);
|
||||||
|
|
||||||
|
return { navigateHistory, resetHistoryNavigation };
|
||||||
|
}
|
||||||
+32
-19
@@ -59,27 +59,40 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@layer components {
|
||||||
.pride-page-bg {
|
.inter-instance-overlay-frame {
|
||||||
background-color: #050505;
|
/* The unscaled frame always stays inside the viewport on phones and on
|
||||||
background-image:
|
desktop browsers that do not apply the larger presentation below. */
|
||||||
linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0)),
|
max-width: calc(100vw - 0.5rem);
|
||||||
repeating-linear-gradient(
|
|
||||||
45deg,
|
|
||||||
#5bcefa 0 1.818%,
|
|
||||||
#f5a9b8 1.818% 3.636%,
|
|
||||||
#ffffff 3.636% 5.455%,
|
|
||||||
#f5a9b8 5.455% 7.273%,
|
|
||||||
#5bcefa 7.273% 9.091%,
|
|
||||||
#e40303 9.091% 10.909%,
|
|
||||||
#ff8c00 10.909% 12.727%,
|
|
||||||
#ffed00 12.727% 14.545%,
|
|
||||||
#00a651 14.545% 16.364%,
|
|
||||||
#0066ff 16.364% 18.182%,
|
|
||||||
#9b2fae 18.182% 20%
|
|
||||||
);
|
|
||||||
background-attachment: fixed;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inter-instance-overlay-body {
|
||||||
|
max-height: 82vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1024px) {
|
||||||
|
.inter-instance-overlay-scale {
|
||||||
|
/*
|
||||||
|
`zoom` enlarges the complete interface—including typography, controls,
|
||||||
|
spacing, and hit targets—while participating in layout. A transform
|
||||||
|
would only enlarge the paint result and could overlap or clip sibling
|
||||||
|
content because the browser would still reserve the original size.
|
||||||
|
*/
|
||||||
|
zoom: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inter-instance-overlay-frame {
|
||||||
|
/* Reserve the inverse width before the 1.5x zoom so the final rendered
|
||||||
|
frame still fits inside the physical desktop viewport. */
|
||||||
|
max-width: calc((100vw - 0.5rem) / 1.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inter-instance-overlay-body {
|
||||||
|
/* 54.6667vh becomes approximately 82vh after the 1.5x desktop zoom. */
|
||||||
|
max-height: 54.6667vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.panel {
|
.panel {
|
||||||
@apply bg-black text-white p-0 rounded-md;
|
@apply bg-black text-white p-0 rounded-md;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,9 @@ import { StrictMode } from 'react'
|
|||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
// Theme artwork is a separate style concern from global component utilities. Loading its dedicated
|
||||||
|
// entrypoint here keeps every route consistent without returning theme definitions to index.css.
|
||||||
|
import './themes/styles/index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
import { SocketProvider } from './context/SocketContext.jsx'
|
import { SocketProvider } from './context/SocketContext.jsx'
|
||||||
import { SessionProvider } from './context/SessionContext.jsx'
|
import { SessionProvider } from './context/SessionContext.jsx'
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
export const PRIDE_THEME_ENABLED = true;
|
|
||||||
|
|
||||||
export const pageBackgroundClass = PRIDE_THEME_ENABLED ? 'pride-page-bg' : 'bg-black';
|
|
||||||
// export const themeGapClass = PRIDE_THEME_ENABLED ? 'gap-1' : 'gap-0.5';
|
|
||||||
// export const themeStackClass = PRIDE_THEME_ENABLED ? 'space-y-1' : 'space-y-0.5';
|
|
||||||
|
|
||||||
// permanent smaller gaps i think is better probably
|
|
||||||
export const themeGapClass = 'gap-0.5';
|
|
||||||
export const themeStackClass = 'space-y-0.5';
|
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Page Theme Catalog
|
||||||
|
// Purpose: Defines persisted theme identities and their presentation order.
|
||||||
|
// Scope: Shared by the settings picker and every route that resolves a saved page theme.
|
||||||
|
|
||||||
|
export const DEFAULT_PAGE_THEME_KEY = 'progress-pride';
|
||||||
|
|
||||||
|
export const PAGE_THEME_OPTIONS = [
|
||||||
|
// Stable keys are stored in the browser settings cookie. Keeping presentation labels separate
|
||||||
|
// lets the UI wording improve later without invalidating anybody's saved preference.
|
||||||
|
{ key: 'progress-pride', label: 'Progress pride', className: 'page-theme-progress-pride' },
|
||||||
|
// Keep the high-contrast caution-stripe option near the default so it is reachable with one
|
||||||
|
// Next click as well as directly through the dropdown.
|
||||||
|
{ key: 'hazard-stripes', label: 'Hazard stripes', className: 'page-theme-hazard-stripes' },
|
||||||
|
{ key: 'pride-mix', label: 'Pride mix', className: 'page-theme-pride-mix' },
|
||||||
|
{ key: 'rainbow', label: 'Rainbow', className: 'page-theme-rainbow' },
|
||||||
|
{ key: 'transgender', label: 'Transgender', className: 'page-theme-transgender' },
|
||||||
|
{ key: 'bisexual', label: 'Bisexual', className: 'page-theme-bisexual' },
|
||||||
|
{ key: 'lesbian', label: 'Lesbian', className: 'page-theme-lesbian' },
|
||||||
|
{ key: 'nonbinary', label: 'Nonbinary', className: 'page-theme-nonbinary' },
|
||||||
|
{ key: 'pansexual', label: 'Pansexual', className: 'page-theme-pansexual' },
|
||||||
|
{ key: 'asexual', label: 'Asexual', className: 'page-theme-asexual' },
|
||||||
|
{ key: 'aurora', label: 'Aurora', className: 'page-theme-aurora' },
|
||||||
|
{ key: 'synthwave-grid', label: 'Synthwave grid', className: 'page-theme-synthwave-grid' },
|
||||||
|
{ key: 'neon-checker', label: 'Neon checker', className: 'page-theme-neon-checker' },
|
||||||
|
{ key: 'ocean-current', label: 'Ocean current', className: 'page-theme-ocean-current' },
|
||||||
|
{ key: 'ember-lattice', label: 'Ember lattice', className: 'page-theme-ember-lattice' },
|
||||||
|
{ key: 'starfield', label: 'Starfield', className: 'page-theme-starfield' },
|
||||||
|
{ key: 'vaporwave-sunset', label: 'Vaporwave sunset', className: 'page-theme-vaporwave-sunset' },
|
||||||
|
{ key: 'electric-circuit', label: 'Electric circuit', className: 'page-theme-electric-circuit' },
|
||||||
|
{ key: 'lava-flow', label: 'Lava flow', className: 'page-theme-lava-flow' },
|
||||||
|
{ key: 'deep-space-nebula', label: 'Deep-space nebula', className: 'page-theme-deep-space-nebula' },
|
||||||
|
{ key: 'holographic-waves', label: 'Holographic waves', className: 'page-theme-holographic-waves' },
|
||||||
|
{ key: 'mint-mosaic', label: 'Mint mosaic', className: 'page-theme-mint-mosaic' },
|
||||||
|
{ key: 'candy-swirl', label: 'Candy swirl', className: 'page-theme-candy-swirl' },
|
||||||
|
{ key: 'black', label: 'Black', className: 'page-theme-black' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function normalizePageThemeKey(value) {
|
||||||
|
// Cookie contents can outlive catalog changes or be edited by hand. Always resolve them to a
|
||||||
|
// known entry so the page, preview, and dropdown cannot disagree about the active selection.
|
||||||
|
return PAGE_THEME_OPTIONS.some((theme) => theme.key === value)
|
||||||
|
? value
|
||||||
|
: DEFAULT_PAGE_THEME_KEY;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageTheme(value) {
|
||||||
|
const normalizedKey = normalizePageThemeKey(value);
|
||||||
|
return PAGE_THEME_OPTIONS.find((theme) => theme.key === normalizedKey) || PAGE_THEME_OPTIONS[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPageThemeClass(value) {
|
||||||
|
// The shared base class owns page-level behavior such as fixed positioning. The modifier only
|
||||||
|
// supplies artwork, which also lets the settings demonstration reuse the exact same theme.
|
||||||
|
return `page-theme ${getPageTheme(value).className}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
// Theme Public API
|
||||||
|
// Purpose: Gives UI consumers one stable import path for catalog resolution and layout constants.
|
||||||
|
// Scope: Re-exports theme behavior while CSS remains independently loaded by the app entrypoint.
|
||||||
|
|
||||||
|
export {
|
||||||
|
DEFAULT_PAGE_THEME_KEY,
|
||||||
|
PAGE_THEME_OPTIONS,
|
||||||
|
getPageTheme,
|
||||||
|
getPageThemeClass,
|
||||||
|
normalizePageThemeKey,
|
||||||
|
} from './catalog.js';
|
||||||
|
export { themeGapClass, themeStackClass } from './layout.js';
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// Theme-Aware Layout Constants
|
||||||
|
// Purpose: Keeps the narrow gap geometry used to reveal page themes consistent across layouts.
|
||||||
|
// Scope: Layout-only values; selecting a theme never changes panel dimensions or input placement.
|
||||||
|
|
||||||
|
export const themeGapClass = 'gap-0.5';
|
||||||
|
export const themeStackClass = 'space-y-0.5';
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
/* Page Theme Surfaces
|
||||||
|
* Purpose: Defines behavior shared by every page theme and by the settings demonstration.
|
||||||
|
* Scope: Canvas positioning and the explicit no-artwork fallback; individual artwork lives elsewhere.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* These selectors intentionally remain ordinary CSS. This stylesheet is processed separately from
|
||||||
|
* index.css, so placing them in a Tailwind layer would require duplicating Tailwind's directives. */
|
||||||
|
.page-theme {
|
||||||
|
/* A fixed canvas makes separated card gaps look like windows onto one continuous design.
|
||||||
|
Individual theme classes below supply only their artwork and fallback color. */
|
||||||
|
background-color: #050505;
|
||||||
|
background-attachment: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-preview {
|
||||||
|
/* Preview artwork belongs to the demonstration box, not the browser viewport. Overriding the
|
||||||
|
page behavior here keeps the swatch representative while it moves inside a scroll panel. */
|
||||||
|
background-attachment: scroll;
|
||||||
|
background-position: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-black {
|
||||||
|
/* An explicit quiet option belongs in the catalog rather than being a special-case toggle. */
|
||||||
|
background-color: #000000;
|
||||||
|
background-image: none;
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
/* Page Theme Stylesheet Entry
|
||||||
|
* Purpose: Loads theme surfaces and artwork as one explicit bundle from main.jsx.
|
||||||
|
* Scope: Import order keeps shared behavior first, followed by the two artwork families.
|
||||||
|
*/
|
||||||
|
|
||||||
|
@import './base.css';
|
||||||
|
@import './pride.css';
|
||||||
|
@import './patterns.css';
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
/* Pattern Page Themes
|
||||||
|
* Purpose: Contains decorative non-pride artwork optimized for narrow horizontal and vertical gaps.
|
||||||
|
* Scope: Static gradients and repeating patterns only; theme identity and ordering live in catalog.js.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Ordinary CSS keeps the artwork independent of Tailwind's global component layer. main.jsx loads
|
||||||
|
* this theme bundle after index.css, giving the theme modifiers their original cascade position. */
|
||||||
|
.page-theme-aurora {
|
||||||
|
/* Overlapping oversized glows create slow color movement across the page without animation,
|
||||||
|
avoiding distracting motion and continuous painting behind live video. */
|
||||||
|
background-color: #03120f;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle at 18% 24%, rgba(45, 212, 191, 0.95), transparent 34%),
|
||||||
|
radial-gradient(circle at 76% 18%, rgba(168, 85, 247, 0.9), transparent 38%),
|
||||||
|
radial-gradient(circle at 56% 82%, rgba(34, 197, 94, 0.88), transparent 42%),
|
||||||
|
linear-gradient(125deg, #020617, #0f172a 45%, #082f49);
|
||||||
|
background-size: 34rem 28rem, 38rem 32rem, 42rem 36rem, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-synthwave-grid {
|
||||||
|
/* Two repeating line layers form a broad grid; the magenta horizon glow prevents horizontal
|
||||||
|
gaps from reading as a single flat color when they cross between grid lines. Two-pixel grid
|
||||||
|
strokes remain crisp without bringing back the previous tiny graph-paper appearance. */
|
||||||
|
background-color: #090018;
|
||||||
|
background-image:
|
||||||
|
linear-gradient(rgba(34, 211, 238, 0.7) 2px, transparent 2px),
|
||||||
|
linear-gradient(90deg, rgba(236, 72, 153, 0.72) 2px, transparent 2px),
|
||||||
|
radial-gradient(ellipse at 50% 100%, #7e22ce 0, #1e1b4b 38%, #090018 72%);
|
||||||
|
background-size: 54px 54px, 54px 54px, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-neon-checker {
|
||||||
|
/* A conic gradient makes a crisp checker with no image asset and keeps intersections equally
|
||||||
|
interesting when exposed through horizontal or vertical card seams. The large tile size
|
||||||
|
turns each color into a strong block instead of a rapidly alternating micro-pattern. */
|
||||||
|
background-color: #111827;
|
||||||
|
background-image: conic-gradient(
|
||||||
|
from 45deg,
|
||||||
|
#06b6d4 0 25%,
|
||||||
|
#7c3aed 0 50%,
|
||||||
|
#ec4899 0 75%,
|
||||||
|
#111827 0
|
||||||
|
);
|
||||||
|
background-size: 84px 84px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-ocean-current {
|
||||||
|
/* Crossing translucent diagonals suggest moving water while remaining completely static. The
|
||||||
|
broad transparent intervals create long currents instead of tightly packed hatch marks. */
|
||||||
|
background-color: #082f49;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(35deg, rgba(103, 232, 249, 0.72) 0 12px, transparent 12px 54px),
|
||||||
|
repeating-linear-gradient(-35deg, rgba(14, 116, 144, 0.72) 0 21px, transparent 21px 72px),
|
||||||
|
linear-gradient(90deg, #0c4a6e, #0891b2, #164e63);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-ember-lattice {
|
||||||
|
/* Opposing narrow diagonals create bright crossings, with a dark red base keeping the theme
|
||||||
|
readable instead of overpowering the neutral cards around it. The enlarged repeat creates
|
||||||
|
occasional strong crossings rather than a dense mesh along every seam. */
|
||||||
|
background-color: #1c0704;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(45deg, transparent 0 36px, rgba(251, 146, 60, 0.9) 36px 45px),
|
||||||
|
repeating-linear-gradient(-45deg, transparent 0 48px, rgba(239, 68, 68, 0.78) 48px 57px),
|
||||||
|
radial-gradient(circle at center, #7c2d12, #1c0704 70%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-starfield {
|
||||||
|
/* Offset radial layers provide stars at three scales. All layers repeat, so long PTZ and
|
||||||
|
driver pages never reveal an unpainted region while scrolling. Larger spacing and dots make
|
||||||
|
this read as a sparse starfield rather than fine visual noise inside the narrow gaps. */
|
||||||
|
background-color: #020617;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(circle, rgba(255, 255, 255, 0.95) 0 2px, transparent 3px),
|
||||||
|
radial-gradient(circle, rgba(125, 211, 252, 0.9) 0 3px, transparent 4px),
|
||||||
|
radial-gradient(circle, rgba(216, 180, 254, 0.8) 0 2px, transparent 3px),
|
||||||
|
linear-gradient(135deg, #020617, #172554 55%, #3b0764);
|
||||||
|
background-position: 0 0, 57px 81px, 123px 33px, 0 0;
|
||||||
|
background-size: 111px 111px, 183px 183px, 237px 237px, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-vaporwave-sunset {
|
||||||
|
/* A large horizon glow supplies the sunset while widely spaced scan lines add retro structure.
|
||||||
|
Both layers span enough distance to remain recognizable through narrow card seams instead of
|
||||||
|
collapsing into the fine television-static texture common to smaller vaporwave patterns. */
|
||||||
|
background-color: #2e1065;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent 0 24px,
|
||||||
|
rgba(103, 232, 249, 0.48) 24px 27px,
|
||||||
|
transparent 27px 54px
|
||||||
|
),
|
||||||
|
radial-gradient(circle at 50% 78%, #fda4af 0 14%, #f97316 14% 25%, transparent 25.5%),
|
||||||
|
linear-gradient(180deg, #172554 0%, #6b21a8 48%, #db2777 72%, #f97316 100%);
|
||||||
|
background-size: auto, 48rem 34rem, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-electric-circuit {
|
||||||
|
/* Offset horizontal and vertical traces form large circuit-like routes. Separate repeat sizes
|
||||||
|
keep their intersections irregular, while the dark navy base prevents the cyan and violet
|
||||||
|
traces from overpowering the cards that the pattern is meant to separate. */
|
||||||
|
background-color: #020617;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
90deg,
|
||||||
|
transparent 0 48px,
|
||||||
|
rgba(34, 211, 238, 0.9) 48px 52px,
|
||||||
|
transparent 52px 96px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
0deg,
|
||||||
|
transparent 0 68px,
|
||||||
|
rgba(168, 85, 247, 0.86) 68px 72px,
|
||||||
|
transparent 72px 124px
|
||||||
|
),
|
||||||
|
radial-gradient(circle at center, #312e81 0, #0f172a 45%, #020617 78%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-lava-flow {
|
||||||
|
/* Oversized overlapping pools create broad molten channels without animation. Their offset
|
||||||
|
positions ensure horizontal and vertical seams cross different hot and cooled regions as
|
||||||
|
they travel around the page. */
|
||||||
|
background-color: #180503;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 18% 28%, #facc15 0 8%, #f97316 9% 18%, #991b1b 24%, transparent 39%),
|
||||||
|
radial-gradient(ellipse at 76% 64%, #fb923c 0 10%, #dc2626 18%, #7f1d1d 29%, transparent 44%),
|
||||||
|
radial-gradient(ellipse at 48% 92%, #fde047 0 7%, #ea580c 17%, transparent 36%),
|
||||||
|
linear-gradient(135deg, #180503, #450a0a 50%, #1c0704);
|
||||||
|
background-size: 42rem 34rem, 48rem 39rem, 37rem 31rem, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-deep-space-nebula {
|
||||||
|
/* Large soft clouds distinguish this from the sparse Starfield theme. A few broad nebulae are
|
||||||
|
more useful in thin gaps than many tiny stars, and the static layers avoid continuous paint
|
||||||
|
work behind the live rover and PTZ video surfaces. */
|
||||||
|
background-color: #020617;
|
||||||
|
background-image:
|
||||||
|
radial-gradient(ellipse at 22% 38%, rgba(217, 70, 239, 0.92), transparent 34%),
|
||||||
|
radial-gradient(ellipse at 72% 24%, rgba(59, 130, 246, 0.9), transparent 38%),
|
||||||
|
radial-gradient(ellipse at 58% 82%, rgba(14, 165, 233, 0.72), transparent 36%),
|
||||||
|
radial-gradient(circle, rgba(255, 255, 255, 0.9) 0 2px, transparent 3px),
|
||||||
|
linear-gradient(125deg, #020617, #1e1b4b 52%, #3b0764);
|
||||||
|
background-position: center, center, center, 17px 31px, center;
|
||||||
|
background-size: 42rem 34rem, 48rem 38rem, 39rem 35rem, 173px 173px, auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-holographic-waves {
|
||||||
|
/* Broad translucent diagonals cross over an iridescent base. Using two directions means both
|
||||||
|
horizontal and vertical gaps encounter gradual color shifts without resorting to small,
|
||||||
|
noisy rainbow repetitions. */
|
||||||
|
background-color: #164e63;
|
||||||
|
background-image:
|
||||||
|
repeating-linear-gradient(
|
||||||
|
32deg,
|
||||||
|
rgba(255, 255, 255, 0.4) 0 14px,
|
||||||
|
transparent 14px 82px
|
||||||
|
),
|
||||||
|
repeating-linear-gradient(
|
||||||
|
-38deg,
|
||||||
|
rgba(216, 180, 254, 0.38) 0 18px,
|
||||||
|
transparent 18px 104px
|
||||||
|
),
|
||||||
|
linear-gradient(110deg, #67e8f9, #c4b5fd 32%, #f9a8d4 60%, #86efac 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-hazard-stripes {
|
||||||
|
/* This intentionally uses a simple, very wide two-color repeat. The large bands make the
|
||||||
|
industrial warning motif immediately readable even though only a two-pixel slice is visible
|
||||||
|
between neighboring cards. Its bands use the same shared width as every pride stripe theme. */
|
||||||
|
background-color: #111111;
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#facc15 0 48px,
|
||||||
|
#171717 48px 96px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-mint-mosaic {
|
||||||
|
/* A large conic tile creates chunky geometric blocks in cool mint, teal, navy, and cream. The
|
||||||
|
restrained palette offers a calmer alternative to Neon checker while retaining clear color
|
||||||
|
changes along both seam directions. */
|
||||||
|
background-color: #064e3b;
|
||||||
|
background-image: conic-gradient(
|
||||||
|
from 45deg,
|
||||||
|
#a7f3d0 0 25%,
|
||||||
|
#0f766e 0 50%,
|
||||||
|
#fef3c7 0 75%,
|
||||||
|
#164e63 0
|
||||||
|
);
|
||||||
|
background-size: 96px 96px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-candy-swirl {
|
||||||
|
/* Large repeating rings provide curved movement that the otherwise line-oriented catalog does
|
||||||
|
not have. Wide color stops keep the pastel rings bold and prevent them from turning into a
|
||||||
|
dense target pattern in the preview or real page gaps. */
|
||||||
|
background-color: #fdf2f8;
|
||||||
|
background-image: repeating-radial-gradient(
|
||||||
|
circle at 28% 34%,
|
||||||
|
#f9a8d4 0 28px,
|
||||||
|
#fef3c7 28px 56px,
|
||||||
|
#93c5fd 56px 84px,
|
||||||
|
#c4b5fd 84px 112px,
|
||||||
|
#f9a8d4 112px 140px
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
/* Pride Page Themes
|
||||||
|
* Purpose: Contains the broad, equal-width flag-inspired stripe artwork.
|
||||||
|
* Scope: Pride color sequences only; shared canvas behavior lives in base.css.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* Ordinary CSS keeps the artwork independent of Tailwind's global component layer. main.jsx loads
|
||||||
|
* this theme bundle after index.css, giving the theme modifiers their original cascade position. */
|
||||||
|
.page-theme-pride-mix {
|
||||||
|
/* Every solid stripe theme uses the same forty-eight-pixel band width. Keeping that measurement
|
||||||
|
shared makes browsing predictable: only the colors and sequence change, never the visual
|
||||||
|
density of the card-gap artwork. */
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#5bcefa 0 48px,
|
||||||
|
#f5a9b8 48px 96px,
|
||||||
|
#ffffff 96px 144px,
|
||||||
|
#f5a9b8 144px 192px,
|
||||||
|
#5bcefa 192px 240px,
|
||||||
|
#e40303 240px 288px,
|
||||||
|
#ff8c00 288px 336px,
|
||||||
|
#ffed00 336px 384px,
|
||||||
|
#00a651 384px 432px,
|
||||||
|
#0066ff 432px 480px,
|
||||||
|
#9b2fae 480px 528px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-rainbow {
|
||||||
|
/* Equal broad diagonal bands keep the familiar six-color flag readable as color fields rather
|
||||||
|
than a dense ribbon when exposed through either horizontal or vertical card gaps. */
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#e40303 0 48px,
|
||||||
|
#ff8c00 48px 96px,
|
||||||
|
#ffed00 96px 144px,
|
||||||
|
#008026 144px 192px,
|
||||||
|
#004dff 192px 240px,
|
||||||
|
#750787 240px 288px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-progress-pride {
|
||||||
|
/* The inclusive chevron colors are interleaved with the rainbow because a literal large
|
||||||
|
chevron would disappear inside narrow seams. Using the shared stripe width keeps this longer
|
||||||
|
sequence just as bold as the shorter flag themes instead of compressing it to fit one cycle. */
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#000000 0 48px,
|
||||||
|
#784f17 48px 96px,
|
||||||
|
#5bcefa 96px 144px,
|
||||||
|
#f5a9b8 144px 192px,
|
||||||
|
#ffffff 192px 240px,
|
||||||
|
#e40303 240px 288px,
|
||||||
|
#ff8c00 288px 336px,
|
||||||
|
#ffed00 336px 384px,
|
||||||
|
#008026 384px 432px,
|
||||||
|
#004dff 432px 480px,
|
||||||
|
#750787 480px 528px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-transgender {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#5bcefa 0 48px,
|
||||||
|
#f5a9b8 48px 96px,
|
||||||
|
#ffffff 96px 144px,
|
||||||
|
#f5a9b8 144px 192px,
|
||||||
|
#5bcefa 192px 240px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-bisexual {
|
||||||
|
/* Equal bands deliberately prioritize consistency with the other selectable stripe themes.
|
||||||
|
The three-color identity remains clear while browsing no longer changes stripe density. */
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#d60270 0 48px,
|
||||||
|
#9b4f96 48px 96px,
|
||||||
|
#0038a8 96px 144px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-lesbian {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#d52d00 0 48px,
|
||||||
|
#ef7627 48px 96px,
|
||||||
|
#ff9a56 96px 144px,
|
||||||
|
#ffffff 144px 192px,
|
||||||
|
#d162a4 192px 240px,
|
||||||
|
#b55690 240px 288px,
|
||||||
|
#a30262 288px 336px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-nonbinary {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#fff430 0 48px,
|
||||||
|
#ffffff 48px 96px,
|
||||||
|
#9c59d1 96px 144px,
|
||||||
|
#2d2d2d 144px 192px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-pansexual {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#ff218c 0 48px,
|
||||||
|
#ffd800 48px 96px,
|
||||||
|
#21b1ff 96px 144px
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-theme-asexual {
|
||||||
|
background-image: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
#111111 0 48px,
|
||||||
|
#a3a3a3 48px 96px,
|
||||||
|
#ffffff 96px 144px,
|
||||||
|
#800080 144px 192px
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user