feat(protobufs): sync to firmware-current and consume workspace package (#1097)
* feat(protobufs): sync to firmware-current and consume workspace package Sync the vendored .proto sources to firmware-current (v2.7.25+48), regenerate the v2 TS bindings, and consume the workspace @meshtastic/protobufs (workspace:*) in place of the stale JSR 2.7.20 — finishing the monorepo migration (core was already workspace:*). Includes the one required breaking-change fix: admin nodedb_reset changed int32 to bool, so resetNodes() now sends value: true. * build(protobufs): vendor generated bindings for workspace consumers The package is consumed via workspace:* — its exports point at the TS source, which imports ./dist/meshtastic/*_pb.ts — so the generated output must exist at build time. CI builds web/core with no codegen step and the runners have no buf CLI, so the bindings are vendored here (kept gitignored; lint/format skip them). Regenerate with: pnpm --filter @meshtastic/protobufs gen * fix(protobufs): clean script removes the actual generated output dir buf writes bindings to packages/ts/dist, but clean was removing a non-existent root dist — so it never cleaned stale output. Addresses Copilot review feedback. * refactor: move web app packages/web -> apps/web Aligns the web app with the apps/web layout (matching the Vercel web-test Root Directory and the SDK-migration direction). Pure directory move plus root config: pnpm-workspace (adds apps/*), vitest projects, root tsconfig reference, and the pr/release-web/nightly workflows. vercel.json moved with the app. Build + 36 validation tests green. * feat: config fields, module pages, key verification, telemetry capture Incorporates the firmware-current feature work onto the protobuf foundation: new config fields (Display message bubbles; LoRa fem_lna_mode + serial_hal_only; Telemetry air_quality_screen_enabled); 4 new ModuleConfig pages (TrafficManagement, StatusMessage, TAK, RemoteHardware); the manual Key Verification flow (sendKeyVerification + ClientNotificationDialog stages + Verify Key button); live telemetry capture (nodeDB addDeviceMetrics) and admin hardening (toggleMutedNode, graceful PortNum default); plus the sdk-preview ConfigEditor demo and store/config tests. Build + lint + format + 131 tests green. * chore: drop #1062 (unsaved-change-detection) to match upstream revert #1062 was merged to main by accident (per @danditomaso) and is being reverted. Reverse-applied its diff here via 3-way so #1097 stays consistent with where main is headed, while keeping the feature changes layered on the same files (deviceStore/changeRegistry). Build + 131 tests + lint + format green. * fix(nodes): clean up SNR display in node table and map popup SNR is a ratio measured in dB, not dBm (which is absolute power); the node table and map popup both mislabeled it and crammed three values together: '0dBm/50%/50raw'. The trailing '%/raw' pair was the same heuristic ((snr+10)*5) shown twice — once clamped, once not. Render SNR in dB rounded to one decimal, color-coded by a 0-100% signal-quality heuristic (green/yellow/red), with the quality percentage as a muted secondary. Drop the redundant raw value. Adds unit.db; this matches the existing SNRTooltip, which already renders dB.
This commit is contained in:
@@ -0,0 +1,281 @@
|
||||
import {
|
||||
defaultVisibilityState,
|
||||
MapLayerTool,
|
||||
type VisibilityState,
|
||||
} from "@app/components/PageComponents/Map/Tools/MapLayerTool.tsx";
|
||||
import { FilterControl } from "@components/generic/Filter/FilterControl.tsx";
|
||||
import { type FilterState, useFilterNode } from "@components/generic/Filter/useFilterNode.ts";
|
||||
import { BaseMap } from "@components/Map.tsx";
|
||||
import {
|
||||
HeatmapLayer,
|
||||
type HeatmapMode,
|
||||
} from "@components/PageComponents/Map/Layers/HeatmapLayer.tsx";
|
||||
import { NodesLayer } from "@components/PageComponents/Map/Layers/NodesLayer.tsx";
|
||||
import { PrecisionLayer } from "@components/PageComponents/Map/Layers/PrecisionLayer.tsx";
|
||||
import {
|
||||
SNRLayer,
|
||||
SNRTooltip,
|
||||
type SNRTooltipProps,
|
||||
} from "@components/PageComponents/Map/Layers/SNRLayer.tsx";
|
||||
import { WaypointLayer } from "@components/PageComponents/Map/Layers/WaypointLayer.tsx";
|
||||
import type { PopupState } from "@components/PageComponents/Map/Popups/PopupWrapper.tsx";
|
||||
import { PageLayout } from "@components/PageLayout.tsx";
|
||||
import { Sidebar } from "@components/Sidebar.tsx";
|
||||
import { useMapFitting } from "@core/hooks/useMapFitting.ts";
|
||||
import { useNodeDB } from "@core/stores";
|
||||
import { cn } from "@core/utils/cn.ts";
|
||||
import { hasPos, toLngLat } from "@core/utils/geo.ts";
|
||||
import type { Protobuf } from "@meshtastic/core";
|
||||
import { numberToHexUnpadded } from "@noble/curves/abstract/utils";
|
||||
import { FunnelIcon, LocateFixedIcon } from "lucide-react";
|
||||
import { useCallback, useDeferredValue, useId, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { type MapLayerMouseEvent, useMap } from "react-map-gl/maplibre";
|
||||
|
||||
const NODEDB_DEBOUNCE_MS = 250;
|
||||
|
||||
const MapPage = () => {
|
||||
const { t } = useTranslation("map");
|
||||
const { getNode } = useNodeDB();
|
||||
const { nodes: validNodes, myNode } = useNodeDB(
|
||||
(db) => ({
|
||||
// only nodes with a position
|
||||
nodes: db.getNodes((n): n is Protobuf.Mesh.NodeInfo => Boolean(n.position?.latitudeI)),
|
||||
myNode: db.getMyNode(),
|
||||
|
||||
// References to cause re-render on change
|
||||
_errorsRef: db.nodeErrors,
|
||||
_nodeNumRef: db.myNodeNum,
|
||||
}),
|
||||
{ debounce: NODEDB_DEBOUNCE_MS },
|
||||
);
|
||||
const { nodeFilter, defaultFilterValues, isFilterDirty } = useFilterNode();
|
||||
const { default: mapRef } = useMap();
|
||||
const { focusLngLat, fitToNodes } = useMapFitting(mapRef);
|
||||
|
||||
const hasFitBoundsOnce = useRef(false);
|
||||
const [snrHover, setSnrHover] = useState<SNRTooltipProps>();
|
||||
const [expandedCluster, setExpandedCluster] = useState<string | undefined>();
|
||||
const [popupState, setPopupState] = useState<PopupState | undefined>();
|
||||
|
||||
const [visibilityState, setVisibilityState] = useState<VisibilityState>(
|
||||
() => defaultVisibilityState,
|
||||
);
|
||||
const [heatmapMode, setHeatmapMode] = useState<HeatmapMode>("density");
|
||||
|
||||
// Filters
|
||||
const [filterState, setFilterState] = useState<FilterState>(() => defaultFilterValues);
|
||||
const deferredFilterState = useDeferredValue(filterState);
|
||||
|
||||
const filteredNodes = useMemo(
|
||||
() => validNodes.filter((node) => nodeFilter(node, deferredFilterState)),
|
||||
[validNodes, deferredFilterState, nodeFilter],
|
||||
);
|
||||
|
||||
// Map fitting
|
||||
const getMapBounds = useCallback(() => {
|
||||
if (!hasFitBoundsOnce.current) {
|
||||
fitToNodes(validNodes);
|
||||
hasFitBoundsOnce.current = true;
|
||||
}
|
||||
}, [fitToNodes, validNodes]);
|
||||
|
||||
// SNR lines
|
||||
const snrLayerElementId = useId();
|
||||
const snrLayerElement = useMemo(
|
||||
() => (
|
||||
<SNRLayer
|
||||
id={snrLayerElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
myNode={myNode}
|
||||
visibilityState={visibilityState}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, myNode, visibilityState, snrLayerElementId],
|
||||
);
|
||||
|
||||
// Heatmap
|
||||
const heatmapLayerElementId = useId();
|
||||
const heatmapLayerElement = useMemo(
|
||||
() => (
|
||||
<HeatmapLayer
|
||||
id={heatmapLayerElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
isVisible={visibilityState.heatmap}
|
||||
mode={heatmapMode}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, visibilityState.heatmap, heatmapMode, heatmapLayerElementId],
|
||||
);
|
||||
|
||||
const onMouseMove = useCallback(
|
||||
(event: MapLayerMouseEvent) => {
|
||||
const {
|
||||
features,
|
||||
point: { x, y },
|
||||
} = event;
|
||||
const hoveredFeature = features?.[0];
|
||||
|
||||
if (hoveredFeature) {
|
||||
const { from, to, snr, name, shortName, num } = hoveredFeature.properties;
|
||||
|
||||
// Handle Heatmap Hover
|
||||
if (
|
||||
hoveredFeature.layer.id === `${heatmapLayerElementId}-interaction` &&
|
||||
name !== undefined
|
||||
) {
|
||||
setSnrHover({
|
||||
pos: { x, y },
|
||||
snr: snr, // Single node SNR
|
||||
from:
|
||||
name ||
|
||||
shortName ||
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(num).slice(-4).toUpperCase(),
|
||||
}),
|
||||
to: undefined, // Single node
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle SNR Line Hover
|
||||
const fromLong =
|
||||
getNode(from)?.user?.longName ??
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(from).slice(-4).toUpperCase(),
|
||||
});
|
||||
|
||||
const toLong =
|
||||
getNode(to)?.user?.longName ??
|
||||
t("fallbackName", {
|
||||
last4: numberToHexUnpadded(to).slice(-4).toUpperCase(),
|
||||
});
|
||||
|
||||
setSnrHover({ pos: { x, y }, snr, from: fromLong, to: toLong });
|
||||
} else {
|
||||
setSnrHover(undefined);
|
||||
}
|
||||
},
|
||||
[getNode, t, heatmapLayerElementId],
|
||||
);
|
||||
|
||||
// Node markers & clusters
|
||||
const onMapBackgroundClick = useCallback(() => {
|
||||
setExpandedCluster(undefined);
|
||||
}, []);
|
||||
|
||||
const markerElements = useMemo(
|
||||
() => (
|
||||
<NodesLayer
|
||||
mapRef={mapRef}
|
||||
filteredNodes={filteredNodes}
|
||||
myNode={myNode}
|
||||
expandedCluster={expandedCluster}
|
||||
setExpandedCluster={setExpandedCluster}
|
||||
popupState={popupState}
|
||||
setPopupState={setPopupState}
|
||||
isVisible={visibilityState.nodeMarkers}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, expandedCluster, mapRef, myNode, popupState, visibilityState.nodeMarkers],
|
||||
);
|
||||
|
||||
// Precision circles
|
||||
const precisionCirclesElementId = useId();
|
||||
const precisionCirclesElement = useMemo(
|
||||
() => (
|
||||
<PrecisionLayer
|
||||
id={precisionCirclesElementId}
|
||||
filteredNodes={filteredNodes}
|
||||
isVisible={visibilityState.positionPrecision}
|
||||
/>
|
||||
),
|
||||
[filteredNodes, visibilityState.positionPrecision, precisionCirclesElementId],
|
||||
);
|
||||
|
||||
// Waypoints
|
||||
const waypointLayerElement = useMemo(
|
||||
() => (
|
||||
<WaypointLayer
|
||||
mapRef={mapRef}
|
||||
myNode={myNode}
|
||||
isVisible={visibilityState.waypoints}
|
||||
popupState={popupState}
|
||||
setPopupState={setPopupState}
|
||||
/>
|
||||
),
|
||||
[mapRef, myNode, visibilityState.waypoints, popupState],
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout label="Map" noPadding actions={[]} leftBar={<Sidebar />}>
|
||||
<BaseMap
|
||||
onLoad={getMapBounds}
|
||||
onMouseMove={onMouseMove}
|
||||
onClick={onMapBackgroundClick}
|
||||
interactiveLayerIds={[snrLayerElementId, `${heatmapLayerElementId}-interaction`]}
|
||||
>
|
||||
{heatmapLayerElement}
|
||||
{markerElements}
|
||||
{snrLayerElement}
|
||||
{precisionCirclesElement}
|
||||
{waypointLayerElement}
|
||||
|
||||
{snrHover && (
|
||||
<SNRTooltip pos={snrHover.pos} snr={snrHover.snr} from={snrHover.from} to={snrHover.to} />
|
||||
)}
|
||||
</BaseMap>
|
||||
<div className="flex flex-col space-y-1 fixed top-35 right-2.5">
|
||||
{myNode && hasPos(myNode?.position) && (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"rounded align-center",
|
||||
"w-[29px] px-1 py-1 shadow-l outline-[2px] outline-stone-600/20",
|
||||
"bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 ",
|
||||
"text-slate-600 hover:text-slate-700",
|
||||
"dark:text-slate-600 hover:dark:text-slate-700",
|
||||
)}
|
||||
aria-label={t("mapMenu.locateAria")}
|
||||
onClick={() => focusLngLat(toLngLat(myNode.position))}
|
||||
>
|
||||
{" "}
|
||||
<LocateFixedIcon className="w-[21px]" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<FilterControl
|
||||
filterState={filterState}
|
||||
defaultFilterValues={defaultFilterValues}
|
||||
setFilterState={setFilterState}
|
||||
isDirty={isFilterDirty(filterState)}
|
||||
parameters={{
|
||||
popoverContentProps: {
|
||||
side: "bottom",
|
||||
align: "end",
|
||||
sideOffset: 7,
|
||||
},
|
||||
popoverTriggerClassName: cn(
|
||||
"w-[29px] px-1 py-1 rounded shadow-l outline-[2px] outline-stone-600/20 ",
|
||||
"dark:text-slate-600 dark:hover:text-slate-700 bg-stone-50 hover:bg-stone-200 dark:bg-stone-200 dark:hover:bg-stone-300 dark:active:bg-stone-300",
|
||||
isFilterDirty(filterState)
|
||||
? "text-slate-100 dark:text-slate-100 bg-green-600 dark:bg-green-600 hover:bg-green-700 dark:hover:bg-green-700 hover:text-slate-200 dark:hover:text-slate-200 active:bg-green-800 dark:active:bg-green-800 outline-green-600 dark:outline-green-700"
|
||||
: "",
|
||||
),
|
||||
triggerIcon: <FunnelIcon className="w-[21px]" />,
|
||||
showTextSearch: true,
|
||||
}}
|
||||
/>
|
||||
|
||||
<MapLayerTool
|
||||
visibilityState={visibilityState}
|
||||
setVisibilityState={setVisibilityState}
|
||||
heatmapMode={heatmapMode}
|
||||
setHeatmapMode={setHeatmapMode}
|
||||
/>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default MapPage;
|
||||
Reference in New Issue
Block a user