mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-15 17:12:59 -04:00
Compare commits
3
Commits
038ae0f45a
...
8895ed6bd8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8895ed6bd8 | ||
|
|
6ca7cc0cf0 | ||
|
|
d3fd3946e6 |
+21
-14
@@ -16,7 +16,7 @@ The design deliberately stays small:
|
||||
- There is no peripheral configuration in the rover configuration file.
|
||||
- There is no separate rover-peripheral protocol version.
|
||||
|
||||
This document is both the design contract and implementation guide. The PlatformIO firmware library, reference sketch, focused Go Firmata client, hardware probe, boot-time daemon discovery, fixed inventory, generic output dispatch, built-in hardware backend selection, and rover WebSocket message shapes now exist. Server forwarding and HUD rendering remain later implementation stages.
|
||||
This document is both the design contract and implementation guide. The PlatformIO firmware library, reference sketch, focused Go Firmata client, hardware probe, boot-time daemon discovery, fixed inventory, generic output dispatch, built-in hardware backend selection, rover WebSocket message shapes, server roster forwarding, and shared HUD renderer now exist.
|
||||
|
||||
## System boundary
|
||||
|
||||
@@ -956,10 +956,12 @@ No global `session.features` flag is necessary. Peripherals are inherently optio
|
||||
|
||||
## Browser-to-server control path
|
||||
|
||||
The browser sends one generic Socket.IO event for every peripheral control:
|
||||
The browser sends every peripheral interaction through the existing Socket.IO
|
||||
`command` event. Peripheral actuation is a rover command, so it does not need a
|
||||
parallel event or authorization path.
|
||||
|
||||
```text
|
||||
peripheral:set
|
||||
command
|
||||
```
|
||||
|
||||
Payload:
|
||||
@@ -967,9 +969,14 @@ Payload:
|
||||
```json
|
||||
{
|
||||
"roverId": "rover-name",
|
||||
"peripheralId": "firmata-0",
|
||||
"controlId": "servoPosition",
|
||||
"value": 90
|
||||
"type": "peripheral",
|
||||
"data": {
|
||||
"peripheral": {
|
||||
"id": "firmata-0",
|
||||
"control": "servoPosition",
|
||||
"value": 90
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1124,13 +1131,13 @@ Generic peripheral controls are rover controls, so they follow the new driver's
|
||||
|
||||
The standardized replacements do not create any new UI. `cameraServo`, `headlight`, and `laser` continue to use their current camera-tilt, headlight, and laser HUD controls. Only entries in the generic `controls` arrays appear in a new surface named `Accessories`.
|
||||
|
||||
On desktop, `Accessories` is a collapsible HUD drawer connected to the bottom-left rover-control pod. This keeps additional actuation beside the existing horn, headlight, and laser controls without permanently covering the video. The drawer is absent when the assigned rover advertises no generic controls.
|
||||
On desktop, `Accessories` is a vertical button centered on the left wall of the video. It uses the existing translucent black HUD treatment and opens a height-limited, vertically scrollable panel toward the right. The panel uses the same compact control renderer as mobile and is independent of the bottom-left horn, headlight, and laser pod.
|
||||
|
||||
On mobile, the HUD launcher opens an unscaled, vertically scrollable sheet over the video stage. Generic controls must not be placed in the fixed `AuxColumn`: an arbitrary device-defined list cannot fit that column's intentionally fixed set of large driving controls. The mobile sheet closes without changing control values and disappears when there are no generic controls.
|
||||
On mobile, a vertical `Accessories` button sits directly to the right of the vacuum-forward and vacuum-backward buttons. Activating it replaces the complete `AuxColumn` contents with the ordered, vertically scrollable accessory list. A small `Aux` button shares the first compact device heading and returns to the normal vacuum, camera, light, laser, and horn controls without creating a separate rail or overlay border.
|
||||
|
||||
Desktop and mobile reuse one generic renderer inside their different HUD containers. Device-specific React components are not created for individual peripherals. The renderer sends actions through `ControlSystemProvider`, `ControlContext`, and the existing command pipeline so assignment gating, input cancellation, and command behavior remain consistent with other rover HUD controls.
|
||||
Desktop and mobile reuse one placement-independent `RoverAccessoryControls` renderer inside their different containers. Device-specific React components are not created for individual peripherals. The renderer sends actions through `ControlSystemProvider`, `ControlContext`, and the existing command pipeline so assignment gating, input cancellation, and command behavior remain consistent with other rover HUD controls. Both parents and the renderer disappear completely when the assigned rover has no generic controls; no launcher, empty shell, or reserved space remains.
|
||||
|
||||
Control values are local UI values in the first implementation. Slider and toggle changes update the displayed value immediately and are then sent to the server. Restarting `roverd` recreates controls from the new hello rather than persisting peripheral values in `roverSettings`.
|
||||
Control values are local UI values in the first implementation. Slider and toggle changes update the displayed value immediately and are then sent to the server. Generic sliders use the same custom pointer-capture approach as mobile camera tilt rather than a browser-native range control, which keeps touch behavior and appearance consistent while driving. Restarting `roverd` recreates controls from the new hello rather than persisting peripheral values in `roverSettings`.
|
||||
|
||||
## Permissions
|
||||
|
||||
@@ -1140,7 +1147,7 @@ The server enforces this with the existing `roverManager.canDrive(roverId, socke
|
||||
|
||||
No peripheral-specific roles, administrator-only controls, access lists, or permissions in ESP32 configuration are part of this design.
|
||||
|
||||
When the driver loses the rover assignment, the UI stops presenting enabled controls and subsequent `peripheral:set` requests fail the same server-side drive check.
|
||||
When the driver loses the rover assignment, the UI stops presenting enabled controls and subsequent peripheral commands fail the same server-side drive check.
|
||||
|
||||
## Expected repository changes
|
||||
|
||||
@@ -1194,7 +1201,7 @@ Extend the existing rover connection and roster path to:
|
||||
- Accept `peripherals` in rover hello metadata.
|
||||
- Include peripherals in `roverManager.getRoster()`.
|
||||
- Continue exposing effective `cameraServo`, `headlight`, and `laser` metadata through their existing roster fields regardless of physical backend.
|
||||
- Add the generic `peripheral:set` Socket.IO handler.
|
||||
- Route generic controls through the existing Socket.IO `command` handler.
|
||||
- Reuse `roverManager.canDrive()` for authorization.
|
||||
- Forward the command through `commandService` so rover acknowledgements remain consistent with other controls.
|
||||
|
||||
@@ -1205,9 +1212,9 @@ Add one generic peripheral control renderer that:
|
||||
- Selects the assigned rover and its peripherals from session state.
|
||||
- Preserves peripheral and control array order.
|
||||
- Renders only the four agreed control types.
|
||||
- Sends every interaction through the same `peripheral:set` event.
|
||||
- Sends every interaction through the existing `command` event with type `peripheral`.
|
||||
- Supports momentary press and release for pointer, touch, and keyboard activation.
|
||||
- Mounts in the desktop Accessories HUD drawer and mobile Accessories HUD sheet.
|
||||
- Mounts in the desktop left-wall expansion and as a replacement view inside mobile `AuxColumn`.
|
||||
- Uses the shared control context and command pipeline rather than emitting directly from layout code.
|
||||
- Disappears completely when the assigned rover has no peripherals.
|
||||
|
||||
|
||||
+2
-2
File diff suppressed because one or more lines are too long
+1
-1
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
File diff suppressed because one or more lines are too long
@@ -12,8 +12,8 @@
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- site-metadata:inject -->
|
||||
<!-- analytics:inject -->
|
||||
<script type="module" crossorigin src="/assets/index-BZ2ymoHR.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BFNKIMjg.css">
|
||||
<script type="module" crossorigin src="/assets/index-DsdqxXyd.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-0J6fxLEg.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -239,6 +239,11 @@ function createRosterLifecycle(deps) {
|
||||
cameraServo: record.meta?.cameraServo,
|
||||
audio: record.meta?.audio,
|
||||
horn: record.meta?.horn,
|
||||
// Generic peripheral metadata is already reduced by roverd to the fields
|
||||
// the browser needs: stable process-local IDs, display names, and ordered
|
||||
// controls. Preserve that order here instead of rebuilding the inventory,
|
||||
// because ESP32 registration order is also the driver's display order.
|
||||
peripherals: Array.isArray(record.meta?.peripherals) ? record.meta.peripherals : [],
|
||||
headlight: record.meta?.headlight
|
||||
? { ...record.meta.headlight, state: record.headlightState }
|
||||
: record.meta?.headlight,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
// Rover Roster Lifecycle Tests
|
||||
// Purpose: Verifies that boot-discovered accessory metadata reaches the public rover roster unchanged.
|
||||
// Scope: Covers roster projection only; roverd remains responsible for validating and reducing device descriptions.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { createRosterLifecycle } = require('./rosterLifecycle');
|
||||
|
||||
function createRosterManager(meta) {
|
||||
const rovers = new Map([[
|
||||
meta.name,
|
||||
{
|
||||
id: meta.name,
|
||||
meta,
|
||||
batteryState: null,
|
||||
headlightState: null,
|
||||
laserState: null,
|
||||
locked: false,
|
||||
lockReason: null,
|
||||
lastSeen: 123,
|
||||
},
|
||||
]]);
|
||||
|
||||
return createRosterLifecycle({
|
||||
rovers,
|
||||
isPrivateRecord: () => false,
|
||||
isPrivateOpen: () => true,
|
||||
getPrivateSafety: () => ({}),
|
||||
});
|
||||
}
|
||||
|
||||
test('getRoster preserves peripheral and control registration order', () => {
|
||||
const peripherals = [
|
||||
{
|
||||
id: 'firmata-0',
|
||||
name: 'Camera arm',
|
||||
controls: [
|
||||
{ id: 'position', type: 'slider', name: 'Position', min: 0, max: 180 },
|
||||
{ id: 'action', type: 'button', name: 'Action', mode: 'momentary' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'firmata-1',
|
||||
name: 'Lighting',
|
||||
controls: [
|
||||
{ id: 'brightness', type: 'number', name: 'Brightness', min: 0, max: 255 },
|
||||
],
|
||||
},
|
||||
];
|
||||
const manager = createRosterManager({ name: 'rover-one', peripherals });
|
||||
|
||||
const [entry] = manager.getRoster();
|
||||
|
||||
// Deep equality verifies both the public field set and array order. The
|
||||
// server must not alphabetize controls because their firmware order is a UI
|
||||
// contract rather than incidental transport ordering.
|
||||
assert.deepEqual(entry.peripherals, peripherals);
|
||||
});
|
||||
|
||||
test('getRoster supplies an empty peripheral inventory when none was advertised', () => {
|
||||
const manager = createRosterManager({ name: 'rover-one' });
|
||||
|
||||
const [entry] = manager.getRoster();
|
||||
|
||||
assert.deepEqual(entry.peripherals, []);
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
// Desktop Accessories Expansion
|
||||
// Purpose: Places generic rover controls on a collapsible surface centered along the video's left wall.
|
||||
// Scope: Owns desktop positioning and persisted visibility while reusing the layout-independent control renderer.
|
||||
import RoverAccessoryControls from '../../../RoverAccessoryControls/index.jsx';
|
||||
import AccessoriesToggle from '../../../RoverAccessoryControls/AccessoriesToggle.jsx';
|
||||
import useRoverAccessories from '../../../RoverAccessoryControls/useRoverAccessories.js';
|
||||
import usePodVisibility from './usePodVisibility.js';
|
||||
|
||||
export default function AccessoriesExpansion({ roverId }) {
|
||||
const { hasAccessories } = useRoverAccessories(roverId);
|
||||
const [open, setOpen] = usePodVisibility('accessories', false);
|
||||
|
||||
// Do not leave an invisible anchor or reserved HUD area on rovers whose
|
||||
// peripherals provide only standardized controls or no controls at all.
|
||||
if (!hasAccessories) return null;
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none absolute inset-y-0 left-0 z-20 flex items-center">
|
||||
<AccessoriesToggle
|
||||
label="Accessories"
|
||||
ariaLabel={open ? 'Hide accessory controls' : 'Show accessory controls'}
|
||||
onClick={() => setOpen(!open)}
|
||||
hud
|
||||
className="pointer-events-auto !h-28"
|
||||
/>
|
||||
{open ? (
|
||||
<div className="pointer-events-auto w-64 overflow-hidden rounded-r-xl bg-black/60 p-0.5">
|
||||
{/* This is exactly the renderer mounted by AuxColumn. The desktop
|
||||
wrapper changes available dimensions, never control behavior.
|
||||
Content determines the normal panel height; max-height becomes a
|
||||
scrolling boundary only for genuinely long accessory lists. */}
|
||||
<RoverAccessoryControls roverId={roverId} className="max-h-[70vh]" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import TopLeftPod from './TopLeftPod.jsx';
|
||||
import TopRightPod from './TopRightPod.jsx';
|
||||
import BottomLeftPod from './BottomLeftPod.jsx';
|
||||
import BottomRightPod from './BottomRightPod.jsx';
|
||||
import AccessoriesExpansion from './AccessoriesExpansion.jsx';
|
||||
import { useDriverLayout } from '../../../../layouts/driver/DriverLayoutContext.jsx';
|
||||
|
||||
export default function CornerPods({ roverId }) {
|
||||
@@ -17,6 +18,9 @@ export default function CornerPods({ roverId }) {
|
||||
{/* The mobile layouts already provide large touch controls around the video.
|
||||
Omitting this pod avoids presenting duplicate horn, light, and laser actions. */}
|
||||
{showPhysicalControlPods ? <BottomLeftPod roverId={roverId} /> : null}
|
||||
{/* Generic accessory controls stay on the same left side at every
|
||||
breakpoint, but mobile owns their placement inside AuxColumn. */}
|
||||
{showPhysicalControlPods ? <AccessoriesExpansion roverId={roverId} /> : null}
|
||||
{/* BottomRightPod also owns the independent chat expansion, so it remains mounted
|
||||
on mobile and determines its own camera-control visibility from layout context. */}
|
||||
<BottomRightPod roverId={roverId} />
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Aux Column
|
||||
// Purpose: Assembles the mobile auxiliary controls column, which is the left column by default.
|
||||
// Scope: Owns mobile aux/camera/headlight/laser/horn wiring while reusing desktop variation components where intended.
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { FaBullhorn, FaCrosshairs, FaLightbulb } from 'react-icons/fa';
|
||||
import './mobileControls.css';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
@@ -15,11 +15,14 @@ import { AUX_ZERO } from './constants.js';
|
||||
import VacuumControls from './VacuumControls.jsx';
|
||||
import VerticalCameraTilt from './VerticalCameraTilt.jsx';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import RoverAccessoryControls from '../RoverAccessoryControls/index.jsx';
|
||||
import AccessoriesToggle from '../RoverAccessoryControls/AccessoriesToggle.jsx';
|
||||
import useRoverAccessories from '../RoverAccessoryControls/useRoverAccessories.js';
|
||||
|
||||
const CAMERA_TILT_STEP_DEGREES = 0.5;
|
||||
const CAMERA_TILT_PRECISION_STEP_DEGREES = 0.1;
|
||||
|
||||
function AuxColumnContent() {
|
||||
function AuxColumnContent({ accessoriesAvailable, onShowAccessories }) {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
const roomLightsLockedOn = useSessionSelector((state) => Boolean(state.session?.homeAssistant?.lightPolicy?.lockedOn));
|
||||
const camera = useControlSelector((control) => control.state.camera);
|
||||
@@ -119,11 +122,20 @@ function AuxColumnContent() {
|
||||
|
||||
return (
|
||||
<div className="mobile-touch-control grid h-full min-h-0 w-full grid-rows-[minmax(0,1fr)_minmax(0,1fr)_minmax(0,1fr)] gap-0.5 text-slate-100">
|
||||
<VacuumControls
|
||||
disabled={vacuumDisabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
<div className={`mobile-touch-control grid min-h-0 gap-0.5 ${accessoriesAvailable ? 'grid-cols-[minmax(0,1fr)_2rem]' : 'grid-cols-1'}`}>
|
||||
<VacuumControls
|
||||
disabled={vacuumDisabled}
|
||||
onPress={handleAuxPress}
|
||||
onRelease={handleAuxRelease}
|
||||
/>
|
||||
{accessoriesAvailable ? (
|
||||
<AccessoriesToggle
|
||||
label="Accessories"
|
||||
ariaLabel="Show accessory controls"
|
||||
onClick={onShowAccessories}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="mobile-touch-control flex min-h-0 items-stretch gap-0.5">
|
||||
{cameraEnabled ? (
|
||||
<VerticalCameraTilt
|
||||
@@ -180,10 +192,49 @@ function AuxColumnContent() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuxColumn({ layout, className = '' }) {
|
||||
function RoverAuxColumn({ roverId, layout, className }) {
|
||||
const [showAccessories, setShowAccessories] = useState(false);
|
||||
const { hasAccessories } = useRoverAccessories(roverId);
|
||||
|
||||
return (
|
||||
<div className={`mobile-touch-control flex flex-col gap-0.5 ${className}`.trim()} data-mobile-layout={layout}>
|
||||
<AuxColumnContent />
|
||||
{showAccessories && hasAccessories ? (
|
||||
<div className="mobile-touch-control h-full min-h-0 overflow-hidden">
|
||||
<RoverAccessoryControls
|
||||
roverId={roverId}
|
||||
className="h-full"
|
||||
headerAction={(
|
||||
<AccessoriesToggle
|
||||
label="Aux"
|
||||
ariaLabel="Return to auxiliary controls"
|
||||
compact
|
||||
onClick={() => setShowAccessories(false)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<AuxColumnContent
|
||||
accessoriesAvailable={hasAccessories}
|
||||
onShowAccessories={() => setShowAccessories(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AuxColumn({ layout, className = '' }) {
|
||||
const roverId = useControlSelector((control) => control.state.roverId);
|
||||
|
||||
// Keying the stateful view by assignment makes every newly selected rover
|
||||
// start in the familiar Aux view. It also guarantees that an Accessories
|
||||
// view cannot remain open after changing to a rover without accessories.
|
||||
return (
|
||||
<RoverAuxColumn
|
||||
key={roverId || 'no-rover'}
|
||||
roverId={roverId}
|
||||
layout={layout}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// Accessories Vertical Toggle
|
||||
// Purpose: Gives mobile and desktop the same edge-mounted control for changing accessory visibility.
|
||||
// Scope: Owns only visual treatment and activation; each parent decides its position and destination.
|
||||
import { FaPuzzlePiece } from 'react-icons/fa';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
export default function AccessoriesToggle({
|
||||
label,
|
||||
ariaLabel,
|
||||
onClick,
|
||||
compact = false,
|
||||
hud = false,
|
||||
className = '',
|
||||
}) {
|
||||
const sizeClass = compact ? 'h-6 w-10' : 'h-full w-8';
|
||||
const toneClass = hud
|
||||
? 'rounded-none border-0 bg-black/60 text-white/75 shadow-none hover:bg-black hover:text-white'
|
||||
: 'rounded-xl border-2 border-cyan-300/70 bg-cyan-900 text-cyan-50 shadow-md hover:brightness-110 active:brightness-125';
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={ariaLabel || label}
|
||||
onClick={() => {
|
||||
triggerTouchHaptic('button');
|
||||
onClick();
|
||||
}}
|
||||
className={`mobile-touch-control flex shrink-0 items-center justify-center text-sm font-semibold transition active:scale-[0.98] ${sizeClass} ${toneClass} ${className}`.trim()}
|
||||
>
|
||||
{/* Full launchers use vertical writing in the narrow wall space. The
|
||||
compact Aux return stays horizontal so it consumes only one heading. */}
|
||||
<span className={compact ? 'flex items-center' : 'flex items-center gap-1 [writing-mode:vertical-rl] rotate-180'}>
|
||||
{!compact ? <FaPuzzlePiece className="shrink-0 text-sm" aria-hidden="true" /> : null}
|
||||
<span>{label}</span>
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
// Accessory Control Field
|
||||
// Purpose: Maps one firmware-advertised generic control to a compact rover-control surface.
|
||||
// Scope: Owns browser-local input semantics; transport and device-specific behavior stay outside this file.
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { triggerTouchHaptic } from '../../lib/touchHaptics.js';
|
||||
|
||||
function integerBound(value, fallback) {
|
||||
return Number.isInteger(value) ? value : fallback;
|
||||
}
|
||||
|
||||
function clampInteger(value, minimum, maximum) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return minimum;
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(numeric)));
|
||||
}
|
||||
|
||||
function trimUnicode(value, maximumLength) {
|
||||
// Array.from counts Unicode code points instead of UTF-16 code units. That
|
||||
// mirrors Go's rune-count validation for emoji and other non-BMP characters.
|
||||
return Array.from(String(value ?? '')).slice(0, maximumLength).join('');
|
||||
}
|
||||
|
||||
const CARD_CLASS = 'mobile-touch-control rounded-xl border-2 px-2 py-1 text-slate-50';
|
||||
const DISABLED_CLASS = 'disabled:cursor-not-allowed disabled:opacity-40';
|
||||
|
||||
function SliderControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||
const minimum = integerBound(control.min, 0);
|
||||
const maximum = integerBound(control.max, minimum);
|
||||
const value = Number.isInteger(storedValue)
|
||||
? clampInteger(storedValue, minimum, maximum)
|
||||
: minimum;
|
||||
const trackRef = useRef(null);
|
||||
const pointerIdRef = useRef(null);
|
||||
const lastHapticValueRef = useRef(value);
|
||||
|
||||
const valuePercent = useMemo(() => {
|
||||
if (maximum === minimum) return 50;
|
||||
return ((value - minimum) / (maximum - minimum)) * 100;
|
||||
}, [maximum, minimum, value]);
|
||||
|
||||
const sendValue = useCallback((nextValue) => {
|
||||
const next = clampInteger(nextValue, minimum, maximum);
|
||||
const hapticStep = Math.max(1, Math.round((maximum - minimum) / 20));
|
||||
if (Math.abs(next - lastHapticValueRef.current) >= hapticStep) {
|
||||
triggerTouchHaptic('camera');
|
||||
lastHapticValueRef.current = next;
|
||||
}
|
||||
send(peripheralId, control.id, next);
|
||||
}, [control.id, maximum, minimum, peripheralId, send]);
|
||||
|
||||
const valueFromPointer = useCallback((event) => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return value;
|
||||
const bounds = track.getBoundingClientRect();
|
||||
const rawPercent = (event.clientX - bounds.left) / Math.max(1, bounds.width);
|
||||
return minimum + Math.max(0, Math.min(1, rawPercent)) * (maximum - minimum);
|
||||
}, [maximum, minimum, value]);
|
||||
|
||||
const updateFromPointer = useCallback((event) => {
|
||||
sendValue(valueFromPointer(event));
|
||||
}, [sendValue, valueFromPointer]);
|
||||
|
||||
const handlePointerDown = useCallback((event) => {
|
||||
if (disabled || pointerIdRef.current !== null) return;
|
||||
// This follows VerticalCameraTilt's custom pointer-capture path so a range
|
||||
// drag remains reliable while another finger is operating the drive pad.
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = event.pointerId;
|
||||
lastHapticValueRef.current = value;
|
||||
trackRef.current?.setPointerCapture?.(event.pointerId);
|
||||
updateFromPointer(event);
|
||||
}, [disabled, updateFromPointer, value]);
|
||||
|
||||
const handlePointerMove = useCallback((event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
updateFromPointer(event);
|
||||
}, [updateFromPointer]);
|
||||
|
||||
const handlePointerEnd = useCallback((event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = null;
|
||||
trackRef.current?.releasePointerCapture?.(event.pointerId);
|
||||
}, []);
|
||||
|
||||
const handleKeyDown = (event) => {
|
||||
if (disabled) return;
|
||||
let next = null;
|
||||
if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') next = value - 1;
|
||||
if (event.key === 'ArrowRight' || event.key === 'ArrowUp') next = value + 1;
|
||||
if (event.key === 'Home') next = minimum;
|
||||
if (event.key === 'End') next = maximum;
|
||||
if (next == null) return;
|
||||
event.preventDefault();
|
||||
sendValue(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`${CARD_CLASS} border-emerald-300/70 bg-emerald-900 ${disabled ? 'cursor-not-allowed opacity-40' : ''}`}>
|
||||
<div className="flex items-center justify-between gap-1 text-sm font-semibold">
|
||||
<span className="min-w-0 truncate">{control.name}</span>
|
||||
<span className="shrink-0 font-mono text-emerald-100">{value}</span>
|
||||
</div>
|
||||
<div
|
||||
ref={trackRef}
|
||||
role="slider"
|
||||
aria-label={control.name}
|
||||
aria-valuemin={minimum}
|
||||
aria-valuemax={maximum}
|
||||
aria-valuenow={value}
|
||||
aria-disabled={disabled}
|
||||
tabIndex={disabled ? -1 : 0}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerEnd}
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onLostPointerCapture={(event) => {
|
||||
if (pointerIdRef.current === event.pointerId) pointerIdRef.current = null;
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
style={{ touchAction: 'none' }}
|
||||
className="mobile-touch-control mobile-drag-control relative mt-1 h-7 w-full rounded-full border border-emerald-100/80 bg-emerald-950 shadow-inner focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-200"
|
||||
>
|
||||
{/* An inset track gives the thumb room to remain entirely inside the
|
||||
card at both endpoints without browser-specific range styling. */}
|
||||
<div className="pointer-events-none absolute inset-1">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-emerald-400"
|
||||
style={{ width: `${valuePercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="pointer-events-none absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-emerald-950 bg-emerald-200 shadow"
|
||||
style={{ left: `clamp(0.4375rem, ${valuePercent}%, calc(100% - 0.4375rem))` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleControl({ peripheralId, control, disabled, send, value }) {
|
||||
const enabled = value === true;
|
||||
|
||||
const toggle = () => {
|
||||
if (disabled) return;
|
||||
send(peripheralId, control.id, !enabled);
|
||||
triggerTouchHaptic('button');
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={enabled}
|
||||
disabled={disabled}
|
||||
onClick={toggle}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-between gap-1 font-semibold transition active:scale-[0.99] ${enabled ? 'border-emerald-300/70 bg-emerald-800 text-emerald-50' : 'border-amber-300/70 bg-amber-900 text-amber-50'}`}
|
||||
>
|
||||
<span className="min-w-0 truncate">{control.name}</span>
|
||||
<span className="shrink-0 text-xs">{enabled ? 'On' : 'Off'}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function MomentaryControl({ peripheralId, control, disabled, send, value }) {
|
||||
const pressed = value === true;
|
||||
const pressedRef = useRef(false);
|
||||
const pointerIdRef = useRef(null);
|
||||
|
||||
const release = useCallback(() => {
|
||||
if (!pressedRef.current) return;
|
||||
pressedRef.current = false;
|
||||
pointerIdRef.current = null;
|
||||
// Always pair a successful press with false, including cancellation,
|
||||
// permission loss, and replacement of the Accessories view.
|
||||
send(peripheralId, control.id, false);
|
||||
}, [control.id, peripheralId, send]);
|
||||
|
||||
const press = useCallback(() => {
|
||||
if (disabled || pressedRef.current) return;
|
||||
pressedRef.current = true;
|
||||
send(peripheralId, control.id, true);
|
||||
}, [control.id, disabled, peripheralId, send]);
|
||||
|
||||
useEffect(() => release, [release]);
|
||||
useEffect(() => {
|
||||
if (disabled) release();
|
||||
}, [disabled, release]);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={pressed}
|
||||
disabled={disabled}
|
||||
onPointerDown={(event) => {
|
||||
if (disabled || pointerIdRef.current != null) return;
|
||||
event.preventDefault();
|
||||
pointerIdRef.current = event.pointerId;
|
||||
event.currentTarget.setPointerCapture?.(event.pointerId);
|
||||
press();
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
release();
|
||||
triggerTouchHaptic('button');
|
||||
}}
|
||||
onPointerCancel={release}
|
||||
onLostPointerCapture={release}
|
||||
onKeyDown={(event) => {
|
||||
if ((event.key === ' ' || event.key === 'Enter') && !event.repeat) {
|
||||
event.preventDefault();
|
||||
press();
|
||||
}
|
||||
}}
|
||||
onKeyUp={(event) => {
|
||||
if (event.key === ' ' || event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
release();
|
||||
triggerTouchHaptic('button');
|
||||
}
|
||||
}}
|
||||
onContextMenu={(event) => event.preventDefault()}
|
||||
className={`${CARD_CLASS} ${DISABLED_CLASS} flex min-h-12 w-full items-center justify-center text-center font-semibold transition active:scale-[0.99] ${pressed ? 'border-fuchsia-200 bg-fuchsia-600 text-white' : 'border-fuchsia-300/70 bg-fuchsia-900 text-fuchsia-50'}`}
|
||||
>
|
||||
{control.name}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function NumberControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||
const minimum = integerBound(control.min, 0);
|
||||
const maximum = integerBound(control.max, minimum);
|
||||
const initialValue = Number.isInteger(storedValue)
|
||||
? clampInteger(storedValue, minimum, maximum)
|
||||
: minimum;
|
||||
const [value, setValue] = useState(String(initialValue));
|
||||
const lastSentRef = useRef(initialValue);
|
||||
|
||||
const commit = () => {
|
||||
const next = clampInteger(value, minimum, maximum);
|
||||
setValue(String(next));
|
||||
if (disabled || next === lastSentRef.current) return;
|
||||
lastSentRef.current = next;
|
||||
send(peripheralId, control.id, next);
|
||||
triggerTouchHaptic('button');
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-indigo-300/70 bg-indigo-900`}>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
|
||||
<input
|
||||
type="number"
|
||||
inputMode="numeric"
|
||||
min={minimum}
|
||||
max={maximum}
|
||||
step="1"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={`${control.name}, ${minimum} to ${maximum}`}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control h-9 w-[45%] min-w-16 rounded-lg border border-indigo-200/70 bg-indigo-950 px-1.5 text-right text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-indigo-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
function TextControl({ peripheralId, control, disabled, send, value: storedValue }) {
|
||||
const maximumLength = Math.max(1, integerBound(control.maxLength, 1));
|
||||
const initialValue = typeof storedValue === 'string'
|
||||
? trimUnicode(storedValue, maximumLength)
|
||||
: '';
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const lastSentRef = useRef(initialValue);
|
||||
|
||||
const commit = () => {
|
||||
const next = trimUnicode(value, maximumLength);
|
||||
setValue(next);
|
||||
if (disabled || next === lastSentRef.current) return;
|
||||
lastSentRef.current = next;
|
||||
send(peripheralId, control.id, next);
|
||||
triggerTouchHaptic('button');
|
||||
};
|
||||
|
||||
return (
|
||||
<label className={`${CARD_CLASS} flex min-h-12 items-center gap-1 border-sky-300/70 bg-sky-900`}>
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-semibold">{control.name}</span>
|
||||
<input
|
||||
type="text"
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
aria-label={`${control.name}, maximum ${maximumLength} characters`}
|
||||
onChange={(event) => setValue(trimUnicode(event.target.value, maximumLength))}
|
||||
onBlur={commit}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
commit();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}}
|
||||
className={`mobile-touch-control h-9 w-[55%] min-w-20 rounded-lg border border-sky-200/70 bg-sky-950 px-1.5 text-base text-white outline-none focus-visible:ring-2 focus-visible:ring-sky-200 ${DISABLED_CLASS}`}
|
||||
/>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AccessoryControlField(props) {
|
||||
switch (props.control?.type) {
|
||||
case 'slider':
|
||||
return <SliderControl {...props} />;
|
||||
case 'button':
|
||||
return props.control.mode === 'momentary'
|
||||
? <MomentaryControl {...props} />
|
||||
: <ToggleControl {...props} />;
|
||||
case 'number':
|
||||
return <NumberControl {...props} />;
|
||||
case 'text':
|
||||
return <TextControl {...props} />;
|
||||
default:
|
||||
// roverd validates the four-type contract before publishing metadata.
|
||||
// Returning nothing remains a defensive boundary for stale servers.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// Rover Accessory Controls
|
||||
// Purpose: Renders every generic control advertised by a selected rover as one ordered control surface.
|
||||
// Scope: Reusable content only; mobile and desktop parents own placement, expansion, and visibility.
|
||||
import { useCallback } from 'react';
|
||||
import { useControlActions, useControlSelector } from '../../controls/index.js';
|
||||
import useCanControlRover from '../../hooks/useCanControlRover.js';
|
||||
import AccessoryControlField from './AccessoryControlField.jsx';
|
||||
import useRoverAccessories from './useRoverAccessories.js';
|
||||
|
||||
const EMPTY_ACCESSORY_VALUES = Object.freeze({});
|
||||
|
||||
export default function RoverAccessoryControls({ roverId, headerAction = null, className = '' }) {
|
||||
const { peripherals } = useRoverAccessories(roverId);
|
||||
const canControl = useCanControlRover(roverId);
|
||||
const { setPeripheralControl } = useControlActions();
|
||||
const values = useControlSelector(
|
||||
(control) => control.state.peripheralValues?.[String(roverId)] || EMPTY_ACCESSORY_VALUES,
|
||||
);
|
||||
const send = useCallback(
|
||||
(peripheralId, controlId, value) => setPeripheralControl(peripheralId, controlId, value),
|
||||
[setPeripheralControl],
|
||||
);
|
||||
|
||||
if (peripherals.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`mobile-touch-control min-h-0 overflow-y-auto overscroll-contain text-slate-100 ${className}`.trim()}
|
||||
aria-label="Rover accessories"
|
||||
>
|
||||
{peripherals.map((peripheral, peripheralIndex) => {
|
||||
const showHeading = peripherals.length > 1 || (peripheralIndex === 0 && headerAction);
|
||||
return (
|
||||
<section key={peripheral.id} className="mb-0.5 last:mb-0">
|
||||
{/* The firmware's array order is authoritative. Mapping directly over
|
||||
it keeps physical authoring order intact across every UI host. */}
|
||||
{showHeading ? (
|
||||
<div className="mb-0.5 flex min-h-7 items-center gap-1 bg-black/60 px-1 text-xs font-semibold text-cyan-100">
|
||||
<h3 className="min-w-0 flex-1 truncate">{peripheral.name}</h3>
|
||||
{peripheralIndex === 0 ? headerAction : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex flex-col gap-0.5">
|
||||
{peripheral.controls.map((control) => (
|
||||
<AccessoryControlField
|
||||
key={control.id}
|
||||
peripheralId={peripheral.id}
|
||||
control={control}
|
||||
value={values[peripheral.id]?.[control.id]}
|
||||
disabled={!roverId || !canControl}
|
||||
send={send}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Rover Accessories Selector
|
||||
// Purpose: Provides the ordered generic-control inventory advertised by one rover.
|
||||
// Scope: Selects public roster metadata only; placement and visibility remain parent-UI decisions.
|
||||
import { useMemo } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
|
||||
export default function useRoverAccessories(roverId) {
|
||||
const rosterEntry = useSessionSelector((state) => {
|
||||
if (!roverId) return null;
|
||||
const roster = Array.isArray(state.session?.roster) ? state.session.roster : [];
|
||||
return roster.find((entry) => String(entry.id) === String(roverId)) || null;
|
||||
});
|
||||
const advertisedPeripherals = rosterEntry?.peripherals;
|
||||
|
||||
const peripherals = useMemo(() => {
|
||||
if (!Array.isArray(advertisedPeripherals)) return [];
|
||||
// A peripheral with no generic controls may still provide a standardized
|
||||
// camera, headlight, or laser backend. Those roles use their established
|
||||
// HUD controls and must not create an empty Accessories surface.
|
||||
return advertisedPeripherals.filter(
|
||||
(peripheral) => Array.isArray(peripheral?.controls) && peripheral.controls.length > 0,
|
||||
);
|
||||
}, [advertisedPeripherals]);
|
||||
|
||||
return {
|
||||
peripherals,
|
||||
hasAccessories: peripherals.length > 0,
|
||||
};
|
||||
}
|
||||
@@ -66,6 +66,7 @@ const CONTROL_ACTION_NAMES = [
|
||||
'sendSong',
|
||||
'startHorn',
|
||||
'stopHorn',
|
||||
'setPeripheralControl',
|
||||
'setMicPttActive',
|
||||
];
|
||||
|
||||
@@ -724,6 +725,29 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
|
||||
}, []);
|
||||
|
||||
const setPeripheralControl = useCallback(
|
||||
(peripheralId, controlId, value) => {
|
||||
const sent = pipeline.sendPeripheralControl(peripheralId, controlId, value);
|
||||
if (sent) {
|
||||
// The generic protocol is currently command-only. Recording the value
|
||||
// here lets every renderer instance share the browser's latest intent
|
||||
// without pretending that it is device-reported telemetry.
|
||||
dispatch({
|
||||
type: 'control/set-peripheral-value',
|
||||
payload: {
|
||||
roverId: pipeline.roverId,
|
||||
peripheralId,
|
||||
controlId,
|
||||
value,
|
||||
},
|
||||
});
|
||||
recordControlIntent();
|
||||
}
|
||||
return sent;
|
||||
},
|
||||
[pipeline, recordControlIntent],
|
||||
);
|
||||
|
||||
const actionImplementations = useMemo(
|
||||
() => ({
|
||||
setMode,
|
||||
@@ -751,6 +775,7 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setPeripheralControl,
|
||||
setMicPttActive,
|
||||
}),
|
||||
[
|
||||
@@ -779,6 +804,7 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setPeripheralControl,
|
||||
setMicPttActive,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -44,6 +44,11 @@ export function useCommandPipeline(options = {}) {
|
||||
return rosterEntry.horn;
|
||||
}, [rosterEntry]);
|
||||
|
||||
const peripherals = useMemo(
|
||||
() => (Array.isArray(rosterEntry?.peripherals) ? rosterEntry.peripherals : []),
|
||||
[rosterEntry],
|
||||
);
|
||||
|
||||
const headlightState = useMemo(() => rosterEntry?.headlight?.state ?? null, [rosterEntry]);
|
||||
const laserState = useMemo(() => rosterEntry?.laser?.state ?? null, [rosterEntry]);
|
||||
const emitCommand = useCallback(
|
||||
@@ -208,6 +213,22 @@ export function useCommandPipeline(options = {}) {
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
const sendPeripheralControl = useCallback(
|
||||
(peripheralId, controlId, value) => {
|
||||
if (!roverId || !peripheralId || !controlId) return null;
|
||||
const peripheral = { id: peripheralId, control: controlId, value };
|
||||
// Peripheral commands deliberately use the same command envelope as all
|
||||
// other rover actuation. This keeps turn authorization, acknowledgements,
|
||||
// and rover WebSocket routing in the server's existing command boundary.
|
||||
emitCommand({
|
||||
type: 'peripheral',
|
||||
data: { peripheral },
|
||||
});
|
||||
return peripheral;
|
||||
},
|
||||
[emitCommand, roverId],
|
||||
);
|
||||
|
||||
const sendSong = useCallback(
|
||||
(notes = [], options = {}) => {
|
||||
if (!roverId) return null;
|
||||
@@ -244,6 +265,7 @@ export function useCommandPipeline(options = {}) {
|
||||
laser,
|
||||
laserState,
|
||||
horn,
|
||||
peripherals,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
sendDriveDirect,
|
||||
@@ -253,6 +275,7 @@ export function useCommandPipeline(options = {}) {
|
||||
sendHeadlight,
|
||||
sendLaser,
|
||||
sendHorn,
|
||||
sendPeripheralControl,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
}),
|
||||
@@ -265,6 +288,7 @@ export function useCommandPipeline(options = {}) {
|
||||
laser,
|
||||
laserState,
|
||||
horn,
|
||||
peripherals,
|
||||
emitCommand,
|
||||
enableSensorStream,
|
||||
sendDriveDirect,
|
||||
@@ -274,6 +298,8 @@ export function useCommandPipeline(options = {}) {
|
||||
sendHeadlight,
|
||||
sendLaser,
|
||||
sendHorn,
|
||||
sendPeripheralControl,
|
||||
sendSong,
|
||||
runMacroSteps,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -69,6 +69,10 @@ export const initialControlState = {
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
inputs: {},
|
||||
// Generic accessory controls do not currently report state back from roverd.
|
||||
// Keep the last browser-issued value per rover/device/control so closing a
|
||||
// drawer or changing responsive layouts does not make the UI lie about it.
|
||||
peripheralValues: {},
|
||||
};
|
||||
|
||||
export function controlReducer(state, action) {
|
||||
@@ -226,6 +230,27 @@ export function controlReducer(state, action) {
|
||||
active: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
case 'control/set-peripheral-value': {
|
||||
const roverId = String(action.payload?.roverId || '');
|
||||
const peripheralId = String(action.payload?.peripheralId || '');
|
||||
const controlId = String(action.payload?.controlId || '');
|
||||
if (!roverId || !peripheralId || !controlId) return state;
|
||||
const roverValues = state.peripheralValues?.[roverId] || {};
|
||||
const peripheralValues = roverValues[peripheralId] || {};
|
||||
return {
|
||||
...state,
|
||||
peripheralValues: {
|
||||
...(state.peripheralValues || {}),
|
||||
[roverId]: {
|
||||
...roverValues,
|
||||
[peripheralId]: {
|
||||
...peripheralValues,
|
||||
[controlId]: action.payload.value,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user