sensor stream throttle and new roster panel

This commit is contained in:
legop3
2025-11-20 14:55:59 -05:00
parent ff0bf30eac
commit bfdfa33b62
10 changed files with 111 additions and 69 deletions
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+8
View File
@@ -14,6 +14,7 @@ import (
const ( const (
sensorHeader = 19 sensorHeader = 19
sensorReadTimeout = 150 * time.Millisecond sensorReadTimeout = 150 * time.Millisecond
sensorThrottleMinimum = 80 * time.Millisecond
) )
type SensorStreamer struct { type SensorStreamer struct {
@@ -29,6 +30,7 @@ func NewSensorStreamer(r io.Reader, rawOut chan<- []byte, parsed chan<- SensorSa
func (s *SensorStreamer) Run(ctx context.Context) { func (s *SensorStreamer) Run(ctx context.Context) {
reader := bufio.NewReader(s.r) reader := bufio.NewReader(s.r)
var nextSend time.Time
for { for {
select { select {
case <-ctx.Done(): case <-ctx.Done():
@@ -62,6 +64,12 @@ func (s *SensorStreamer) Run(ctx context.Context) {
continue continue
} }
now := time.Now()
if !nextSend.IsZero() && now.Before(nextSend) {
continue
}
nextSend = now.Add(sensorThrottleMinimum)
select { select {
case s.rawOut <- frame: case s.rawOut <- frame:
default: default:
+2 -2
View File
@@ -10,9 +10,9 @@
- video does not load in spectator page in admin mode - video does not load in spectator page in admin mode
## todo ## todo
1. battery manager <!-- 1. battery manager -->
2. pi-side sensor throttle (1/5th) 2. pi-side sensor throttle (1/5th)
3. convert rover roster to a reusable component, unbake it from telemetry and admin panels <!-- 3. convert rover roster to a reusable component, unbake it from telemetry and admin panels -->
4. room cameras 4. room cameras
5. home assistant controls 5. home assistant controls
6. nicknames 6. nicknames
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/vite.svg" /> <link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>webui</title> <title>webui</title>
<script type="module" crossorigin src="/assets/index-tDRd6DNO.js"></script> <script type="module" crossorigin src="/assets/index-BqC-rKak.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DP0zs3Oi.css"> <link rel="stylesheet" crossorigin href="/assets/index-C4U-e7FZ.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+7 -15
View File
@@ -1,5 +1,6 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import RoverRoster from './RoverRoster.jsx';
const MODES = [ const MODES = [
{ key: 'open', label: 'Open' }, { key: 'open', label: 'Open' },
@@ -69,13 +70,9 @@ export default function AdminPanel() {
</select> </select>
</div> </div>
<div className="space-y-0.5 text-sm"> <RoverRoster
{roster.map((rover) => ( roster={roster}
<div key={rover.id} className="surface flex flex-wrap items-center justify-between gap-0.5"> renderActions={(rover) => (
<div>
<p className="text-slate-100">{rover.name}</p>
<p className="text-xs text-slate-400">{lockMap[rover.id] ? 'locked' : 'unlocked'}</p>
</div>
<div className="flex flex-wrap gap-0.5 text-xs"> <div className="flex flex-wrap gap-0.5 text-xs">
<button <button
type="button" type="button"
@@ -84,17 +81,12 @@ export default function AdminPanel() {
> >
{lockMap[rover.id] ? 'Unlock' : 'Lock'} {lockMap[rover.id] ? 'Unlock' : 'Lock'}
</button> </button>
<button <button type="button" onClick={() => handleForceControl(rover.id)} className="button-dark">
type="button"
onClick={() => handleForceControl(rover.id)}
className="button-dark"
>
Force Force
</button> </button>
</div> </div>
</div> )}
))} />
</div>
</section> </section>
); );
} }
+56
View File
@@ -0,0 +1,56 @@
function classNames(...values) {
return values.filter(Boolean).join(' ');
}
function formatBattery(rover) {
const percent = rover?.batteryState?.percentDisplay;
if (percent == null) return '--';
return `${percent}%`;
}
function batteryClass(rover) {
if (!rover?.batteryState) return 'text-slate-400';
if (rover.batteryState.urgentActive) return 'text-red-400';
if (rover.batteryState.warnActive) return 'text-amber-300';
return 'text-emerald-300';
}
export default function RoverRoster({
title,
roster = [],
emptyText = 'No roster data.',
renderActions = null,
}) {
return (
<div className="space-y-0.5 text-sm">
{title && <p className="text-sm text-slate-400">{title}</p>}
{roster.length === 0 ? (
<p className="text-sm text-slate-500">{emptyText}</p>
) : (
<ul className="space-y-0.5 text-sm">
{roster.map((rover) => (
<li
key={rover.id}
className={classNames(
'surface flex flex-wrap items-center justify-between gap-0.5',
rover.locked && 'bg-red-900/40',
)}
>
<div>
<p className="text-slate-200">{rover.name}</p>
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-1">
<span>{rover.locked ? 'locked' : 'free'}</span>
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
<span className={classNames('font-semibold', batteryClass(rover))}>
Battery {formatBattery(rover)}
</span>
</p>
</div>
{renderActions ? renderActions(rover) : null}
</li>
))}
</ul>
)}
</div>
);
}
+9 -23
View File
@@ -1,6 +1,7 @@
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrame } from '../context/TelemetryContext.jsx'; import { useTelemetryFrame } from '../context/TelemetryContext.jsx';
import RoverRoster from './RoverRoster.jsx';
function formatMetric(value, fallback = '--') { function formatMetric(value, fallback = '--') {
if (value == null || value === '') return fallback; if (value == null || value === '') return fallback;
@@ -97,23 +98,11 @@ export default function TelemetryPanel() {
{rawSnippet} {rawSnippet}
</pre> </pre>
)} )}
<div className="space-y-0.5"> <RoverRoster
<p className="text-sm text-slate-400">Rovers</p> title="Rovers"
{roster.length === 0 ? ( roster={roster}
// if there ARE NO rovers renderActions={(rover) =>
<p className="text-sm text-slate-500">No roster data.</p> isAdmin ? (
) : (
// if there ARE rovers
<ul className="space-y-0.5 text-sm">
{roster.map((rover) => (
<li key={rover.id} className="surface flex items-center justify-between gap-0.5">
<div>
<p className="text-slate-200">{rover.name}</p>
<p className="text-xs text-slate-500">
{rover.locked ? 'locked' : 'free'} · {rover.lastSeen ? 'seen' : 'unknown'}
</p>
</div>
{isAdmin && (
<button <button
type="button" type="button"
onClick={() => handleRequest(rover.id)} onClick={() => handleRequest(rover.id)}
@@ -122,12 +111,9 @@ export default function TelemetryPanel() {
> >
{pending[rover.id] ? '...' : 'request'} {pending[rover.id] ? '...' : 'request'}
</button> </button>
)} ) : null
</li> }
))} />
</ul>
)}
</div>
</section> </section>
); );
} }