diff --git a/apps/web/public/i18n/locales/en/nodes.json b/apps/web/public/i18n/locales/en/nodes.json index 16db3f953..e72d13fe8 100644 --- a/apps/web/public/i18n/locales/en/nodes.json +++ b/apps/web/public/i18n/locales/en/nodes.json @@ -59,5 +59,11 @@ "ignoreNode": "Ignore Node", "unignoreNode": "Unignore Node", "requestPosition": "Request Position" + }, + "signalQuality": { + "good": "Good", + "fair": "Fair", + "bad": "Weak", + "none": "No signal" } } diff --git a/apps/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx b/apps/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx index d87dd1f42..676b6bd5b 100644 --- a/apps/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx +++ b/apps/web/src/components/PageComponents/Map/Layers/SNRLayer.tsx @@ -146,6 +146,7 @@ function makeFeature( toPos: LngLat, snr: number, curved: boolean, + preset?: number | string | null, ): Feature | undefined { const segment = arcSegment(fromPos, toPos, curved); @@ -157,7 +158,7 @@ function makeFeature( type: "Feature", geometry: { type: "LineString", coordinates: segment }, properties: { - color: getSignalColor(snr), + color: getSignalColor(snr, undefined, preset), snr, from: fromId, to: toId, @@ -173,8 +174,9 @@ function pushIfFeature( snr: number, curved: boolean, features: Feature[], + preset?: number | string | null, ) { - const feat = makeFeature(a, b, aPos, bPos, snr, curved); + const feat = makeFeature(a, b, aPos, bPos, snr, curved, preset); if (feat) { features.push(feat); } @@ -182,6 +184,7 @@ function pushIfFeature( function generateNeighborLines( neighborInfos: NeighborInfos[], + preset?: number | string | null, ): FeatureCollection { // Collect positions for all referenced nodes, discard pairs with missing positions const idToLngLat = new Map(); @@ -230,15 +233,51 @@ function generateNeighborLines( if (pair.ab && pair.ba) { // both directions → two arcs - pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, true, features); - pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, true, features); + pushIfFeature( + pair.a, + pair.b, + aPos, + bPos, + pair.ab, + true, + features, + preset, + ); + pushIfFeature( + pair.b, + pair.a, + bPos, + aPos, + pair.ba, + true, + features, + preset, + ); } else { // only one direction → straight if (pair.ab) { - pushIfFeature(pair.a, pair.b, aPos, bPos, pair.ab, false, features); + pushIfFeature( + pair.a, + pair.b, + aPos, + bPos, + pair.ab, + false, + features, + preset, + ); } if (pair.ba) { - pushIfFeature(pair.b, pair.a, bPos, aPos, pair.ba, false, features); + pushIfFeature( + pair.b, + pair.a, + bPos, + aPos, + pair.ba, + false, + features, + preset, + ); } } } @@ -288,7 +327,7 @@ export const SNRLayer = ({ myNode, visibilityState, }: SNRLayerProps): React.ReactNode => { - const { getNeighborInfo } = useDevice(); + const { getNeighborInfo, config } = useDevice(); const remotePairs = visibilityState.remoteNeighbors ? filteredNodes.flatMap((node) => { @@ -325,10 +364,10 @@ export const SNRLayer = ({ })) : []; - const featureCollection = generateNeighborLines([ - ...remotePairs, - ...directPairs, - ]); + const featureCollection = generateNeighborLines( + [...remotePairs, ...directPairs], + config.lora?.modemPreset, + ); return ( diff --git a/apps/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx b/apps/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx index 8ddabd704..6946d3091 100644 --- a/apps/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx +++ b/apps/web/src/components/PageComponents/Map/Popups/NodeDetail.tsx @@ -6,6 +6,8 @@ import { Separator } from "@components/UI/Separator.tsx"; import { Heading } from "@components/UI/Typography/Heading.tsx"; import { Subtle } from "@components/UI/Typography/Subtle.tsx"; import { formatQuantity } from "@core/utils/string.ts"; +import { rateSignalQuality } from "@core/utils/signalQuality.ts"; +import { useDevice } from "@core/stores"; import type { Protobuf as ProtobufType } from "@meshtastic/sdk"; import { Protobuf } from "@meshtastic/sdk"; import { @@ -17,11 +19,14 @@ import { } from "@radix-ui/react-tooltip"; import { useNavigate } from "@tanstack/react-router"; import { - Dot, LockIcon, LockOpenIcon, MessageSquareIcon, MountainSnow, + Signal, + SignalHigh, + SignalLow, + SignalMedium, Star, } from "lucide-react"; import { useTranslation } from "react-i18next"; @@ -32,6 +37,7 @@ export interface NodeDetailProps { export const NodeDetail = ({ node }: NodeDetailProps) => { const navigate = useNavigate(); + const { config } = useDevice(); const { t } = useTranslation("nodes"); const name = node.user?.longName ?? t("unknown.shortName"); const shortName = node.user?.shortName ?? t("unknown.shortName"); @@ -44,17 +50,26 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { ? t("unset") : rawHardwareType.replaceAll("_", " ") : `${hwModel}`; - // SNR is in dB; derive a 0–100% quality heuristic (-10 dB → 0%, +10 dB → 100%). - const snrQuality = Math.min( - Math.max(Math.round((node.snr + 10) * 5), 0), - 100, - ); + // SNR rated against the active modem preset's demodulation floor + // (meshtastic/web#1241). WCAG 1.4.1: tier encoded in color + icon + text. + const quality = + node.snr == null + ? undefined + : rateSignalQuality(node.snr, config.lora?.modemPreset); const snrTone = - snrQuality >= 67 + quality === "good" ? "text-green-600" - : snrQuality >= 34 + : quality === "fair" ? "text-yellow-600" : "text-red-600"; + const SignalIcon = + quality === "good" + ? SignalHigh + : quality === "fair" + ? SignalMedium + : quality === "bad" + ? SignalLow + : Signal; function handleDirectMessage() { navigate({ to: `/messages/direct/${node.num}` }); } @@ -208,15 +223,15 @@ export const NodeDetail = ({ node }: NodeDetailProps) => { )} - {node.snr !== 0 && ( + {node.snr != null && quality && (
{t("unit.snr")}
+
)} diff --git a/apps/web/src/core/utils/signalColor.ts b/apps/web/src/core/utils/signalColor.ts index 76ce82205..157bc290a 100644 --- a/apps/web/src/core/utils/signalColor.ts +++ b/apps/web/src/core/utils/signalColor.ts @@ -1,12 +1,4 @@ -export const SNR_THRESHOLD = { - GOOD: -7, - FAIR: -15, -}; - -export const RSSI_THRESHOLD = { - GOOD: -115, - FAIR: -126, -}; +import { rateSignalQuality } from "./signalQuality.ts"; export const LINE_COLOR = { GOOD: "#00ff00", @@ -14,18 +6,15 @@ export const LINE_COLOR = { BAD: "#f7931a", }; -export const getSignalColor = (snr: number, rssi?: number): string => { - if ( - snr > SNR_THRESHOLD.GOOD && - (rssi == null || rssi > RSSI_THRESHOLD.GOOD) - ) { - return LINE_COLOR.GOOD; - } - if ( - snr > SNR_THRESHOLD.FAIR && - (rssi == null || rssi > RSSI_THRESHOLD.FAIR) - ) { - return LINE_COLOR.FAIR; - } +export const getSignalColor = ( + snr: number, + rssi?: number, + preset?: number | string | null, +): string => { + // Preset-relative quality per meshtastic/web#1241; "none" (no chance of + // demodulation) renders as BAD on the map. + const quality = rateSignalQuality(snr, preset, rssi); + if (quality === "good") return LINE_COLOR.GOOD; + if (quality === "fair") return LINE_COLOR.FAIR; return LINE_COLOR.BAD; }; diff --git a/apps/web/src/core/utils/signalQuality.test.ts b/apps/web/src/core/utils/signalQuality.test.ts new file mode 100644 index 000000000..530a5ffba --- /dev/null +++ b/apps/web/src/core/utils/signalQuality.test.ts @@ -0,0 +1,161 @@ +import { Protobuf } from "@meshtastic/sdk"; +import { describe, expect, it } from "vitest"; + +import { LINE_COLOR, getSignalColor } from "./signalColor.ts"; +import { + getSnrLimit, + rateSignalQuality, + type SignalQuality, +} from "./signalQuality.ts"; + +type Preset = Protobuf.Config.Config_LoRaConfig_ModemPreset; + +const preset = (name: string): Preset => + Protobuf.Config.Config_LoRaConfig_ModemPreset[ + name as keyof typeof Protobuf.Config.Config_LoRaConfig_ModemPreset + ]; + +describe("getSnrLimit", () => { + it("follows spreading-factor demodulation floors per preset", () => { + // Mirrors Android LoraSignalIndicatorTest.snrLimit assertions. + expect(getSnrLimit(preset("SHORT_FAST"))).toBe(-7.5); // SF7 + expect(getSnrLimit(preset("SHORT_TURBO"))).toBe(-7.5); // SF7 + expect(getSnrLimit(preset("SHORT_SLOW"))).toBe(-10); // SF8 + expect(getSnrLimit(preset("MEDIUM_FAST"))).toBe(-12.5); // SF9 + expect(getSnrLimit(preset("MEDIUM_SLOW"))).toBe(-15); // SF10 + expect(getSnrLimit(preset("LONG_FAST"))).toBe(-17.5); // SF11 + expect(getSnrLimit(preset("LONG_MODERATE"))).toBe(-17.5); // SF11 + expect(getSnrLimit(preset("LONG_TURBO"))).toBe(-12.5); + }); + + it("uses physically-correct SF12 floor for LONG_SLOW and VERY_LONG_SLOW", () => { + // Meshtastic-Apple returns -7.5 here (the SF7 value, an apparent bug); + // Android documents this in ChannelOption.kt and uses -20. We follow + // Android + physics, per meshtastic/web#1241 discussion. + expect(getSnrLimit(preset("LONG_SLOW"))).toBe(-20); + if (preset("VERY_LONG_SLOW") !== undefined) { + expect(getSnrLimit(preset("VERY_LONG_SLOW"))).toBe(-20); + } + }); + + it("covers every preset in the vendored protobuf enum", () => { + const members = Object.keys( + Protobuf.Config.Config_LoRaConfig_ModemPreset, + ).filter((k) => Number.isNaN(Number(k))); + for (const member of members) { + expect(getSnrLimit(preset(member)), member).toBeTypeOf("number"); + } + }); + + it("falls back to the LONG_FAST default limit for unknown presets", () => { + expect(getSnrLimit(undefined)).toBe(-17.5); + expect(getSnrLimit(999 as Preset)).toBe(-17.5); + }); + + it("accepts preset names as strings", () => { + expect(getSnrLimit("SHORT_FAST")).toBe(-7.5); + expect(rateSignalQuality(-15, "LONG_SLOW")).toBe("good"); + expect(rateSignalQuality(-15, "SHORT_FAST")).toBe("bad"); + }); + + it("covers MEDIUM_TURBO ahead of the SDK dep bump", () => { + // protobufs master defines value 16; this repo's vendored copy does not + // yet. Name-keyed entry activates automatically when it lands. + expect(getSnrLimit("MEDIUM_TURBO")).toBe(-12.5); + }); +}); + +describe("rateSignalQuality — preset-relative SNR bands", () => { + it("rates the same SNR relative to the active preset", () => { + // -15 dB is comfortably above LongSlow's -20 floor but below ShortFast's -7.5. + expect(rateSignalQuality(-15, preset("LONG_SLOW"))).toBe("good"); + expect(rateSignalQuality(-15, preset("SHORT_FAST"))).toBe("bad"); + }); + + it("rates -10 dB GOOD on LongFast (the original bug's example)", () => { + // A fixed threshold called this BAD; on LongFast it is 7.5 dB above the floor. + expect(rateSignalQuality(-10, preset("LONG_FAST"))).toBe("good"); + }); + + it("draws quality bands around the LongFast floor", () => { + const p = preset("LONG_FAST"); // limit = -17.5 + expect(rateSignalQuality(-17, p)).toBe("good"); // margin > 0 + expect(rateSignalQuality(-17.5, p)).toBe("fair"); // at limit + expect(rateSignalQuality(-22, p)).toBe("fair"); // > limit-5.5 (-23) + expect(rateSignalQuality(-23, p)).toBe("bad"); // >= limit-7.5 (-25) + expect(rateSignalQuality(-30, p)).toBe("none"); // < limit-7.5 + }); + + it("treats zero SNR as a reading, not an absence", () => { + // 0 dB sits above every preset's demod floor. + expect(rateSignalQuality(0, preset("LONG_FAST"))).toBe("good"); + expect(rateSignalQuality(0, preset("SHORT_FAST"))).toBe("good"); + }); +}); + +describe("rateSignalQuality — fallback RSSI blend (no noise floor)", () => { + const p = preset("LONG_FAST"); // limit = -17.5 + + it("returns SNR-only rating when rssi is absent or zero", () => { + expect(rateSignalQuality(-16, p)).toBe("good"); + expect(rateSignalQuality(-16, p, 0)).toBe("good"); + expect(rateSignalQuality(-30, p, 0)).toBe("none"); + expect(rateSignalQuality(-16, p, undefined)).toBe("good"); + }); + + it("applies the fixed-RSSI blend exactly as specified", () => { + // Good: rssi > -115 && snr > limit + expect(rateSignalQuality(-17, p, -110)).toBe("good"); + // Good fails when rssi <= -115 even with snr > limit → falls through: + // not None (rssi not < -126), Bad requires rssi <= -120 || snr <= limit-5.5; + // rssi -117 is > -120 and snr -17 > -23 ⇒ Fair. + expect(rateSignalQuality(-17, p, -117)).toBe("fair"); + // None: rssi < -126 && snr < limit-7.5 + expect(rateSignalQuality(-26, p, -128)).toBe("none"); + // Bad: rssi <= -120 + expect(rateSignalQuality(-10, p, -125)).toBe("bad"); + // Bad: snr <= limit-5.5 despite strong rssi + expect(rateSignalQuality(-23.5, p, -90)).toBe("bad"); + // Fair: everything between the bands + expect(rateSignalQuality(-19, p, -110)).toBe("fair"); + }); +}); + +describe("rateSignalQuality — noise-floor dual margin", () => { + const p = preset("LONG_FAST"); // limit = -17.5 + + it("takes the more conservative tier of the two margins", () => { + // SNR margin says GOOD (snr -10 vs -17.5), link margin says BAD: + // rssi -104 with noiseFloor -80 ⇒ linkMargin = (-104 + 80) + 17.5 = -6.5 → bad. + expect(rateSignalQuality(-10, p, -104, -80)).toBe("bad"); + // Reverse: SNR margin FAIR-ish but link margin strong ⇒ conservative wins. + // snr -18.5 (margin -1 → good? margin=-1 > -5.5 ⇒ fair... compute: -18.5+17.5=-1 → fair) + // link: rssi -50, nf -100 ⇒ (-50 + 100) - (-17.5) = 67.5 → good ⇒ conservative = fair. + expect(rateSignalQuality(-18.5, p, -50, -100)).toBe("fair"); + }); + + it("ignores non-positive or missing noise floors", () => { + // noiseFloor 0/undefined ⇒ fallback blend path. + expect(rateSignalQuality(-10, p, -95, 0)).toBe("good"); + expect(rateSignalQuality(-10, p, -95, undefined)).toBe("good"); + expect(rateSignalQuality(-10, p, undefined, -80)).toBe("good"); + }); +}); + +describe("quality ordering", () => { + it("exposes exactly four tiers", () => { + const all: SignalQuality[] = ["none", "bad", "fair", "good"]; + expect(new Set(all).size).toBe(4); + }); +}); + +describe("getSignalColor mapping", () => { + const p = preset("LONG_FAST"); + + it("maps tiers to line colors, none renders as BAD", () => { + expect(getSignalColor(-10, undefined, p)).toBe(LINE_COLOR.GOOD); + expect(getSignalColor(-19, undefined, p)).toBe(LINE_COLOR.FAIR); + expect(getSignalColor(-24, undefined, p)).toBe(LINE_COLOR.BAD); + expect(getSignalColor(-40, undefined, p)).toBe(LINE_COLOR.BAD); // none → BAD + }); +}); diff --git a/apps/web/src/core/utils/signalQuality.ts b/apps/web/src/core/utils/signalQuality.ts new file mode 100644 index 000000000..eb55a44ec --- /dev/null +++ b/apps/web/src/core/utils/signalQuality.ts @@ -0,0 +1,110 @@ +import { Protobuf } from "@meshtastic/sdk"; + +export type SignalQuality = "good" | "fair" | "bad" | "none"; + +/** + * SNR demodulation floor (dB) per modem preset, ported from Android's + * ChannelOption.snrLimit (meshtastic/web#1241, meshtastic/design#15). + * LONG_SLOW is -20 (the physically-correct SF12 floor); Meshtastic-Apple + * returns -7.5 there, which Android documents as an apparent bug. + */ +const SNR_LIMITS: Record = { + LONG_FAST: -17.5, + LONG_SLOW: -20, + VERY_LONG_SLOW: -20, + MEDIUM_SLOW: -15, + MEDIUM_FAST: -12.5, + SHORT_SLOW: -10, + SHORT_FAST: -7.5, + LONG_MODERATE: -17.5, + SHORT_TURBO: -7.5, + LONG_TURBO: -12.5, + LITE_FAST: -12.5, + LITE_SLOW: -15, + NARROW_FAST: -10, + NARROW_SLOW: -12.5, + TINY_FAST: -7.5, + TINY_SLOW: -10, + // NOTE: Meshtastic-Apple rates TINY_FAST/TINY_SLOW as -12.5/-15 + // ("20kHz ham presets, best link budget"); Android says SF7/SF8 floors. + // Divergence flagged in meshtastic/web#1241 PR for maintainer ruling; + // we follow Android pending that call. + // Not yet in this repo's vendored protobufs, present in protobufs master + // (value 16) and Android's ChannelOption — keyed by name so it activates + // automatically when the SDK dep bumps. + MEDIUM_TURBO: -12.5, +}; + +/** Unknown/deprecated presets fall back to LONG_FAST, mirroring Android. */ +const DEFAULT_SNR_LIMIT = -17.5; + +const QUALITY_RANK: Record = { + none: 0, + bad: 1, + fair: 2, + good: 3, +}; + +export function getSnrLimit(preset?: number | string | null): number { + const name = + typeof preset === "number" + ? ( + Protobuf.Config.Config_LoRaConfig_ModemPreset as Record< + number, + string + > + )[preset] + : preset; + if (name == null) return DEFAULT_SNR_LIMIT; + return SNR_LIMITS[name] ?? DEFAULT_SNR_LIMIT; +} + +function tierFromMargin(margin: number): SignalQuality { + if (margin > 0) return "good"; + if (margin > -5.5) return "fair"; + if (margin >= -7.5) return "bad"; + return "none"; +} + +/** + * Preset-relative signal quality. + * + * With a known nonzero noise floor (node LocalStats telemetry), rates both + * the reported-SNR margin and the physical link margin + * ((rssi − noiseFloor) − limit) and returns the more conservative tier. + * Otherwise falls back to SNR-only, blended with fixed RSSI thresholds + * (-115/-120/-126) exactly as specified in meshtastic/web#1241. + */ +export function rateSignalQuality( + snr: number, + preset?: number | string | null, + rssi?: number | null, + noiseFloor?: number | null, +): SignalQuality { + const limit = getSnrLimit(preset); + + if ( + typeof rssi === "number" && + typeof noiseFloor === "number" && + noiseFloor !== 0 + ) { + const linkMargin = rssi - noiseFloor - limit; + const conservative = Math.min( + QUALITY_RANK[tierFromMargin(snr - limit)], + QUALITY_RANK[tierFromMargin(linkMargin)], + ); + return (Object.keys(QUALITY_RANK) as SignalQuality[]).find( + (q) => QUALITY_RANK[q] === conservative, + ) as SignalQuality; + } + + if (typeof rssi === "number" && rssi !== 0) { + const margin = snr - limit; + if (rssi > -115 && margin > 0) return "good"; + if (rssi < -126 && margin < -7.5) return "none"; + if (rssi <= -120 || margin <= -5.5) return "bad"; + return "fair"; + } + + return tierFromMargin(snr - limit); +} diff --git a/apps/web/src/pages/Nodes/index.tsx b/apps/web/src/pages/Nodes/index.tsx index a8f675941..b10943f9b 100644 --- a/apps/web/src/pages/Nodes/index.tsx +++ b/apps/web/src/pages/Nodes/index.tsx @@ -18,11 +18,19 @@ import { Avatar } from "@components/UI/Avatar.tsx"; import { Input } from "@components/UI/Input.tsx"; import useLang from "@core/hooks/useLang.ts"; import { useNodesAsProto } from "@core/hooks/useNodesAsProto.ts"; +import { rateSignalQuality } from "@core/utils/signalQuality.ts"; import { useAppStore, useDevice } from "@core/stores"; import { Protobuf, type Types } from "@meshtastic/sdk"; import { useNodeErrors } from "@meshtastic/sdk-react"; import { numberToHexUnpadded } from "@noble/curves/utils.js"; -import { LockIcon, LockOpenIcon } from "lucide-react"; +import { + LockIcon, + LockOpenIcon, + Signal, + SignalHigh, + SignalLow, + SignalMedium, +} from "lucide-react"; import { type JSX, useCallback, @@ -42,7 +50,7 @@ export interface DeleteNoteDialogProps { const NodesPage = (): JSX.Element => { const { t } = useTranslation("nodes"); const { current } = useLang(); - const { hardware, connection, setDialogOpen } = useDevice(); + const { hardware, connection, setDialogOpen, config } = useDevice(); const { setNodeNumDetails } = useAppStore(); const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode(); @@ -150,16 +158,26 @@ const NodesPage = (): JSX.Element => { last4: shortName, }); - // SNR is reported in dB. Map it to a 0–100% signal-quality heuristic - // (-10 dB → 0%, +10 dB → 100%) for an at-a-glance read, and pick a tone. - const snr = node.snr ?? 0; - const snrQuality = Math.min(Math.max(Math.round((snr + 10) * 5), 0), 100); + // SNR rated against the active modem preset's demodulation floor + // (meshtastic/web#1241). WCAG 1.4.1: tier encoded in color + icon + text. + const quality = + node.snr == null + ? undefined + : rateSignalQuality(node.snr, config.lora?.modemPreset); const snrTone = - snrQuality >= 67 + quality === "good" ? "text-green-500" - : snrQuality >= 34 + : quality === "fair" ? "text-yellow-500" : "text-red-500"; + const SignalIcon = + quality === "good" + ? SignalHigh + : quality === "fair" + ? SignalMedium + : quality === "bad" + ? SignalLow + : Signal; return { id: node.num, @@ -239,13 +257,24 @@ const NodesPage = (): JSX.Element => { }, { content: ( - - - {Number(snr.toFixed(1))} {t("unit.db")} - - - {snrQuality}% - + + {node.snr == null ? ( + {t("unknown.shortName")} + ) : ( + <> + ), sortValue: node.snr,