mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-04 20:43:03 +02:00
Merge pull request #177 from YourSandwich/feature/battery-status
Add optional battery display to status bar
This commit is contained in:
@@ -1,10 +1,24 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Menu, Moon, Sun } from 'lucide-react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BatteryFull,
|
||||
BatteryLow,
|
||||
BatteryMedium,
|
||||
BatteryWarning,
|
||||
Menu,
|
||||
Moon,
|
||||
Sun,
|
||||
} from 'lucide-react';
|
||||
import type { HealthStatus, RadioConfig } from '../types';
|
||||
import { api } from '../api';
|
||||
import { toast } from './ui/sonner';
|
||||
import { handleKeyboardActivate } from '../utils/a11y';
|
||||
import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme';
|
||||
import {
|
||||
BATTERY_DISPLAY_CHANGE_EVENT,
|
||||
getShowBatteryPercent,
|
||||
getShowBatteryVoltage,
|
||||
mvToPercent,
|
||||
} from '../utils/batteryDisplay';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface StatusBarProps {
|
||||
@@ -22,6 +36,35 @@ export function StatusBar({
|
||||
onSettingsClick,
|
||||
onMenuClick,
|
||||
}: StatusBarProps) {
|
||||
const [showBatteryPercent, setShowBatteryPercent] = useState(getShowBatteryPercent);
|
||||
const [showBatteryVoltage, setShowBatteryVoltage] = useState(getShowBatteryVoltage);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
setShowBatteryPercent(getShowBatteryPercent());
|
||||
setShowBatteryVoltage(getShowBatteryVoltage());
|
||||
};
|
||||
window.addEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
|
||||
return () => window.removeEventListener(BATTERY_DISPLAY_CHANGE_EVENT, handler);
|
||||
}, []);
|
||||
|
||||
const batteryMv = health?.radio_stats?.battery_mv;
|
||||
const batteryInfo = useMemo(() => {
|
||||
if ((!showBatteryPercent && !showBatteryVoltage) || !batteryMv || batteryMv <= 0) return null;
|
||||
const pct = mvToPercent(batteryMv);
|
||||
const Icon =
|
||||
pct >= 80 ? BatteryFull : pct >= 40 ? BatteryMedium : pct >= 15 ? BatteryLow : BatteryWarning;
|
||||
const color =
|
||||
pct >= 40 ? 'text-status-connected' : pct >= 15 ? 'text-warning' : 'text-destructive';
|
||||
const label =
|
||||
showBatteryPercent && showBatteryVoltage
|
||||
? `${pct}% (${batteryMv}mV)`
|
||||
: showBatteryPercent
|
||||
? `${pct}%`
|
||||
: `${batteryMv}mV`;
|
||||
return { pct, Icon, color, label, mv: batteryMv };
|
||||
}, [batteryMv, showBatteryPercent, showBatteryVoltage]);
|
||||
|
||||
const radioState =
|
||||
health?.radio_state ??
|
||||
(health?.radio_initializing
|
||||
@@ -119,6 +162,18 @@ export function StatusBar({
|
||||
<span className="hidden lg:inline text-muted-foreground">{statusLabel}</span>
|
||||
</div>
|
||||
|
||||
{connected && batteryInfo && (
|
||||
<div
|
||||
className={cn('flex items-center gap-1', batteryInfo.color)}
|
||||
title={`Battery: ${batteryInfo.pct}% (${(batteryInfo.mv / 1000).toFixed(2)}V)`}
|
||||
role="status"
|
||||
aria-label={`Battery ${batteryInfo.pct} percent`}
|
||||
>
|
||||
<batteryInfo.Icon className="h-4 w-4" aria-hidden="true" />
|
||||
<span className="hidden sm:inline text-[0.6875rem]">{batteryInfo.label}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{config && (
|
||||
<div className="hidden lg:flex items-center gap-2 text-muted-foreground">
|
||||
<span className="text-foreground font-medium">{config.name || 'Unnamed'}</span>
|
||||
|
||||
@@ -28,6 +28,13 @@ import {
|
||||
setSavedFontScale,
|
||||
} from '../../utils/fontScale';
|
||||
import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput';
|
||||
import {
|
||||
BATTERY_DISPLAY_CHANGE_EVENT,
|
||||
getShowBatteryPercent,
|
||||
setShowBatteryPercent as saveBatteryPercent,
|
||||
getShowBatteryVoltage,
|
||||
setShowBatteryVoltage as saveBatteryVoltage,
|
||||
} from '../../utils/batteryDisplay';
|
||||
|
||||
export function SettingsLocalSection({
|
||||
onLocalLabelChange,
|
||||
@@ -50,6 +57,8 @@ export function SettingsLocalSection({
|
||||
const [localLabelText, setLocalLabelText] = useState(() => getLocalLabel().text);
|
||||
const [localLabelColor, setLocalLabelColor] = useState(() => getLocalLabel().color);
|
||||
const [autoFocusInput, setAutoFocusInput] = useState(getAutoFocusInputEnabled);
|
||||
const [batteryPercent, setBatteryPercent] = useState(getShowBatteryPercent);
|
||||
const [batteryVoltage, setBatteryVoltage] = useState(getShowBatteryVoltage);
|
||||
const [fontScale, setFontScale] = useState(getSavedFontScale);
|
||||
const [fontScaleSlider, setFontScaleSlider] = useState(getSavedFontScale);
|
||||
const [fontScaleInput, setFontScaleInput] = useState(() => String(getSavedFontScale()));
|
||||
@@ -201,6 +210,43 @@ export function SettingsLocalSection({
|
||||
<span className="text-sm">Auto-focus input on conversation load (desktop only)</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batteryPercent}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setBatteryPercent(v);
|
||||
saveBatteryPercent(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Show battery percentage in status bar</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={batteryVoltage}
|
||||
onChange={(e) => {
|
||||
const v = e.target.checked;
|
||||
setBatteryVoltage(v);
|
||||
saveBatteryVoltage(v);
|
||||
window.dispatchEvent(new Event(BATTERY_DISPLAY_CHANGE_EVENT));
|
||||
}}
|
||||
className="w-4 h-4 rounded border-input accent-primary"
|
||||
/>
|
||||
<span className="text-sm">Show battery voltage in status bar</span>
|
||||
</label>
|
||||
|
||||
{(batteryPercent || batteryVoltage) && (
|
||||
<p className="text-xs text-muted-foreground ml-7">
|
||||
Battery data updates every 60 seconds and may take up to a minute to appear after
|
||||
connecting.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="font-scale-input">Relative Font Size</Label>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
|
||||
44
frontend/src/test/batteryDisplay.test.ts
Normal file
44
frontend/src/test/batteryDisplay.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { mvToPercent, formatBatteryLabel } from '../utils/batteryDisplay';
|
||||
|
||||
describe('mvToPercent', () => {
|
||||
it('clamps to 100 above table ceiling', () => {
|
||||
expect(mvToPercent(4500)).toBe(100);
|
||||
expect(mvToPercent(4190)).toBe(100);
|
||||
});
|
||||
|
||||
it('clamps to 0 below table floor', () => {
|
||||
expect(mvToPercent(3100)).toBe(0);
|
||||
expect(mvToPercent(2800)).toBe(0);
|
||||
});
|
||||
|
||||
it('returns exact table values at boundaries', () => {
|
||||
expect(mvToPercent(4050)).toBe(90);
|
||||
expect(mvToPercent(3630)).toBe(40);
|
||||
});
|
||||
|
||||
it('interpolates between table entries', () => {
|
||||
// Midpoint between 3630 (40%) and 3720 (50%) = 3675 → ~45%
|
||||
const mid = mvToPercent(3675);
|
||||
expect(mid).toBeGreaterThan(40);
|
||||
expect(mid).toBeLessThan(50);
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatBatteryLabel', () => {
|
||||
it('returns null when both toggles are off', () => {
|
||||
expect(formatBatteryLabel(4050, false, false)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns percentage only', () => {
|
||||
expect(formatBatteryLabel(4050, true, false)).toBe('90%');
|
||||
});
|
||||
|
||||
it('returns voltage only', () => {
|
||||
expect(formatBatteryLabel(4050, false, true)).toBe('4050mV');
|
||||
});
|
||||
|
||||
it('returns combined when both enabled', () => {
|
||||
expect(formatBatteryLabel(4050, true, true)).toBe('90% (4050mV)');
|
||||
});
|
||||
});
|
||||
83
frontend/src/utils/batteryDisplay.ts
Normal file
83
frontend/src/utils/batteryDisplay.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
export const BATTERY_DISPLAY_CHANGE_EVENT = 'remoteterm-battery-display-change';
|
||||
|
||||
// Meshtastic default OCV table (meshtastic/firmware src/power.h)
|
||||
const OCV_TABLE: [number, number][] = [
|
||||
[4190, 100],
|
||||
[4050, 90],
|
||||
[3990, 80],
|
||||
[3890, 70],
|
||||
[3800, 60],
|
||||
[3720, 50],
|
||||
[3630, 40],
|
||||
[3530, 30],
|
||||
[3420, 20],
|
||||
[3300, 10],
|
||||
[3100, 0],
|
||||
];
|
||||
|
||||
export function mvToPercent(mv: number): number {
|
||||
if (mv >= OCV_TABLE[0][0]) return 100;
|
||||
if (mv <= OCV_TABLE[OCV_TABLE.length - 1][0]) return 0;
|
||||
for (let i = 0; i < OCV_TABLE.length - 1; i++) {
|
||||
const [highMv, highPct] = OCV_TABLE[i];
|
||||
const [lowMv, lowPct] = OCV_TABLE[i + 1];
|
||||
if (mv >= lowMv)
|
||||
return Math.round(lowPct + ((mv - lowMv) / (highMv - lowMv)) * (highPct - lowPct));
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
export function formatBatteryLabel(
|
||||
mv: number,
|
||||
showPercent: boolean,
|
||||
showVoltage: boolean
|
||||
): string | null {
|
||||
if (!showPercent && !showVoltage) return null;
|
||||
const pct = mvToPercent(mv);
|
||||
if (showPercent && showVoltage) return `${pct}% (${mv}mV)`;
|
||||
if (showPercent) return `${pct}%`;
|
||||
return `${mv}mV`;
|
||||
}
|
||||
|
||||
const PERCENT_KEY = 'remoteterm-show-battery-percent';
|
||||
const VOLTAGE_KEY = 'remoteterm-show-battery-voltage';
|
||||
|
||||
export function getShowBatteryPercent(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(PERCENT_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setShowBatteryPercent(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
localStorage.setItem(PERCENT_KEY, 'true');
|
||||
} else {
|
||||
localStorage.removeItem(PERCENT_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
|
||||
export function getShowBatteryVoltage(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(VOLTAGE_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setShowBatteryVoltage(enabled: boolean): void {
|
||||
try {
|
||||
if (enabled) {
|
||||
localStorage.setItem(VOLTAGE_KEY, 'true');
|
||||
} else {
|
||||
localStorage.removeItem(VOLTAGE_KEY);
|
||||
}
|
||||
} catch {
|
||||
// localStorage may be unavailable
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user