prisma migration and fix dropdowns

This commit is contained in:
2025-09-11 14:12:15 +10:00
parent d6a5f13f89
commit 168cc87447
4 changed files with 139 additions and 353 deletions

View File

@ -1,302 +1,57 @@
// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema
// 1. Define the database connection
datasource db {
provider = "postgresql" // Or "mysql" if you decide to switch
url = env("DATABASE_URL")
}
// 2. Define the Prisma Client generator
generator client {
provider = "prisma-client-js"
}
// 3. Define your data models
model Device { model Device {
id String @id @default(uuid()) id String @id @default(uuid())
imei String @unique imei String @unique
name String? name String?
telemetry Telemetry[] telemetry Telemetry[]
@@map("devices") commands Command[]
}
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?
}
model Command {
id Int @id @default(autoincrement())
deviceId String
device Device @relation(fields: [deviceId], references: [id])
type String
payload Json
createdAt DateTime @default(now())
} }
model CommandTemplate { model CommandTemplate {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String // e.g., "Lights On", "Reboot Device" name String
type String // e.g., "lights", "reboot" type String
payload Json // The JSON payload, e.g., {"on": true} payload Json
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
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 Command {
id Int @id @default(autoincrement())
deviceId String
device Device @relation(fields: [deviceId], references [id])
type String
payload Json
createdAt DateTime @default(now())
@@map("commands")
}iniclude <HardwareSerial.h>
// 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
}
}

View File

@ -62,18 +62,25 @@ export function CommandManager({ selectedImei }: CommandManagerProps) {
placeholder="Template Name (e.g., Lights On)" placeholder="Template Name (e.g., Lights On)"
value={name} value={name}
onChange={(e) => setName(e.target.value)} onChange={(e) => setName(e.target.value)}
className="p-2 text-white rounded-md bg-white/10" className="p-2 text-white rounded-md bg-gray-800 border border-gray-600"
/> />
<select
value={type} <select
onChange={(e) => setType(e.target.value as DeviceCommandType)} value={type} // This value is tied to the 'type' state
className="p-2 text-white rounded-md bg-white/10" onChange={(e) => setType(e.target.value as DeviceCommandType)} // This updates the state when you select an option
> className="p-2 text-white rounded-md bg-gray-800 border border-gray-600"
<option value="reboot">Reboot Device</option> >
<option value="lights">Lights On/Off</option> <option value="reboot" className="bg-gray-800 text-white">Reboot Device</option>
{/* Add other types */} <option value="lights" className="bg-gray-800 text-white">Lights On/Off</option>
</select> <option value="camera" className="bg-gray-800 text-white">Camera On/Off</option>
<textarea <option value="sleep" className="bg-gray-800 text-white">Set Sleep Interval</option>
<option value="telemetry_sec" className="bg-gray-800 text-white">Set Telemetry Interval</option>
<option value="poll_sec" className="bg-gray-800 text-white">Set Poll Interval</option>
<option value="wifi" className="bg-gray-800 text-white">Set WiFi Credentials</option>
<option value="ring_fence" className="bg-gray-800 text-white">Set Geo-Fence</option>
<option value="ota" className="bg-gray-800 text-white">Firmware OTA</option>
</select>
<textarea
placeholder='JSON Payload, e.g., {"on": true}' placeholder='JSON Payload, e.g., {"on": true}'
value={payload} value={payload}
onChange={(e) => setPayload(e.target.value)} onChange={(e) => setPayload(e.target.value)}
@ -90,32 +97,41 @@ export function CommandManager({ selectedImei }: CommandManagerProps) {
</div> </div>
{/* List of saved templates */} {/* List of saved templates */}
<div className="flex flex-col gap-2 p-4 rounded-md bg-white/10"> // In file: src/app/_components/CommandManager.tsx
<h3 className="text-lg font-bold">Saved Commands</h3>
{templatesQuery.isLoading && <p>Loading templates...</p>} {/* List of saved templates */}
{templatesQuery.data?.map((template) => ( <div className="flex flex-col gap-2 p-4 rounded-md bg-white/10">
<div key={template.id} className="flex items-center justify-between p-2 rounded-md bg-black/20"> <h3 className="text-lg font-bold">Saved Commands</h3>
<div> {templatesQuery.isLoading && <p>Loading templates...</p>}
<p className="font-semibold">{template.name}</p>
<p className="text-sm text-gray-400">Type: {template.type}</p> {/* ADD THIS CHECK for an empty list */}
</div> {templatesQuery.data && templatesQuery.data.length === 0 && (
<div className="flex gap-2"> <p className="text-gray-400">No templates saved. Create one on the left!</p>
<button )}
onClick={() => handleSendCommand(template)}
className="px-3 py-1 text-white bg-green-600 rounded-md hover:bg-green-700" {templatesQuery.data?.map((template) => (
> <div key={template.id} className="flex items-center justify-between p-2 rounded-md bg-black/20">
Send <div>
</button> <p className="font-semibold">{template.name}</p>
<button <p className="text-sm text-gray-400">Type: {template.type}</p>
onClick={() => deleteTemplateMutation.mutate({ id: template.id })}
className="px-3 py-1 text-white bg-red-600 rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
))}
</div> </div>
<div className="flex gap-2">
<button
onClick={() => handleSendCommand(template)}
className="px-3 py-1 text-white bg-green-600 rounded-md hover:bg-green-700"
>
Send
</button>
<button
onClick={() => deleteTemplateMutation.mutate({ id: template.id })}
className="px-3 py-1 text-white bg-red-600 rounded-md hover:bg-red-700"
>
Delete
</button>
</div>
</div>
))}
</div>
</div> </div>
); );
} }

View File

@ -1,29 +1,36 @@
// In file: src/components/DeviceMapClient.tsx // In file: src/app/_components/DynamicDeviceMap.tsx
"use client"; "use client";
// This file now contains your DeviceMap component, ensuring it's always a client component. import { useState, useEffect } from "react";
import { useState } from "react"; import { MapContainer, TileLayer, Marker, Polyline, useMap, useMapEvents } from "react-leaflet";
import { MapContainer, TileLayer, Marker, Polyline, useMapEvents } from "react-leaflet";
import L from "leaflet"; import L from "leaflet";
import "leaflet/dist/leaflet.css"; import "leaflet/dist/leaflet.css";
// Fix default Leaflet icon issue in React // Fix default Leaflet icon paths
delete (L.Icon.Default as any)._getIconUrl; delete (L.Icon.Default as any)._getIconUrl;
L.Icon.Default.mergeOptions({ L.Icon.Default.mergeOptions({
iconRetinaUrl: '/leaflet/marker-icon-2x.png', iconRetinaUrl: '/leaflet/marker-icon-2x.png',
iconUrl: '/leaflet/marker-icon.png', iconUrl: '/leaflet/marker-icon.png',
shadowUrl: '/leaflet/marker-shadow.png', shadowUrl: '/leaflet/marker-shadow.png',
}); });
// Helper component that forces the map to update its view
function MapViewUpdater({ center }: { center: L.LatLngExpression }) {
const map = useMap();
useEffect(() => {
// When the 'center' prop changes, fly the map to the new location
map.flyTo(center, map.getZoom());
}, [center, map]);
return null;
}
interface MapProps { interface FenceMapProps {
latestGPS?: { lat: number; lng: number }; latestGPS?: { lat: number; lng: number };
onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void; onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void;
} }
const FenceMap = ({ latestGPS, onFenceChange }: MapProps) => { const FenceMap = ({ latestGPS, onFenceChange }: FenceMapProps) => {
const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]); const [fence, setFence] = useState<Array<{ lat: number; lng: number }>>([]);
useMapEvents({ useMapEvents({
click: (e) => { click: (e) => {
const newFence = [...fence, { lat: e.latlng.lat, lng: e.latlng.lng }]; const newFence = [...fence, { lat: e.latlng.lat, lng: e.latlng.lng }];
@ -31,7 +38,6 @@ const FenceMap = ({ latestGPS, onFenceChange }: MapProps) => {
onFenceChange(newFence); onFenceChange(newFence);
}, },
}); });
return ( return (
<> <>
{fence.length > 1 && <Polyline pathOptions={{ color: "red" }} positions={fence} />} {fence.length > 1 && <Polyline pathOptions={{ color: "red" }} positions={fence} />}
@ -40,13 +46,18 @@ const FenceMap = ({ latestGPS, onFenceChange }: MapProps) => {
); );
}; };
export const DeviceMap = ({ latestGPS, onFenceChange }: { latestGPS?: { lat: number; lng: number }; onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void; }) => { export const DeviceMap = ({ latestGPS, onFenceChange }: { latestGPS?: { lat: number; lng: number } | null; onFenceChange: (fence: Array<{ lat: number; lng: number }>) => void; }) => {
const center = latestGPS ? [latestGPS.lat, latestGPS.lng] : [-37.76, 145.04]; // Default center // Use live GPS if available, otherwise default to a reasonable fallback
const center: L.LatLngExpression = latestGPS ? [latestGPS.lat, latestGPS.lng] : [-37.76, 145.04];
// For debugging, let's see what coordinates this component is receiving
console.log("DeviceMap received latestGPS:", latestGPS);
return ( return (
<MapContainer center={center} zoom={13} style={{ height: "300px", width: "100%" }}> <MapContainer center={center} zoom={13} style={{ height: "300px", width: "100%" }}>
<TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" /> <TileLayer url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png" />
<FenceMap latestGPS={latestGPS} onFenceChange={onFenceChange} /> <FenceMap latestGPS={latestGPS} onFenceChange={onFenceChange} />
<MapViewUpdater center={center} /> {/* This component will handle map movement */}
</MapContainer> </MapContainer>
); );
}; };

View File

@ -56,14 +56,18 @@ export default function Dashboard() {
<p>Loading telemetry...</p> <p>Loading telemetry...</p>
)} )}
</div> </div>
<div> <div>
<h2 className="text-xl font-bold">Map & Geo-Fence</h2> <h2 className="text-xl font-bold">Map & Geo-Fence</h2>
<div className="mt-2 rounded-md overflow-hidden"> <div className="mt-2 h-[300px] rounded-md overflow-hidden bg-gray-800 flex items-center justify-center">
<DeviceMap latestGPS={telemetry} onFenceChange={setFence} /> {telemetry ? (
</div> <DeviceMap latestGPS={telemetry} onFenceChange={setFence} />
<p>Geo-fence points: {fence.length}</p> ) : (
</div> <p className="text-gray-400">Waiting for GPS data...</p>
</section> )}
</div>
<p>Geo-fence points: {fence.length}</p>
</div>
</section>
<section className="mt-6"> <section className="mt-6">
<h2 className="text-xl font-bold">Queued Commands</h2> <h2 className="text-xl font-bold">Queued Commands</h2>