From add1b2d00869c628ad1a6109c4067f126f6ab274 Mon Sep 17 00:00:00 2001 From: Reko Peltola <4742341+rpeltola@users.noreply.github.com> Date: Wed, 1 Jul 2026 02:07:54 +0300 Subject: [PATCH] Add planned-path map layer Draws the slic3r planned mowing path as a grey overlay (outline passes lighter/dashed) over the track, behind a persisted 'Planned path' toggle (default off), hidden in edit mode. The plan is fetched from history per area and assembled client-side, for the current job as well as a selected past one. The live map_layers/planned_path/json topic is treated as a {job_id, step_index} signal; useJobPlannedPath (mirroring useJobTrack) fetches the whole plan via planned_path.history (step list) + planned_path.history.step (one area each, in small batches), re-fetching when a new area is planned. Because the plan is held client-side it stays visible after the job is cancelled/finished -- like the driven track -- and the whole in-progress job is shown, not just the current area. No single RPC response carries the whole job; a newer selection supersedes an in-flight fetch and a real per-step failure fails the load rather than rendering a partial path. Adds the RPCs to openrpc.json and regenerates rpc.ts. --- openrpc.json | 130 ++++++++++++++++++++++++ src/components/map/LayersButton.tsx | 14 +++ src/components/map/MowerMap.tsx | 7 +- src/components/map/PlannedPathLayer.tsx | 62 +++++++++++ src/hooks/useJobPlannedPath.ts | 99 ++++++++++++++++++ src/lib/rpc.ts | 42 +++++++- src/stores/mapDisplayStore.ts | 4 + src/stores/mowersStore.ts | 15 +++ src/stores/schemas.ts | 29 ++++++ 9 files changed, 400 insertions(+), 2 deletions(-) create mode 100644 src/components/map/PlannedPathLayer.tsx create mode 100644 src/hooks/useJobPlannedPath.ts diff --git a/openrpc.json b/openrpc.json index b61e5b3..4158e43 100644 --- a/openrpc.json +++ b/openrpc.json @@ -174,6 +174,136 @@ } } }, + { + "name": "planned_path.history", + "summary": "List a planned-path job's steps (metadata only); fetch each area's geometry via planned_path.history.step.", + "paramStructure": "by-name", + "params": [ + { + "name": "job_id", + "description": "Job ID to fetch the planned path for. Omit to get the current/live job.", + "required": false, + "schema": { + "type": "string" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "object", + "properties": { + "job_id": { + "type": "string" + }, + "steps": { + "type": "array", + "items": { + "type": "object", + "properties": { + "step_index": { + "type": "number" + }, + "area_id": { + "type": "string" + }, + "angle": { + "type": "number" + }, + "offset": { + "type": "number" + } + } + } + } + } + } + } + }, + { + "name": "planned_path.history.step", + "summary": "Returns one area/step's planned-path geometry for a job (bounded per-area payload).", + "paramStructure": "by-name", + "params": [ + { + "name": "job_id", + "description": "Job ID.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "step_index", + "description": "Ordinal step/area index within the job.", + "required": true, + "schema": { + "type": "number" + } + } + ], + "result": { + "name": "result", + "schema": { + "type": "object", + "properties": { + "job_id": { + "type": "string" + }, + "step_index": { + "type": "number" + }, + "paths": { + "type": "array", + "items": { + "type": "object", + "properties": { + "is_outline": { + "type": "boolean" + }, + "points": { + "type": "array", + "items": { + "type": "array", + "items": { + "type": "number" + } + } + } + } + } + } + } + } + } + }, + { + "name": "planned_path.history.list", + "summary": "List available planned-path job history entries.", + "params": [], + "result": { + "name": "jobs", + "description": "Array of job history entries, each with a job_id and epoch timestamp. Sorted newest first.", + "schema": { + "type": "array", + "items": { + "type": "object", + "properties": { + "job_id": { + "type": "string" + }, + "timestamp": { + "type": "number" + } + }, + "required": [ + "job_id", + "timestamp" + ] + } + } + } + }, { "name": "events.history", "summary": "Returns events for today when called without params, or events for a specific date.", diff --git a/src/components/map/LayersButton.tsx b/src/components/map/LayersButton.tsx index beedf34..952741a 100644 --- a/src/components/map/LayersButton.tsx +++ b/src/components/map/LayersButton.tsx @@ -31,9 +31,11 @@ export default function LayersButton({datum, trackLoading, editMode}: LayersButt const { showSatelliteLayer, showTrackLayer, + showPlannedPath, selectedJobId, setShowSatelliteLayer, setShowTrackLayer, + setShowPlannedPath, setSelectedJobId, } = useMapDisplayStore(); @@ -134,6 +136,18 @@ export default function LayersButton({datum, trackLoading, editMode}: LayersButt label="Satellite" /> + setShowPlannedPath(e.target.checked)} + disabled={editMode} + /> + } + label="Planned path" + /> + {hasPositionCapability && ( <> ({top: 10, bottom: 10, left: 60, right: showAreaList ? 390 : 60}), [showAreaList]); const fitToBounds = useFitToBounds(); @@ -294,6 +298,7 @@ export function MowerMap({mapData, saveMapToMower, sx}: MowerMapProps) { ))} {mowerPosition && !isDocked && } + {showTeleop && } diff --git a/src/components/map/PlannedPathLayer.tsx b/src/components/map/PlannedPathLayer.tsx new file mode 100644 index 0000000..6fae42c --- /dev/null +++ b/src/components/map/PlannedPathLayer.tsx @@ -0,0 +1,62 @@ +'use client'; + +import type {Datum, PlannedPath} from '@/stores/schemas'; +import {datumToRelative, pointToAbsolute} from '@/utils/coordinates'; +import {featureCollection} from '@turf/helpers'; +import type {Feature, FeatureCollection, LineString} from 'geojson'; +import type {ExpressionSpecification, LineLayerSpecification} from 'maplibre-gl'; +import {RLayer, RSource} from 'maplibre-react-components'; +import {useMemo} from 'react'; + +type PathProps = {is_outline: boolean}; + +// Interior fill passes: solid grey. Outline (perimeter) passes: lighter, dashed. +const linePaint: LineLayerSpecification['paint'] = { + 'line-color': ['case', ['==', ['get', 'is_outline'], true], '#aaaaaa', '#888888'] as ExpressionSpecification, + 'line-width': 1.5, + 'line-opacity': ['case', ['==', ['get', 'is_outline'], true], 0.7, 0.8] as ExpressionSpecification, + 'line-dasharray': ['case', ['==', ['get', 'is_outline'], true], ['literal', [2, 2]], ['literal', [1, 0]]], +}; + +const emptyCollection: FeatureCollection = featureCollection([]); + +interface PlannedPathLayerProps { + visible?: boolean; + datum: Datum | null; + // The whole-job plan to draw (fetched from history for the current or a selected past job). + plannedPath?: PlannedPath | null; +} + +/** + * Planned-path layer: renders the slic3r planned mowing path as grey lines in the map frame. + * The plan is fetched from history (via useJobPlannedPath) for whichever job is shown -- current or + * a selected past one -- so it stays visible after the job ends. Outline (perimeter) passes are + * drawn lighter/dashed. Rendered declaratively as a GeoJSON FeatureCollection of LineStrings. + */ +export default function PlannedPathLayer({visible = true, datum, plannedPath = null}: PlannedPathLayerProps) { + const data = useMemo>(() => { + if (!plannedPath || !datum) return emptyCollection; + const utm = datumToRelative([datum.long, datum.lat]); + const features: Feature[] = plannedPath.paths + .filter((path) => path.points.length >= 2) + .map((path) => ({ + type: 'Feature', + properties: {is_outline: path.is_outline}, + geometry: { + type: 'LineString', + coordinates: path.points.map(([x, y]) => pointToAbsolute({x, y}, utm)), + }, + })); + return featureCollection(features); + }, [plannedPath, datum]); + + const visibility = visible ? 'visible' : 'none'; + const layout: LineLayerSpecification['layout'] = {'line-join': 'round', 'line-cap': 'round', visibility}; + + return ( + <> + + + + ); +} diff --git a/src/hooks/useJobPlannedPath.ts b/src/hooks/useJobPlannedPath.ts new file mode 100644 index 0000000..40f2390 --- /dev/null +++ b/src/hooks/useJobPlannedPath.ts @@ -0,0 +1,99 @@ +import {useSelectedMower} from '@/stores/mowersStore'; +import {plannedPathSchema, type PlannedPath} from '@/stores/schemas'; +import {useCallback, useEffect, useRef, useState} from 'react'; + +type FetchState = + | {status: 'idle'} + | {status: 'loading'; jobId: string} + | {status: 'loaded'; jobId: string; planned: PlannedPath | null} + | {status: 'error'; jobId: string}; + +/** + * Fetches a job's whole planned path from history (one area at a time) and assembles it client-side. + * + * Used for BOTH a selected past job AND the current/live job: the live MQTT topic is only a + * {job_id, step_index} signal, so the plan is fetched and held client-side and therefore stays + * visible after the job is cancelled/finished, exactly like the driven track. The signal picks the + * current job and triggers a re-fetch when a new area is planned (step_index advances). + */ +export function useJobPlannedPath(selectedJobId: string | null): {plannedPath: PlannedPath | null; loading: boolean} { + const rpc = useSelectedMower((s) => s?.rpc); + const liveJobId = useSelectedMower((s) => s?.plannedPathSignal?.job_id ?? null); + const liveStep = useSelectedMower((s) => s?.plannedPathSignal?.step_index ?? null); + + // The job to display: an explicitly selected past job, else the current/live job. + const jobId = selectedJobId ?? liveJobId; + const isLive = jobId !== null && jobId === liveJobId; + + // Cache assembled plans by job_id. Past jobs are immutable; the live job grows as it mows, so it + // is always re-fetched (its cache entry is overwritten each time). + const cache = useRef>(new Map()); + const latestJobId = useRef(null); + const [state, setState] = useState({status: 'idle'}); + + const fetchJob = useCallback( + async (id: string, useCache: boolean) => { + if (!rpc) return; + latestJobId.current = id; + + if (useCache) { + const cached = cache.current.get(id); + if (cached !== undefined) { + setState({status: 'loaded', jobId: id, planned: cached}); + return; + } + } + + // Only show a spinner if we have nothing to display for this job yet; a live-job re-fetch keeps + // the current plan on screen (no flicker) until the grown plan arrives. + setState((prev) => (prev.status === 'loaded' && prev.jobId === id ? prev : {status: 'loading', jobId: id})); + try { + // Fetch the job's step list (metadata only), then each area's geometry separately so no single + // MQTT message carries the whole job. Fetch in small batches and do NOT swallow a real failure + // (timeout / transport) -- that fails the load rather than rendering a partial path. A + // genuinely empty/missing step resolves to [] (the backend returns it as a result). + const stepList = await rpc.planned_path.history({job_id: id}); + const stepIndices = (stepList?.steps ?? []) + .map((s) => s.step_index) + .filter((i): i is number => typeof i === 'number'); + + const batches: unknown[][] = []; + const BATCH = 6; + for (let i = 0; i < stepIndices.length; i += BATCH) { + const batch = await Promise.all( + stepIndices + .slice(i, i + BATCH) + .map((step_index) => + rpc.planned_path.history.step({job_id: id, step_index}).then((r) => (r?.paths ?? []) as unknown[]), + ), + ); + batches.push(...batch); + } + + const parsed = plannedPathSchema.safeParse({job_id: id, paths: batches.flat()}); + const planned = parsed.success ? parsed.data : null; + cache.current.set(id, planned); + // Ignore the result if a newer job was selected while we were fetching. + if (latestJobId.current === id) setState({status: 'loaded', jobId: id, planned}); + } catch { + if (latestJobId.current === id) setState({status: 'error', jobId: id}); + } + }, + [rpc], + ); + + useEffect(() => { + if (!jobId) { + latestJobId.current = null; + setState({status: 'idle'}); + return; + } + // Past jobs are immutable -> use the cache. The live job grows per area -> re-fetch fresh, and + // re-run whenever its step advances (liveStep is a trigger dependency). + void fetchJob(jobId, !isLive); + }, [jobId, isLive, liveStep, fetchJob]); + + if (!jobId) return {plannedPath: null, loading: false}; + if (state.status === 'loaded' && state.jobId === jobId) return {plannedPath: state.planned, loading: false}; + return {plannedPath: null, loading: true}; +} diff --git a/src/lib/rpc.ts b/src/lib/rpc.ts index 323cab1..a93c8f9 100644 --- a/src/lib/rpc.ts +++ b/src/lib/rpc.ts @@ -25,6 +25,20 @@ export interface ObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19 { timestamp: NumberHo1ClIqD; [k: string]: any; } +export interface ObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDPm14NNHr { + step_index?: NumberHo1ClIqD; + area_id?: StringDoaGddGA; + angle?: NumberHo1ClIqD; + offset?: NumberHo1ClIqD; + [k: string]: any; +} +export type UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDPm14NNHrptS2Tk0D = ObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDPm14NNHr[]; +export interface ObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GBooleanVyG3AEThV8C6Hkpo { + is_outline?: BooleanVyG3AETh; + points?: UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5G; + [k: string]: any; +} +export type UnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GBooleanVyG3AEThV8C6Hkpokk7AmLb6 = ObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GBooleanVyG3AEThV8C6Hkpo[]; type AlwaysTrue = any; export interface ObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfB { id: StringDoaGddGA; @@ -53,6 +67,17 @@ export interface ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumbe [k: string]: any; } export type UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK = ObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19[]; +export interface ObjectOfUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDPm14NNHrptS2Tk0DStringDoaGddGA61SsNSC5 { + job_id?: StringDoaGddGA; + steps?: UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDPm14NNHrptS2Tk0D; + [k: string]: any; +} +export interface ObjectOfNumberHo1ClIqDUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GBooleanVyG3AEThV8C6Hkpokk7AmLb6StringDoaGddGAR79If3A2 { + job_id?: StringDoaGddGA; + step_index?: NumberHo1ClIqD; + paths?: UnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GBooleanVyG3AEThV8C6Hkpokk7AmLb6; + [k: string]: any; +} export type UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup = ObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfB[]; /** * @@ -65,7 +90,7 @@ export interface ObjectHicl3T4F { [key: string]: any; } * Generated! Represents an alias to any of the provided schemas * */ -export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGAStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4F = ObjectHAgrRKSz | StringDoaGddGA | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; +export type AnyOfObjectHAgrRKSzStringDoaGddGAStringDoaGddGAStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringZDJW5SIjUnorderedSetOfStringDoaGddGADvj0XlFaNullQu0Arl1FStringZDJW5SIjStringDoaGddGAObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1WsUnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKObjectOfUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDPm14NNHrptS2Tk0DStringDoaGddGA61SsNSC5ObjectOfNumberHo1ClIqDUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GBooleanVyG3AEThV8C6Hkpokk7AmLb6StringDoaGddGAR79If3A2UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvKUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETupUnorderedSetOfStringDoaGddGADvj0XlFaObjectHicl3T4F = ObjectHAgrRKSz | StringDoaGddGA | NumberHo1ClIqD | StringZDJW5SIj | UnorderedSetOfStringDoaGddGADvj0XlFa | NullQu0Arl1F | ObjectOfUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GObjectOfStringDoaGddGAStringDoaGddGABooleanVyG3AETh8PZSKeKMFruGLaE4Gkga5QR4UnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GA4JKc1Ws | UnorderedSetOfObjectOfNumberHo1ClIqDStringDoaGddGAJFf7IL19E3WiTXvK | ObjectOfUnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDPm14NNHrptS2Tk0DStringDoaGddGA61SsNSC5 | ObjectOfNumberHo1ClIqDUnorderedSetOfObjectOfUnorderedSetOfUnorderedSetOfNumberHo1ClIqDAokMKuEfbuExLS5GBooleanVyG3AEThV8C6Hkpokk7AmLb6StringDoaGddGAR79If3A2 | UnorderedSetOfObjectOfNumberHo1ClIqDNumberHo1ClIqDStringDoaGddGANumberHo1ClIqDStringDoaGddGAStringDoaGddGAStringDoaGddGABooleanVyG3AEThEg3PskfBvWnyETup | ObjectHicl3T4F; export class OpenMowerRpc extends OpenMowerBaseRpc { rpc = { @@ -113,6 +138,21 @@ export class OpenMowerRpc extends OpenMowerBaseRpc { list: async (): Promise => this.call('position.history.list'), }), }; + planned_path = { + /** + * List a planned-path job's steps (metadata only); fetch each area's geometry via planned_path.history.step. + */ + history: Object.assign(async (args: {job_id?: StringDoaGddGA}): Promise => this.call('planned_path.history', args), { + /** + * Returns one area/step's planned-path geometry for a job (bounded per-area payload). + */ + step: async (args: {job_id: StringDoaGddGA, step_index: NumberHo1ClIqD}): Promise => this.call('planned_path.history.step', args), + /** + * List available planned-path job history entries. + */ + list: async (): Promise => this.call('planned_path.history.list'), + }), + }; events = { /** * Returns events for today when called without params, or events for a specific date. diff --git a/src/stores/mapDisplayStore.ts b/src/stores/mapDisplayStore.ts index f45e27f..c9445f0 100644 --- a/src/stores/mapDisplayStore.ts +++ b/src/stores/mapDisplayStore.ts @@ -4,10 +4,12 @@ import {persist} from 'zustand/middleware'; interface MapDisplayStore { showSatelliteLayer: boolean; showTrackLayer: boolean; + showPlannedPath: boolean; showAreaList: boolean; selectedJobId: string | null; setShowSatelliteLayer: (v: boolean) => void; setShowTrackLayer: (v: boolean) => void; + setShowPlannedPath: (v: boolean) => void; setShowAreaList: (v: boolean) => void; setSelectedJobId: (v: string | null) => void; } @@ -17,10 +19,12 @@ export const useMapDisplayStore = create()( (set) => ({ showSatelliteLayer: false, showTrackLayer: true, + showPlannedPath: false, showAreaList: true, selectedJobId: null, setShowSatelliteLayer: (v) => set({showSatelliteLayer: v}), setShowTrackLayer: (v) => set({showTrackLayer: v}), + setShowPlannedPath: (v) => set({showPlannedPath: v}), setShowAreaList: (v) => set({showAreaList: v}), setSelectedJobId: (v) => set({selectedJobId: v}), }), diff --git a/src/stores/mowersStore.ts b/src/stores/mowersStore.ts index f081c72..5e070f8 100644 --- a/src/stores/mowersStore.ts +++ b/src/stores/mowersStore.ts @@ -27,12 +27,14 @@ import { legacyMapSchema, mapDefaults, mapSchema, + plannedPathSignalSchema, positionSchema, stateDefaults, stateSchema, type Capabilities, type Datum, type MapData, + type PlannedPathSignal, type PositionWithAttributes, type StateOptionalPose, type TrackAttributes, @@ -56,6 +58,7 @@ class Mower { params: Record = {}; position: PositionWithAttributes | null = null; track: TrackPipeline = new TrackPipeline(); + plannedPathSignal?: PlannedPathSignal; jobList: {job_id: string; epoch: number}[] | null = null; events: MowerEventState = mowerEventDefaults; @@ -161,6 +164,7 @@ export const useMowersStore = create()( client.subscribe(clientMower.prefix + 'position/json'); client.subscribe(clientMower.prefix + 'params/json'); client.subscribe(clientMower.prefix + 'events/json'); + client.subscribe(clientMower.prefix + 'map_layers/planned_path/json'); mowers[clientMower.idx].rpc.events.history .list() .then((dates) => { @@ -264,6 +268,17 @@ export const useMowersStore = create()( applyLiveEvent(state.mowers[idx].events, parsed.data); } }); + } else if (partialTopic === 'map_layers/planned_path/json') { + set((state) => { + // The live topic is just a {job_id, step_index} signal; an empty retained payload + // clears it, a malformed one is ignored. The geometry is fetched from history. + if (payload.length === 0) { + state.mowers[idx].plannedPathSignal = undefined; + } else { + const parsed = plannedPathSignalSchema.safeParse(JSON.parse(payload.toString())); + if (parsed.success) state.mowers[idx].plannedPathSignal = parsed.data; + } + }); } } }); diff --git a/src/stores/schemas.ts b/src/stores/schemas.ts index aa43abf..798c53d 100644 --- a/src/stores/schemas.ts +++ b/src/stores/schemas.ts @@ -92,6 +92,35 @@ export const mapSchema = z.object({ export type MapData = z.infer; +//////////////////////////////////////////////////////////////////////////////////////////////////// +// Planned path (slic3r planned path map layer) +//////////////////////////////////////////////////////////////////////////////////////////////////// + +// The planned mowing path, streamed over MQTT (map_layers/planned_path/json). +// Points are [x, y] in metres in the map frame; `is_outline` marks perimeter passes. +const xyTupleSchema = z.tuple([z.number(), z.number()]); + +const plannedPathEntrySchema = z.object({ + is_outline: z.boolean(), + points: z.array(xyTupleSchema), +}); +export type PlannedPathEntry = z.infer; + +export const plannedPathSchema = z.object({ + job_id: z.string(), + paths: z.array(plannedPathEntrySchema), +}); +export type PlannedPath = z.infer; + +// The live map_layers/planned_path/json topic is only a signal: which job is current and which step +// it is on. The actual geometry is fetched from history (planned_path.history / .step), so the plan +// is held client-side and stays visible after the job is cancelled/finished (like the driven track). +export const plannedPathSignalSchema = z.object({ + job_id: z.string(), + step_index: z.number().optional(), +}); +export type PlannedPathSignal = z.infer; + //////////////////////////////////////////////////////////////////////////////////////////////////// // Legacy map ////////////////////////////////////////////////////////////////////////////////////////////////////