colorcoding

This commit is contained in:
legop3
2026-03-23 03:39:36 -04:00
parent ad1b6b48f8
commit 20227cd0f8
27 changed files with 291 additions and 143 deletions
BIN
View File
Binary file not shown.
Vendored
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+1
View File
@@ -3,6 +3,7 @@ package roverd
type helloMessage struct {
Type string `json:"type"`
Name string `json:"name"`
Color string `json:"color,omitempty"`
Battery BatteryConfig `json:"battery"`
MaxWheelSpeed int `json:"maxWheelSpeed"`
Media MediaConfig `json:"media"`
+21
View File
@@ -5,6 +5,8 @@ import (
"fmt"
"net/url"
"os"
"regexp"
"strings"
"time"
"gopkg.in/yaml.v3"
@@ -123,6 +125,7 @@ type AutoSideBrushConfig struct {
type Config struct {
Name string `yaml:"name"`
Color string `yaml:"color" json:"color,omitempty"`
ServerURL string `yaml:"serverUrl"`
Serial SerialConfig `yaml:"serial"`
BRC BRCConfig `yaml:"brc"`
@@ -207,6 +210,11 @@ func LoadConfig(path string) (*Config, error) {
if cfg.Name == "" {
return nil, errors.New("missing name")
}
normalizedColor, err := normalizeHexColor(cfg.Color)
if err != nil {
return nil, err
}
cfg.Color = normalizedColor
if cfg.ServerURL == "" {
return nil, errors.New("missing serverUrl")
}
@@ -410,3 +418,16 @@ func deriveSRTURL(serverURL, streamName string, port int, mode string) (string,
escaped := url.PathEscape(streamName)
return fmt.Sprintf("srt://%s:%d?streamid=#!::r=%s,m=%s&latency=10&mode=caller&transtype=live&pkt_size=1316", host, port, escaped, mode), nil
}
var hexColorRe = regexp.MustCompile(`^#[0-9A-Fa-f]{6}$`)
func normalizeHexColor(raw string) (string, error) {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return "", nil
}
if !hexColorRe.MatchString(trimmed) {
return "", fmt.Errorf("color must be #RRGGBB, got %q", raw)
}
return strings.ToUpper(trimmed), nil
}
+1
View File
@@ -1,5 +1,6 @@
# Sample configuration for roverd
name: roomba-alpha
color: "#4DB6AC"
serverUrl: ws://control-server.local:8080/rover
serial:
device: /dev/ttyAMA0
+1
View File
@@ -113,6 +113,7 @@ func (c *WSClient) sendHello(ctx context.Context, conn *websocket.Conn) error {
msg := helloMessage{
Type: "hello",
Name: c.cfg.Name,
Color: c.cfg.Color,
Battery: c.cfg.Battery,
MaxWheelSpeed: c.cfg.MaxWheelMMs,
Media: c.cfg.Media,
+17 -1
View File
@@ -30,4 +30,20 @@ This means that locking / unlocking will act a little different than standard ro
- private rovers don't show in the list for normal users unless they are unlocked
- they will only show for lockdown admins
- when unlocked, they show for everyone
- with a different color in the rover list
- with a different color in the rover list
## . . .
this will be kind of invasive, touching a lot of systems server-side, long story short:
- private rovers are set as private in the roverd config
- by default:
- locked to only lockdown admins
- cant be spectated by anyone
- any user who isnt a lockdown admin cannot know that it exists in any way at all
- not included by most automated systems like LLM integration, discord alerts, etc
- still included in safties like auto docking
- limitations dont apply because its lockdown admin only anyway
- when opened up (can only be opened by lockdown admins):
- only verified users can drive them
- anyone can spectate them
- limits apply
- included in all automated systems just like a normal rover
+1 -1
View File
@@ -9,7 +9,7 @@
- audio files first [x]
- then voice chat [x]
8. mobile controls column swapping (optional joystick on left) [x]
9. fix fullscreen on mobile so that you can re-enter it
9. fix fullscreen on mobile so that you can re-enter it [x]
10. home assistant rover mute switch
# relative pipe dreams:
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
+2 -2
View File
@@ -11,8 +11,8 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-Dnr0s1G9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-r3OnudWV.css">
<script type="module" crossorigin src="/assets/index-BSallfbn.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-DFsFpeU2.css">
</head>
<body>
<div id="root"></div>
+10
View File
@@ -83,6 +83,12 @@ function resolveRoverId(socketId) {
return assignment?.roverId || null;
}
function resolveRoverColor(roverId) {
if (!roverId) return null;
const record = roverManager.rovers.get(String(roverId));
return record?.meta?.color || null;
}
function normalizeUserText(raw) {
if (typeof raw !== 'string') return '';
return raw.replace(/\\n/g, '\n');
@@ -90,6 +96,7 @@ function normalizeUserText(raw) {
function buildMessage(socket, text, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
return {
id: uuidv4(),
ts: Date.now(),
@@ -97,6 +104,7 @@ function buildMessage(socket, text, meta = {}) {
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord: Boolean(meta.fromDiscord),
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
@@ -219,6 +227,7 @@ function buildRoverCtxSnapshot(roverId) {
function buildTypingPayload(socket, meta = {}) {
const roverId = meta.roverId || resolveRoverId(socket?.id);
const roverColor = meta.roverColor ?? resolveRoverColor(roverId);
const socketId = socket?.id || null;
const fromDiscord = Boolean(meta.fromDiscord);
let typingId = meta.typingId || null;
@@ -250,6 +259,7 @@ function buildTypingPayload(socket, meta = {}) {
nickname: meta.nickname || getNickname(socket) || null,
role: meta.role || getRole(socket),
roverId,
roverColor,
fromDiscord,
discordGuildId: meta.discordGuildId || null,
discordGuildName: meta.discordGuildName || null,
@@ -6,6 +6,7 @@ function getReplaySources() {
type: 'rover',
id: String(rover.id),
label: rover.name || rover.id,
color: rover.color || null,
}));
const roomSources = getRoomCameras().map((camera) => ({
type: 'room',
+1
View File
@@ -145,6 +145,7 @@ function getRoster() {
return Array.from(rovers.values()).map((record) => ({
id: record.id,
name: record.meta?.name || record.id,
color: record.meta?.color || null,
battery: record.meta?.battery,
batteryState: record.batteryState,
maxWheelSpeed: record.meta?.maxWheelSpeed,
+7 -1
View File
@@ -1,4 +1,5 @@
import { FaDiscord } from 'react-icons/fa';
import { roverBadgeStyle } from '../lib/roverColor.js';
function roleColors(role) {
switch (role) {
@@ -88,7 +89,12 @@ export function ChatIdentity({ message }) {
</span>
) : null}
{message.roverId && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">{message.roverId}</span>
<span
className="rounded bg-slate-800 px-1 text-[0.7rem]"
style={roverBadgeStyle(message.roverColor, 0.14)}
>
{message.roverId}
</span>
)}
</>
);
@@ -120,6 +120,7 @@ export default function DriverVideoPanel({layoutFormat = 'desktop'}) {
snapshotFeed={snapshotFeed}
audioSessionInfo={audioInfo}
label={roverLabel}
roverColor={batteryRecord?.color || null}
telemetryFrame={frame}
batteryConfig={batteryConfig}
layoutFormat={layoutFormat}
@@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { roverSwatchStyle } from '../lib/roverColor.js';
function normalizeSources(list = []) {
return list
@@ -10,6 +11,7 @@ function normalizeSources(list = []) {
type: entry.type,
id: String(entry.id),
label: entry.label || String(entry.id),
color: entry.color || null,
key: `${entry.type}:${entry.id}`,
};
})
@@ -143,6 +145,13 @@ function GroupList({ title, items, selected, onToggle }) {
onChange={() => onToggle(item.key)}
className="accent-emerald-400"
/>
{item.type === 'rover' && item.color ? (
<span
className="inline-block h-2 w-2 rounded-full border border-white/30"
style={roverSwatchStyle(item.color)}
aria-hidden="true"
/>
) : null}
<span className="truncate">{item.label}</span>
</label>
))}
+4 -1
View File
@@ -1,5 +1,6 @@
import { useEffect, useMemo, useState } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import { roverNameStyle } from '../lib/roverColor.js';
function classNames(...values) {
return values.filter(Boolean).join(' ');
@@ -131,7 +132,9 @@ export default function RoverQueuesPanel({ title = 'Rovers' }) {
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-center justify-between gap-0.5">
<div className="flex items-center gap-0.5">
<p className="text-slate-200">{rover.name}</p>
<p className="text-slate-200" style={roverNameStyle(rover.color)}>
{rover.name}
</p>
{showTimer ? (
<span className="rounded bg-slate-800 px-1 text-[0.7rem] text-slate-200">
{isSelfCurrent ? `${remainingSeconds}s left` : `Your turn in ${remainingSeconds}s`}
+5 -1
View File
@@ -1,3 +1,5 @@
import { roverNameStyle } from '../lib/roverColor.js';
function classNames(...values) {
return values.filter(Boolean).join(' ');
}
@@ -37,7 +39,9 @@ export default function RoverRoster({
)}
>
<div>
<p className="text-slate-200">{rover.name}</p>
<p className="text-slate-200" style={roverNameStyle(rover.color)}>
{rover.name}
</p>
<p className="text-xs text-slate-500 flex flex-wrap items-center gap-0.5">
<span>{rover.locked ? 'locked' : 'free'}</span>
{rover.lockReason && <span className="rounded bg-black/30 px-1">{rover.lockReason}</span>}
+12 -4
View File
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState, useCallback } from 'react';
import { useSession } from '../context/SessionContext.jsx';
import NicknameForm from './NicknameForm.jsx';
import SocialButtonsGrid from './SocialButtonsGrid.jsx';
import { roverBadgeStyle, roverNameStyle } from '../lib/roverColor.js';
export function NicknameEntryPanel({ compact = false }) {
return (
@@ -75,8 +76,8 @@ export default function UserListPanel({
[selfId, users],
);
const rosterName = useCallback(
(roverId) => roster.find((r) => String(r.id) === String(roverId))?.name || roverId,
const rosterEntry = useCallback(
(roverId) => roster.find((r) => String(r.id) === String(roverId)) || null,
[roster],
);
@@ -124,7 +125,12 @@ export default function UserListPanel({
>
<p className={`font-semibold ${roleColors(user.role)}`}>{formatLabel(user, selfId)}</p>
{user.roverId ? (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">rover {user.roverId}</span>
<span
className="rounded bg-slate-800 px-1 text-[0.7rem]"
style={roverBadgeStyle(rosterEntry(user.roverId)?.color, 0.12)}
>
rover {rosterEntry(user.roverId)?.name || user.roverId}
</span>
) : (
<span className="text-[0.7rem] text-slate-500">no rover</span>
)}
@@ -211,7 +217,9 @@ export default function UserListPanel({
return (
<div key={roverId} className={`surface-muted flex flex-col gap-0.5 ${compact ? 'text-[0.8rem] py-0.25' : 'text-sm'}`}>
<div className="flex items-center gap-0.5">
<p className="font-semibold text-slate-200">{rosterName(roverId)}</p>
<p className="font-semibold text-slate-200" style={roverNameStyle(rosterEntry(roverId)?.color)}>
{rosterEntry(roverId)?.name || roverId}
</p>
{remaining != null && (
<span className="rounded bg-slate-800 px-1 text-[0.7rem]">
{remaining}s left
+10 -2
View File
@@ -9,6 +9,7 @@ import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import SocialButton from './SocialButton.jsx';
import BatteryBar from './BatteryBar.jsx';
import { buildBatteryVisual } from '../lib/battery.js';
import { roverNameStyle } from '../lib/roverColor.js';
const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000;
@@ -23,6 +24,7 @@ export default function VideoTile({
snapshotFeed = null,
qualityNotice = null,
label,
roverColor = null,
forceMute = false,
telemetryFrame,
batteryConfig,
@@ -606,6 +608,7 @@ export default function VideoTile({
frame={telemetryFrame}
sensors={sensors}
label={label}
roverColor={roverColor}
status={renderedStatus}
audioStatus={renderedAudioStatus}
levelStatus={levelIndicator}
@@ -721,6 +724,7 @@ function HudOverlay({
frame,
sensors,
label,
roverColor = null,
status,
audioStatus,
levelStatus,
@@ -845,7 +849,9 @@ function HudOverlay({
<div
className={`flex items-center gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}
>
<span className="font-semibold text-white">{label || 'Unnamed Rover'}</span>
<span className="font-semibold text-white" style={roverNameStyle(roverColor)}>
{label || 'Unnamed Rover'}
</span>
{driverLabel ? <span className="text-slate-300"> {driverLabel}</span> : null}
</div>
</div>
@@ -882,7 +888,9 @@ function HudOverlay({
) : null}
<div className={`absolute ${labelPosClass} left-1/2`} style={labelWrapperStyle}>
<div className={`flex gap-0.5 bg-black/80 text-slate-100 ${labelPadClass} ${labelTextClass}`}>
<span>Rover: "{label || 'Unnamed Rover'}"</span>
<span>
Rover: "<span style={roverNameStyle(roverColor)}>{label || 'Unnamed Rover'}</span>"
</span>
{/* <span>{pulse ? 'Sensors active' : 'No recent sensors'}</span> */}
</div>
</div>
+42
View File
@@ -0,0 +1,42 @@
const HEX_RE = /^#[0-9A-Fa-f]{6}$/;
export function normalizeRoverColor(value) {
const raw = typeof value === 'string' ? value.trim() : '';
if (!raw || !HEX_RE.test(raw)) return null;
return raw.toUpperCase();
}
export function roverNameStyle(color) {
const normalized = normalizeRoverColor(color);
if (!normalized) return undefined;
return { color: normalized };
}
export function roverBadgeStyle(color, alpha = 0.2) {
const normalized = normalizeRoverColor(color);
if (!normalized) return undefined;
const safeAlpha = Number.isFinite(alpha) ? Math.max(0, Math.min(1, alpha)) : 0.2;
const rgb = hexToRgb(normalized);
if (!rgb) return undefined;
return {
borderColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, 0.55)`,
backgroundColor: `rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${safeAlpha})`,
color: normalized,
};
}
export function roverSwatchStyle(color) {
const normalized = normalizeRoverColor(color);
if (!normalized) return undefined;
return { backgroundColor: normalized };
}
function hexToRgb(hex) {
const normalized = normalizeRoverColor(hex);
if (!normalized) return null;
return {
r: parseInt(normalized.slice(1, 3), 16),
g: parseInt(normalized.slice(3, 5), 16),
b: parseInt(normalized.slice(5, 7), 16),
};
}
+18 -4
View File
@@ -10,6 +10,7 @@ import AlertFeed from '../components/AlertFeed.jsx';
import useDefaultNickname from '../hooks/useDefaultNickname.js';
import BatteryBar from '../components/BatteryBar.jsx';
import { buildBatteryVisual } from '../lib/battery.js';
import { roverNameStyle } from '../lib/roverColor.js';
const ROTATE_MS = 20000;
const HARD_REFRESH_MS = 1 * 60 * 60 * 1000;
@@ -57,7 +58,12 @@ function InfoColumn({
{isActiveView ? (
<div className="relative z-10 flex min-w-0 flex-1 flex-col justify-between text-center">
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={18}>
<AutoFitText
className="font-semibold leading-none text-white"
maxSize={1000}
minSize={18}
style={roverNameStyle(rover.color)}
>
{rover.name || rover.id}
</AutoFitText>
</div>
@@ -82,7 +88,12 @@ function InfoColumn({
<>
<div className="relative z-10 flex min-w-0 flex-col gap-1 text-center">
<div className="min-w-0 bg-transparent px-0 py-0 leading-none">
<AutoFitText className="font-semibold leading-none text-white" maxSize={1000} minSize={18}>
<AutoFitText
className="font-semibold leading-none text-white"
maxSize={1000}
minSize={18}
style={roverNameStyle(rover.color)}
>
{rover.name || rover.id}
</AutoFitText>
</div>
@@ -113,6 +124,7 @@ function InfoColumn({
snapshotFeed={snapshotFeed}
audioSessionInfo={null}
label={rover.name || rover.id}
roverColor={rover.color || null}
telemetryFrame={frame}
batteryConfig={rover.battery}
layoutFormat="mobile"
@@ -130,7 +142,7 @@ function InfoColumn({
);
}
function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14 }) {
function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14, style = undefined }) {
const containerRef = useRef(null);
const textRef = useRef(null);
const [fontSize, setFontSize] = useState(maxSize);
@@ -183,7 +195,7 @@ function AutoFitText({ children, className = '', maxSize = 1000, minSize = 14 })
<div
ref={textRef}
className={`whitespace-nowrap ${className}`}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1 }}
style={{ fontSize: `${fontSize}px`, lineHeight: 1.1, ...(style || {}) }}
>
{children}
</div>
@@ -344,6 +356,7 @@ function MiniSummaryContent() {
audioSessionInfo={isActive ? activeAudio : null}
forceMute={!isActive}
label={rover.name || rover.id}
roverColor={rover.color || null}
telemetryFrame={frames[rover.id] || null}
batteryConfig={rover.battery}
layoutFormat="mobile"
@@ -361,6 +374,7 @@ function MiniSummaryContent() {
snapshotFeed={activeSnapshot}
audioSessionInfo={activeAudio}
label={activeRover.name || activeRover.id}
roverColor={activeRover.color || null}
telemetryFrame={activeFrame}
batteryConfig={activeRover.battery}
layoutFormat="mobile"
+1
View File
@@ -63,6 +63,7 @@ function RoverSpectatorCard({ rover, frame, sessionInfo, videoMode, snapshotFeed
snapshotFeed={snapshotFeed}
audioSessionInfo={audioInfo}
label={rover.name}
roverColor={rover.color || null}
telemetryFrame={frame}
batteryConfig={rover.battery}
hudVariant="spectator"