Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 93 additions & 3 deletions src/components/RotatorPanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,14 @@ export default function RotatorPanel({
onStop,
controlsEnabled = true,
}) {
const { azimuth, lastGoodAzimuth, isStale } = state ?? useRotator({ endpointUrl, pollMs, staleMs });
const {
azimuth,
lastGoodAzimuth,
isStale,
status = 'disconnected',
lastError = null,
reconnect,
} = state ?? useRotator({ endpointUrl, pollMs, staleMs });

const displayAngleRef = useRef(null); // continuous angle
const prevAzRef = useRef(null);
Expand All @@ -47,10 +54,11 @@ export default function RotatorPanel({
}, [lastGoodAzimuth, azimuth]);

const bearingText = useMemo(() => {
if (status !== 'connected') return '---';
if (azimuth == null || Number.isNaN(azimuth)) return '--';
const a = ((Math.round(azimuth) % 360) + 360) % 360;
return String(a).padStart(3, '0');
}, [azimuth]);
}, [azimuth, status]);

const [showCompass, setShowCompass] = React.useState(() => {
try {
Expand All @@ -75,6 +83,41 @@ export default function RotatorPanel({
<div className="ohc-rotator-panel">
<div className="ohc-rotator-header">
<div className="ohc-rotator-meta">
<div
className="ohc-rotator-conn"
title={
status === 'connected'
? 'Rotator connected'
: status === 'connecting'
? 'Rotator connecting…'
: lastError
? `Rotator disconnected: ${lastError}`
: 'Rotator disconnected'
}
aria-label="Rotator connection status"
>
<span className={`ohc-led ohc-led--${status}`} />

{status !== 'connected' && (
<button
onClick={() => reconnect?.()}
disabled={typeof reconnect !== 'function'}
style={{
padding: '4px 10px',
borderRadius: 999,
fontSize: 11,
fontFamily: 'JetBrains Mono',
cursor: typeof reconnect === 'function' ? 'pointer' : 'not-allowed',
background: 'rgba(0,0,0,0.35)',
border: '1px solid rgba(255,255,255,0.18)',
color: 'rgba(255,255,255,0.75)',
}}
title="Reconnect to rotator"
>
RECONNECT
</button>
)}
</div>
<button
onClick={toggleCompass}
style={{
Expand Down Expand Up @@ -200,7 +243,11 @@ export default function RotatorPanel({
)}
<div className="ohc-rotator-body">
{showCompass && (
<Compass azimuth={azimuth ?? lastGoodAzimuth ?? 0} displayAngle={displayAngle} isStale={isStale} />
<Compass
azimuth={azimuth ?? lastGoodAzimuth}
displayAngle={displayAngle}
isStale={isStale || status !== 'connected'}
/>
)}

<div
Expand Down Expand Up @@ -257,6 +304,7 @@ export default function RotatorPanel({
}

function Compass({ azimuth, displayAngle, isStale }) {
const safeAngle = azimuth == null ? null : azimuth;
return (
<div className={'ohc-compass ' + (isStale ? 'ohc-compass--stale' : '')}>
<svg viewBox="0 0 200 200" className="ohc-compass-svg" aria-label="Compass">
Expand Down Expand Up @@ -307,6 +355,7 @@ function Compass({ azimuth, displayAngle, isStale }) {
style={{
transform: displayAngle == null ? 'none' : `rotate(${displayAngle}deg)`,
transition: 'transform 250ms linear',
opacity: safeAngle == null ? 0.25 : 1,
}}
>
{/* Needle body */}
Expand Down Expand Up @@ -375,6 +424,47 @@ function AzGoto({ disabled, onGo }) {
);
}
const css = `
.ohc-rotator-conn{
display:flex;
align-items:center;
gap:10px;
}

.ohc-led{
width:10px;
height:10px;
border-radius:999px;
display:inline-block;
box-shadow: 0 0 0 1px rgba(255,255,255,0.12) inset;
}

.ohc-led--connected{
background: rgba(0, 255, 140, 0.85);
box-shadow:
0 0 0 1px rgba(0, 255, 140, 0.35) inset,
0 0 10px rgba(0, 255, 140, 0.22);
}

.ohc-led--disconnected{
background: rgba(255, 70, 70, 0.75);
box-shadow:
0 0 0 1px rgba(255, 70, 70, 0.28) inset,
0 0 10px rgba(255, 70, 70, 0.10);
}

.ohc-led--connecting{
background: rgba(255, 205, 60, 0.85);
box-shadow:
0 0 0 1px rgba(255, 205, 60, 0.30) inset,
0 0 10px rgba(255, 205, 60, 0.12);
animation: ohcPulse 900ms ease-in-out infinite;
}

@keyframes ohcPulse{
0%, 100% { transform: scale(1); opacity: 0.85; }
50% { transform: scale(1.25); opacity: 1; }
}

.ohc-rotator-panel{
height:100%;
width:100%;
Expand Down
83 changes: 68 additions & 15 deletions src/hooks/useRotator.js
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

/**
* useRotator (V2)
* - Polls endpointUrl for rotator status
* - Auto-disables polling if server reports source === 'none'
* - If server reports source === 'none', pauses polling briefly (but does NOT disable forever)
* - mock mode: smooth rotating azimuth for UI dev
*
* Return:
Expand All @@ -13,16 +13,22 @@ import { useEffect, useMemo, useRef, useState } from 'react';
* isStale: boolean
* ageMs: number
* available: boolean — whether a rotator is configured server-side
* status: 'connected' | 'connecting' | 'disconnected'
* lastError: string | null
* reconnect: () => void — clears any temporary disable and forces an immediate poll
*/
export default function useRotator({ endpointUrl, pollMs = 2000, staleMs = 5000, mock = false } = {}) {
const [azimuth, setAzimuth] = useState(null);
const [lastGoodAzimuth, setLastGoodAzimuth] = useState(null);
const [source, setSource] = useState(mock ? 'mock' : 'unknown');
const [lastUpdate, setLastUpdate] = useState(0);
const [available, setAvailable] = useState(false);
const [live, setLive] = useState(false);
const [lastError, setLastError] = useState(null);

const timerRef = useRef(null);
const disabledRef = useRef(false); // sticky: once server says 'none', stop forever
const noneUntilRef = useRef(0); // when server says source==='none', pause polling until this time
const pollRef = useRef(null);

const ageMs = useMemo(() => {
if (!lastUpdate) return Number.POSITIVE_INFINITY;
Expand All @@ -34,6 +40,28 @@ export default function useRotator({ endpointUrl, pollMs = 2000, staleMs = 5000,
return Date.now() - lastUpdate > staleMs;
}, [lastUpdate, staleMs]);

const status = useMemo(() => {
if (!endpointUrl && !mock) return 'disconnected';

if (mock) return 'connected';

// Explicit provider-down state
if (live === false) return 'disconnected';

// If we’ve never successfully connected yet
if (!lastUpdate) return 'connecting';

if (isStale) return 'disconnected';

return 'connected';
}, [endpointUrl, mock, live, lastUpdate, isStale]);

const reconnect = useCallback(() => {
noneUntilRef.current = 0;
setLastError(null);
pollRef.current?.();
}, []);

useEffect(() => {
if (timerRef.current) {
clearInterval(timerRef.current);
Expand Down Expand Up @@ -66,24 +94,40 @@ export default function useRotator({ endpointUrl, pollMs = 2000, staleMs = 5000,
if (!endpointUrl) return;

async function poll() {
// Once server told us 'none', stop polling entirely
if (disabledRef.current) return;
if (noneUntilRef.current && Date.now() < noneUntilRef.current) return;

try {
const res = await fetch(endpointUrl, { cache: 'no-store' });
if (!res.ok) throw new Error(`HTTP ${res.status}`);

const data = await res.json();

// If server says rotator provider is 'none', stop all future polling
if (typeof data?.live === 'boolean') setLive(data.live);
if (data?.source === 'none') {
disabledRef.current = true;
setSource('none');
setAvailable(false);
if (timerRef.current) {
clearInterval(timerRef.current);
timerRef.current = null;
}
setLastError(null);

setLastUpdate(0);
setAzimuth(null);
setLastGoodAzimuth(null);

noneUntilRef.current = Date.now() + 30_000;
return;
}

if (data?.live === false) {
// Provider configured, but not currently connected/running
setAvailable(true); // provider exists
setSource(String(data?.source ?? 'unknown'));
setLastError(data?.error ? String(data.error) : null);

// Mark as disconnected immediately
setLastUpdate(0);
setAzimuth(null);
setLastGoodAzimuth(null);

// Retry soon (no need to wait 30s here)
noneUntilRef.current = Date.now() + 2000;
return;
}

Expand All @@ -96,23 +140,32 @@ export default function useRotator({ endpointUrl, pollMs = 2000, staleMs = 5000,
}

const ts = Number(data?.lastSeen);
setLastUpdate(Number.isFinite(ts) && ts > 0 ? ts : Date.now());
if (Number.isFinite(ts) && ts > 0) {
setLastUpdate(ts);
}

if (data?.source) setSource(String(data.source));

setLastError(null);
} catch {
// Keep last value; staleness will indicate trouble
setLastError('Unable to reach rotator service');
}
}

// Expose poll for reconnect()
pollRef.current = poll;

// Initial poll
poll();

// Poll at a reasonable interval (default 2s, minimum 1s)
timerRef.current = setInterval(poll, Math.max(1000, pollMs));

return () => {
if (timerRef.current) clearInterval(timerRef.current);
pollRef.current = null;
};
}, [endpointUrl, pollMs, staleMs, mock]);

return { azimuth, lastGoodAzimuth, source, isStale, ageMs, available };
return { azimuth, lastGoodAzimuth, source, isStale, ageMs, available, live, status, lastError, reconnect };
}