From e09a3a01f7915cde24543d7b6c78dfe34c72266c Mon Sep 17 00:00:00 2001 From: YourSandwich Date: Fri, 10 Apr 2026 22:25:17 +0200 Subject: [PATCH 1/6] Add localStorage helpers for battery display settings --- frontend/src/utils/batteryDisplay.ts | 42 ++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 frontend/src/utils/batteryDisplay.ts diff --git a/frontend/src/utils/batteryDisplay.ts b/frontend/src/utils/batteryDisplay.ts new file mode 100644 index 0000000..8ce1af4 --- /dev/null +++ b/frontend/src/utils/batteryDisplay.ts @@ -0,0 +1,42 @@ +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 + } +} From fa0be2499031e8b2d677024896991ffbf3e08e9e Mon Sep 17 00:00:00 2001 From: YourSandwich Date: Fri, 10 Apr 2026 22:54:43 +0200 Subject: [PATCH 2/6] Add battery indicator to status bar --- frontend/src/components/StatusBar.tsx | 88 ++++++++++++++++++++++++++- 1 file changed, 87 insertions(+), 1 deletion(-) diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 9a0d12c..2b14c6c 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -1,12 +1,50 @@ import { useEffect, useState } from 'react'; -import { Menu, Moon, Sun } from 'lucide-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 { getShowBatteryPercent, getShowBatteryVoltage } from '../utils/batteryDisplay'; import { cn } from '@/lib/utils'; +// 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], +]; + +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 const BATTERY_DISPLAY_CHANGE_EVENT = 'remoteterm-battery-display-change'; + interface StatusBarProps { health: HealthStatus | null; config: RadioConfig | null; @@ -22,6 +60,18 @@ 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 radioState = health?.radio_state ?? (health?.radio_initializing @@ -119,6 +169,42 @@ export function StatusBar({ {statusLabel} + {(showBatteryPercent || showBatteryVoltage) && + connected && + health?.radio_stats?.battery_mv != null && + health.radio_stats.battery_mv > 0 && + (() => { + const mv = health.radio_stats.battery_mv!; + const pct = mvToPercent(mv); + 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}% (${mv}mV)` + : showBatteryPercent + ? `${pct}%` + : `${mv}mV`; + return ( +
+
+ ); + })()} + {config && (
{config.name || 'Unnamed'} From 2f55d11b0bb558e196c63ea11186f54cfb743b2b Mon Sep 17 00:00:00 2001 From: YourSandwich Date: Fri, 10 Apr 2026 23:14:52 +0200 Subject: [PATCH 3/6] Add battery display toggles to Local Configuration --- .../settings/SettingsLocalSection.tsx | 46 +++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 29aab85..6774c56 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -28,6 +28,13 @@ import { setSavedFontScale, } from '../../utils/fontScale'; import { getAutoFocusInputEnabled, setAutoFocusInputEnabled } from '../../utils/autoFocusInput'; +import { + getShowBatteryPercent, + setShowBatteryPercent as saveBatteryPercent, + getShowBatteryVoltage, + setShowBatteryVoltage as saveBatteryVoltage, +} from '../../utils/batteryDisplay'; +import { BATTERY_DISPLAY_CHANGE_EVENT } from '../StatusBar'; 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({ Auto-focus input on conversation load (desktop only) + + + + + {(batteryPercent || batteryVoltage) && ( +

+ Battery data updates every 60 seconds and may take up to a minute to appear after + connecting. +

+ )} +
From 390c0624ea81f2f1849f9eda842e7183b245e17e Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 10 Apr 2026 14:48:12 -0700 Subject: [PATCH 4/6] IIFE => memo for battery color/styling conversion --- frontend/src/components/StatusBar.tsx | 65 ++++++++++++--------------- 1 file changed, 29 insertions(+), 36 deletions(-) diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 2b14c6c..698fcc8 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { BatteryFull, BatteryLow, @@ -72,6 +72,23 @@ export function StatusBar({ 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 @@ -169,41 +186,17 @@ export function StatusBar({ {statusLabel}
- {(showBatteryPercent || showBatteryVoltage) && - connected && - health?.radio_stats?.battery_mv != null && - health.radio_stats.battery_mv > 0 && - (() => { - const mv = health.radio_stats.battery_mv!; - const pct = mvToPercent(mv); - 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}% (${mv}mV)` - : showBatteryPercent - ? `${pct}%` - : `${mv}mV`; - return ( -
-
- ); - })()} + {connected && batteryInfo && ( +
+
+ )} {config && (
From f9f046a05fdc75c049e16655ff38e3c00cf0d753 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 10 Apr 2026 14:51:19 -0700 Subject: [PATCH 5/6] Fix inversion of const definition location --- frontend/src/components/StatusBar.tsx | 8 +++++--- frontend/src/components/settings/SettingsLocalSection.tsx | 2 +- frontend/src/utils/batteryDisplay.ts | 2 ++ 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index 698fcc8..ee55c98 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -13,7 +13,11 @@ import { api } from '../api'; import { toast } from './ui/sonner'; import { handleKeyboardActivate } from '../utils/a11y'; import { applyTheme, getSavedTheme, THEME_CHANGE_EVENT } from '../utils/theme'; -import { getShowBatteryPercent, getShowBatteryVoltage } from '../utils/batteryDisplay'; +import { + BATTERY_DISPLAY_CHANGE_EVENT, + getShowBatteryPercent, + getShowBatteryVoltage, +} from '../utils/batteryDisplay'; import { cn } from '@/lib/utils'; // Meshtastic default OCV table (meshtastic/firmware src/power.h) @@ -43,8 +47,6 @@ function mvToPercent(mv: number): number { return 0; } -export const BATTERY_DISPLAY_CHANGE_EVENT = 'remoteterm-battery-display-change'; - interface StatusBarProps { health: HealthStatus | null; config: RadioConfig | null; diff --git a/frontend/src/components/settings/SettingsLocalSection.tsx b/frontend/src/components/settings/SettingsLocalSection.tsx index 6774c56..b706dea 100644 --- a/frontend/src/components/settings/SettingsLocalSection.tsx +++ b/frontend/src/components/settings/SettingsLocalSection.tsx @@ -29,12 +29,12 @@ import { } 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'; -import { BATTERY_DISPLAY_CHANGE_EVENT } from '../StatusBar'; export function SettingsLocalSection({ onLocalLabelChange, diff --git a/frontend/src/utils/batteryDisplay.ts b/frontend/src/utils/batteryDisplay.ts index 8ce1af4..3617bf0 100644 --- a/frontend/src/utils/batteryDisplay.ts +++ b/frontend/src/utils/batteryDisplay.ts @@ -1,3 +1,5 @@ +export const BATTERY_DISPLAY_CHANGE_EVENT = 'remoteterm-battery-display-change'; + const PERCENT_KEY = 'remoteterm-show-battery-percent'; const VOLTAGE_KEY = 'remoteterm-show-battery-voltage'; From 8752320f52205c553c0460cbcfcfece7d57eee00 Mon Sep 17 00:00:00 2001 From: Jack Kingsman Date: Fri, 10 Apr 2026 14:52:34 -0700 Subject: [PATCH 6/6] Add some tests and move the helpers into their own TS file --- frontend/src/components/StatusBar.tsx | 28 +-------------- frontend/src/test/batteryDisplay.test.ts | 44 ++++++++++++++++++++++++ frontend/src/utils/batteryDisplay.ts | 39 +++++++++++++++++++++ 3 files changed, 84 insertions(+), 27 deletions(-) create mode 100644 frontend/src/test/batteryDisplay.test.ts diff --git a/frontend/src/components/StatusBar.tsx b/frontend/src/components/StatusBar.tsx index ee55c98..eb97f3f 100644 --- a/frontend/src/components/StatusBar.tsx +++ b/frontend/src/components/StatusBar.tsx @@ -17,36 +17,10 @@ import { BATTERY_DISPLAY_CHANGE_EVENT, getShowBatteryPercent, getShowBatteryVoltage, + mvToPercent, } from '../utils/batteryDisplay'; import { cn } from '@/lib/utils'; -// 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], -]; - -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; -} - interface StatusBarProps { health: HealthStatus | null; config: RadioConfig | null; diff --git a/frontend/src/test/batteryDisplay.test.ts b/frontend/src/test/batteryDisplay.test.ts new file mode 100644 index 0000000..c79eca6 --- /dev/null +++ b/frontend/src/test/batteryDisplay.test.ts @@ -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)'); + }); +}); diff --git a/frontend/src/utils/batteryDisplay.ts b/frontend/src/utils/batteryDisplay.ts index 3617bf0..a5c12ec 100644 --- a/frontend/src/utils/batteryDisplay.ts +++ b/frontend/src/utils/batteryDisplay.ts @@ -1,5 +1,44 @@ 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';