Added map and dashboard

This commit is contained in:
2025-09-09 11:30:30 +10:00
parent 14453c32fe
commit 27f8b1da20
7 changed files with 637 additions and 14 deletions

110
app/page.tsx Normal file
View File

@ -0,0 +1,110 @@
import { useState, useEffect } from "react";
import { MapContainer, TileLayer, Marker, useMapEvents } from "react-leaflet";
import "leaflet/dist/leaflet.css";
import L from "leaflet";
// Delete default Leaflet marker icon to avoid errors
delete (L.Icon.Default as any)._getIconUrl;
// Helpers and types from lib/api.ts
const IMEI = "your_device_imei_here"; // Replace with dynamic if multiple devices
export default function Dashboard() {
const [telemetry, setTelemetry] = useState<TelemetryPost | null>(null);
const [commands, setCommands] = useState<DeviceCommand[]>([]);
const [fence, setFence] = useState<{ lat: number; lng: number }[]>([]); // Geo-fence points
const [commandType, setCommandType] = useState<DeviceCommandType>("sleep");
const [payload, setPayload] = useState<string>(""); // JSON string for form
// Poll for latest telemetry and commands
useEffect(() => {
const interval = setInterval(async () => {
try {
const latestGPS = await getLatestGPS(IMEI);
setTelemetry(latestGPS);
const cmds = await getCommands(IMEI);
setCommands(cmds);
} catch (e) {
console.error(e);
}
}, 5000); // Poll every 5s; adjust
return () => clearInterval(interval);
}, []);
// Send command from form
const sendCommand = async () => {
try {
const parsedPayload = JSON.parse(payload); // Validate in real app
await postReceipt(IMEI, { command_id: 0, result: "ok" }); // Stub receipt (adjust)
// Send actual command via your API if needed
setPayload("");
} catch (e) {
console.error(e);
}
};
// Map component (geo-fence drawing)
function FenceMap() {
const map = useMapEvents({
click: (e) => setFence([...fence, { lat: e.latlng.lat, lng: e.latlng.lng }]),
});
// Draw fence as polyline
if (fence.length > 1) L.polyline(fence, { color: "red" }).addTo(map);
// Marker for latest GPS
if (telemetry) L.marker([telemetry.lat, telemetry.lng]).addTo(map);
return null;
}
return (
<main className="p-4">
<h1>Sams Home Network Device Dashboard</h1>
<section className="grid grid-cols-2 gap-4">
<div>
<h2>Latest Telemetry</h2>
{telemetry ? (
<pre>{JSON.stringify(telemetry, null, 2)}</pre>
) : "Loading telemetry..."}
<p>WiFi: {telemetry?.raw?.wifiStatus ?? 'Unknown'}</p>
<p>Battery: {telemetry?.battery_percent ?? 'Unknown'}%</p>
<p>Car On: {telemetry?.is_car_on ? 'Yes' : 'No'}</p>
</div>
<div>
<h2>Map & Geo-Fence</h2>
<MapContainer center={[0, 0]} zoom={3} style={{ height: "300px" }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<FenceMap />
</MapContainer>
<p>Click map to add fence points: {fence.length}</p>
<button onClick={() => postTelemetry(IMEI, fence[0])}>Set Fence</button> {/* Example */}
</div>
</section>
<section>
<h2>Queued Commands</h2>
<pre>{JSON.stringify(commands, null, 2)}</pre>
<button onClick={() => postReceipt(IMEI, { command_id: commands[0]?.id, result: "ok" })}>Acknowledge First</button>
</section>
<section>
<h2>Send Command</h2>
<select value={commandType} onChange={(e) => setCommandType(e.target.value as DeviceCommandType)}>
<option value="lights">Lights On/Off</option>
<option value="camera">Camera On/Off</option>
<option value="sleep">Sleep</option>
<option value="telemetry_sec">Posting Frequency</option>
<option value="poll_sec">Poll Frequency</option>
<option value="ota">OTA Update</option>
<option value="ring_fence">Geo Fence</option>
</select>
<textarea value={payload} onChange={(e) => setPayload(e.target.value)} placeholder='{ "on": true }' />
<button onClick={sendCommand}>Send</button>
</section>
</main>
);
}

View File

@ -0,0 +1,108 @@
// lib/api.ts - API helpers with Zod validation for your Laravel endpoints
import { z } from "zod";
export type DeviceCommandType =
| "wifi"
| "sleep"
| "telemetry_sec"
| "poll_sec"
| "ota"
| "ring_fence"
| "lights"
| "camera";
export interface DeviceCommand {
id: number;
type: DeviceCommandType;
payload: Record<string, unknown>;
}
export interface TelemetryPost {
recorded_at?: string;
lat: number;
lng: number;
altitude_m?: number;
speed_kmh?: number;
heading_deg?: number;
accuracy_m?: number;
battery_percent?: number;
is_car_on?: boolean;
raw?: Record<string, unknown>;
}
export interface CommandReceiptPost {
command_id: number;
acked_at?: string;
executed_at?: string;
result?: "ok" | "error";
result_detail?: string;
}
// Zod schemas for validation
const telemetrySchema = z.object({
recorded_at: z.string().optional(),
lat: z.number(),
lng: z.number(),
altitude_m: z.number().optional(),
speed_kmh: z.number().optional(),
heading_deg: z.number().optional(),
accuracy_m: z.number().optional(),
battery_percent: z.number().optional(),
is_car_on: z.boolean().optional(),
raw: z.record(z.unknown()).optional(),
});
const receiptSchema = z.object({
command_id: z.number(),
acked_at: z.string().optional(),
executed_at: z.string().optional(),
result: z.enum(["ok", "error"]).optional(),
result_detail: z.string().optional(),
});
// API base from हुए env (add to .env: NEXT_PUBLIC_API_BASE=https://laravel-server.lab.audasmedia.com.au)
const API_BASE = process.env.NEXT_PUBLIC_API_BASE ?? "";
async function http<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${API_BASE}${path}`, {
method: "GET",
headers: { "Accept": "application/json", ...(init?.headers ?? {}) },
cache: "no-store",
...init,
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status} ${res.statusText}: ${text}`);
}
const text = await res.text();
return (text ? JSON.parse(text) : null) as T;
}
export async function postTelemetry(imei: string, body: TelemetryPost) {
const validatedBody = telemetrySchema.parse(body);
return http<{ ok: true }>(`/api/device/${imei}/telemetry`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(validatedBody),
});
}
export async function getCommands(imei: string, since?: string) {
const q = since ? `?since=${encodeURIComponent(since)}` : "";
return http<DeviceCommand[]>(`/api/device/${imei}/commands${q}`);
}
export async function postReceipt(imei: string, body: CommandReceiptPost) {
const validatedBody = receiptSchema.parse(body);
return http<{ ok: true }>(`/api/device/${imei}/command-receipts`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(validatedBody),
});
}
// Optional: Get latest GPS (adjust if your endpoint is /api/gps/latest-any)
export async function getLatestGPS(imei: string) {
return http<{ lat: number; lng: number; /* other fields */ }>(`/api/gps/latest-any?imei=${imei}`);
}

45
lib/map.tsx Normal file
View File

@ -0,0 +1,45 @@
// lib/map.tsx - Custom叶 map component with Leaflet for GPS location and geo-fence drawing
import { useState } from "react";
import { MapContainer, TileLayer, Marker, Polyline, useMapEvents } from "react-leaflet";
import L from "leaflet";
import "leaflet/dist/leaflet.css";
// Fix default Leaflet icon issue in React
delete (L.Icon.Default as any)._getIconUrl;
interface MapProps {
latestGPS?: { lat: number; lng: number };
onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void;
}
const FenceMap = ({ latestGPS, onFenceChange }: MapProps) => {
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]);
const map = useMapEvents({
click: (e) => {
const newFence = [...fence, { lat: e.latlng.lat, lng: e.latlng.lng }];
setFence(newFence);
onFenceChange(newFence); // Update parent
},
});
const center = latestGPS ? [latestGPS.lat, latestGPS.lng] : [0, 0]; // Default center
return (
<>
{fence.length > 1 && <Polyline pathOptions={{ color: "red" }} positions={fence} />}
{latestGPS && <Marker position={[latestGPS.lat, latestGPS.lng]} />}
</>
);
};
export const DeviceMap = ({ latestGPS }: { latestGPS?: { lat: number; lng: number } }) => {
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]);
return (
<MapContainer center={[0, 0]} zoom={2} style={{ height: "300px", width: "100%" }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<FenceMap latestGPS={latestGPS} onFenceChange={setFence} />
</MapContainer>
);
};

36
lib/types.ts Normal file
View File

@ -0,0 +1,36 @@
export type DeviceCommandType =
| "wifi"
| "sleep"
| "telemetry_sec"
| "poll_sec"
| "ota"
| "ring_fence"
| "lights"
| "camera";
export interface DeviceCommand {
id: number;
type: DeviceCommandType;
payload: Record<string, unknown>;
}
export interface TelemetryPost {
recorded_at?: string;
lat: number;
lng: number;
altitude_m?: number;
speed_kmh?: number;
heading_deg?: number;
accuracy_m?: number;
battery_percent?: number;
is_car_on?: boolean;
raw?: Record<string, unknown>;
}
export interface CommandReceiptPost {
command_id: number;
acked_at?: string;
executed_at?: string;
result?: "ok" | "error";
result_detail?: string;
}

47
package-lock.json generated
View File

@ -10,14 +10,17 @@
"hasInstallScript": true,
"dependencies": {
"@prisma/client": "^6.5.0",
"@react-leaflet/core": "^3.0.0",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"leaflet": "^1.9.4",
"next": "^15.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"server-only": "^0.0.1",
"superjson": "^2.2.1",
"zod": "^3.24.2"
@ -25,6 +28,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/leaflet": "^1.9.20",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
@ -981,6 +985,16 @@
"@prisma/debug": "6.15.0"
}
},
"node_modules/@react-leaflet/core": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@react-leaflet/core/-/core-3.0.0.tgz",
"integrity": "sha512-3EWmekh4Nz+pGcr+xjf0KNyYfC3U2JjnkWsh0zcqaexYqmmB5ZhH37kz41JXGmKzpaMZCnPofBBm64i+YrEvGQ==",
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/@rtsao/scc": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz",
@ -1392,6 +1406,12 @@
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true
},
"node_modules/@types/geojson": {
"version": "7946.0.16",
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
"dev": true
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@ -1404,6 +1424,15 @@
"integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==",
"dev": true
},
"node_modules/@types/leaflet": {
"version": "1.9.20",
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.20.tgz",
"integrity": "sha512-rooalPMlk61LCaLOvBF2VIf9M47HgMQqi5xQ9QRi7c8PkdIe0WrIi5IxXUXQjAdL0c+vcQ01mYWbthzmp9GHWw==",
"dev": true,
"dependencies": {
"@types/geojson": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.13",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.13.tgz",
@ -4304,6 +4333,11 @@
"node": ">=0.10"
}
},
"node_modules/leaflet": {
"version": "1.9.4",
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="
},
"node_modules/levn": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@ -5346,6 +5380,19 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
"node_modules/react-leaflet": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/react-leaflet/-/react-leaflet-5.0.0.tgz",
"integrity": "sha512-CWbTpr5vcHw5bt9i4zSlPEVQdTVcML390TjeDG0cK59z1ylexpqC6M1PJFjV8jD7CF+ACBFsLIDs6DRMoLEofw==",
"dependencies": {
"@react-leaflet/core": "^3.0.0"
},
"peerDependencies": {
"leaflet": "^1.9.0",
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",

View File

@ -22,14 +22,17 @@
},
"dependencies": {
"@prisma/client": "^6.5.0",
"@react-leaflet/core": "^3.0.0",
"@t3-oss/env-nextjs": "^0.12.0",
"@tanstack/react-query": "^5.69.0",
"@trpc/client": "^11.0.0",
"@trpc/react-query": "^11.0.0",
"@trpc/server": "^11.0.0",
"leaflet": "^1.9.4",
"next": "^15.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-leaflet": "^5.0.0",
"server-only": "^0.0.1",
"superjson": "^2.2.1",
"zod": "^3.24.2"
@ -37,6 +40,7 @@
"devDependencies": {
"@eslint/eslintrc": "^3.3.1",
"@tailwindcss/postcss": "^4.0.15",
"@types/leaflet": "^1.9.20",
"@types/node": "^20.14.10",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",

View File

@ -1,20 +1,293 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
generator client {
provider = "prisma-client-js"
model Device {
id String @id @default(uuid())
imei String @unique
name String?
telemetry Telemetry[]
@@map("devices")
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
model Telemetry {
id String @id @default(uuid())
deviceId String
device Device @relation(fields: [deviceId], references [id])
recordedAt DateTime @default(now)
lat Float
lng Float
altitude Float?
speed Float?
heading Float?
accuracy Float?
battery Float?
isCarOn Boolean?
raw Json?
@@map("telemetry")
}
model Post {
model Command {
id Int @id @default(autoincrement())
name String
deviceId String
device Device @relation(fields: [deviceId], references [id])
type String
payload Json
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@map("commands")
}iniclude <HardwareSerial.h>
@@index([name])
// Pins
#define LTE_RX_PIN 16 // ESP32 RX <- SIM7080 TX
#define LTE_TX_PIN 17 // ESP32 TX -> SIM7080 RX
#define PWRKEY_PIN 4 // SIM7080 PWRKEY
// Network and endpoint
const char* APN = "hologram";
const char* BASE_URL = "http://laravel-server.lab.audasmedia.com.au";
const char* PATH = "/api/gps";
// -------- Utilities --------
String sendAT(const String& cmd, uint32_t timeoutMs) {
Serial.print(">> "); Serial.println(cmd);
while (Serial2.available()) (void)Serial2.read();
Serial2.println(cmd);
String resp;
uint32_t t0 = millis();
while (millis() - t0 < timeoutMs) {
while (Serial2.available()) {
char c = (char)Serial2.read();
resp += c;
Serial.print(c);
t0 = millis(); // extend timeout on input
}
}
resp.trim();
Serial.println("\n[Resp]\n" + resp);
return resp;
}
void powerOnSIM7080() {
pinMode(PWRKEY_PIN, OUTPUT);
digitalWrite(PWRKEY_PIN, LOW);
delay(2000);
digitalWrite(PWRKEY_PIN, HIGH);
delay(1000);
digitalWrite(PWRKEY_PIN, LOW);
delay(30000); // full boot
}
bool waitLTE(uint32_t ms = 120000) {
uint32_t t0 = millis();
while (millis() - t0 < ms) {
String r = sendAT("AT+CEREG?", 4000);
if (r.indexOf("+CEREG: 0,1") != -1 || r.indexOf("+CEREG: 0,5") != -1) return true;
delay(3000);
}
return false;
}
// Simple reusable HTTP POST via SH client
bool httpPostSH(const String& baseUrl, const String& path, const String& jsonBody) {
// Configure HTTP client
sendAT("AT+SHCONF=\"URL\",\"" + baseUrl + "\"", 5000);
sendAT("AT+SHCONF=\"BODYLEN\",1024", 2000);
sendAT("AT+SHCONF=\"HEADERLEN\",350", 2000);
// Connect (no micromanagement; rely on OK)
String r = sendAT("AT+SHCONN", 15000);
if (r.indexOf("OK") == -1) {
Serial.println("SHCONN failed");
return false;
}
// Clear and set headers
sendAT("AT+SHCHEAD", 2000);
sendAT("AT+SHAHEAD=\"Content-Type\",\"application/json\"", 2000);
// Send body
String cmd = "AT+SHBOD=" + String(jsonBody.length()) + ",10000";
sendAT(cmd, 2000);
Serial2.print(jsonBody);
delay(200);
// POST (3 = POST)
r = sendAT("AT+SHREQ=\"" + path + "\",3", 20000);
// Expect URC like: +SHREQ: "POST",<HTTP_CODE>,<len>
// Print status line if present
int p = r.indexOf("+SHREQ:");
if (p != -1) {
Serial.println("Status/URC: " + r.substring(p));
}
// If there is a body, try to read (non-fatal if none)
sendAT("AT+SHREAD=0,2048", 4000);
// Disconnect
sendAT("AT+SHDISC", 3000);
return true;
}
// -------- Arduino setup/loop --------
void setup() {
Serial.begin(115200);
Serial2.begin(9600, SERIAL_8N1, LTE_RX_PIN, LTE_TX_PIN);
Serial.println("--- SIM7080: Simple Connect + HTTP POST ---");
powerOnSIM7080();
// Basic checks
sendAT("AT", 2000);
sendAT("AT+CMEE=1", 2000);
sendAT("AT+CPIN?", 2000);
sendAT("AT+CSQ", 2000);
// Force CatM1 + APN attach
sendAT("AT+CNMP=38", 5000);
sendAT("AT+CMNB=1", 5000);
sendAT("AT+CGDCONT=1,\"IP\",\"" + String(APN) + "\"", 5000);
sendAT("AT+CGATT=1", 10000);
// Wait for LTE registration
if (!waitLTE()) {
Serial.println("No LTE registration; stopping.");
while (true) delay(1000);
}
// Activate SIM7080 APP PDP (required for HTTP)
sendAT("AT+CNCFG=0,1,\"" + String(APN) + "\"", 5000);
sendAT("AT+CNACT=0,1", 45000);
sendAT("AT+CNACT?", 3000);
smsSetup();
// Optional DNS
sendAT("AT+CDNSCFG=\"8.8.8.8\",\"1.1.1.1\"", 3000);
// Build your JSON (adjust as needed)
String json = "{\"device_id\":\"sim7080g-01\",\"lat\":-33.865143,"
"\"lng\":151.2099,\"speed\":12.5,\"altitude\":30.2}";
// Do the POST
bool ok = httpPostSH(BASE_URL, PATH, json);
Serial.println(ok ? "HTTP POST sent (see status above)" : "HTTP POST failed");
Serial.println("--- Done ---");
}
void loop() {
// Non-blocking read to capture unsolicited lines like +CMTI
static String urc;
while (Serial2.available()) {
char c = (char)Serial2.read();
urc += c;
if (c == '\n') {
urc.trim();
if (urc.startsWith("+CMTI:")) {
// +CMTI: "SM",<idx>
int comma = urc.lastIndexOf(',');
if (comma != -1) {
int idx = urc.substring(comma + 1).toInt();
String body = smsReadAndDelete(idx);
Serial.println("SMS (URC): " + body);
handleSmsCommand(body);
}
}
urc = "";
}
}
// Optional periodic poll in case URC was missed
static uint32_t lastPoll = 0;
if (millis() - lastPoll > 60000) { // every 60 s
smsPollUnread();
lastPoll = millis();
}
// Your normal work here...
}
// Call once after PDP is active (or right after registration)
void smsSetup() {
sendAT("AT+CMGF=1", 2000); // text mode
sendAT("AT+CPMS=\"SM\",\"SM\",\"SM\"", 2000); // SIM storage
sendAT("AT+CNMI=2,1,0,0,0", 2000); // new SMS URC: +CMTI: "SM",<idx>
// Optional: show extended text params
// sendAT("AT+CSDH=1", 2000);
}
// Read & delete one SMS by index; returns the body
String smsReadAndDelete(int idx) {
String r = sendAT("AT+CMGR=" + String(idx), 4000);
// Extract last non-empty line before OK
int okPos = r.lastIndexOf("\nOK");
String s = (okPos>0) ? r.substring(0, okPos) : r;
int lastLf = s.lastIndexOf('\n');
if (lastLf != -1) s = s.substring(lastLf + 1);
s.trim();
sendAT("AT+CMGD=" + String(idx), 2000);
return s;
}
// Optional: poll unread SMS (if you want to scan periodically)
void smsPollUnread() {
String list = sendAT("AT+CMGL=\"REC UNREAD\"", 5000);
// list may contain many entries like:
// +CMGL: <idx>,"REC UNREAD",...
int pos = 0;
while (true) {
int p = list.indexOf("+CMGL:", pos);
if (p == -1) break;
int comma = list.indexOf(',', p);
if (comma == -1) break;
// Extract index after "+CMGL: "
int idxStart = list.indexOf(' ', p);
if (idxStart == -1 || idxStart > comma) break;
int idx = list.substring(idxStart + 1, comma).toInt();
String body = smsReadAndDelete(idx);
Serial.println("SMS (polled): " + body);
handleSmsCommand(body);
pos = comma + 1;
}
}
void handleSmsCommand(String msg) {
Serial.println("Command received via SMS: " + msg);
msg.trim();
if (msg.isEmpty()) {
Serial.println("Empty body");
return;
}
// Very simple JSON extraction: look for device_id and speed keys
auto extract = [&](const String& key)->String {
String pat = String("\"") + key + "\":";
int k = msg.indexOf(pat);
if (k < 0) return "";
k += pat.length();
// Skip quotes/spaces
while (k < (int)msg.length() && (msg[k]==' ' || msg[k]=='\"')) k++;
// If quoted value
if (k < (int)msg.length() && msg[k-1] == '\"') {
int end = msg.indexOf('\"', k);
if (end > k) return msg.substring(k, end);
}
// Numeric value
int end = k;
while (end < (int)msg.length() && (isDigit(msg[end]) || msg[end]=='.' || msg[end]=='-' )) end++;
return msg.substring(k, end);
};
String deviceId = extract("device_id");
String speed = extract("speed");
Serial.println("Parsed device_id=" + deviceId + " speed=" + speed);
// Example action: if speed present
if (speed.length()) {
Serial.println("Action: set speed to " + speed);
// TODO: apply speed to your app logic
}
}