From 8232e90aa48e3babb5a0742c83784761733804d3 Mon Sep 17 00:00:00 2001 From: Sam Rolfe Date: Tue, 2 Sep 2025 17:18:18 +1000 Subject: [PATCH] IMEI ingest + commands: migrations, models, DeviceApiController, routes --- app/Http/Controllers/DeviceApiController.php | 142 +++++++++++++++++++ 1 file changed, 142 insertions(+) create mode 100644 app/Http/Controllers/DeviceApiController.php diff --git a/app/Http/Controllers/DeviceApiController.php b/app/Http/Controllers/DeviceApiController.php new file mode 100644 index 0000000..9748811 --- /dev/null +++ b/app/Http/Controllers/DeviceApiController.php @@ -0,0 +1,142 @@ +validate([ + 'recorded_at' => ['nullable','date'], + 'lat' => ['required','numeric','between:-90,90'], + 'lng' => ['required','numeric','between:-180,180'], + 'altitude_m' => ['nullable','numeric'], + 'speed_kmh' => ['nullable','numeric'], + 'heading_deg' => ['nullable','numeric'], + 'accuracy_m' => ['nullable','numeric'], + 'battery_percent'=> ['nullable','integer','between:0,100'], + 'is_car_on' => ['nullable','boolean'], + 'raw' => ['nullable'], + ]); + + $device = Device::firstOrCreate( + ['imei' => $imei], + ['name' => $imei, 'is_active' => true] + ); + + $now = now(); + $recordedAt = isset($data['recorded_at']) + ? Carbon::parse($data['recorded_at']) + : $now; + + DB::table('device_telemetry')->insert([ + 'device_id' => $device->id, + 'recorded_at' => $recordedAt, + 'lat' => $data['lat'], + 'lng' => $data['lng'], + 'altitude_m' => $data['altitude_m'] ?? null, + 'speed_kmh' => $data['speed_kmh'] ?? null, + 'heading_deg' => $data['heading_deg'] ?? null, + 'accuracy_m' => $data['accuracy_m'] ?? null, + 'raw' => array_key_exists('raw', $data) ? json_encode($data['raw']) : null, + 'created_at' => $now, + ]); + + // Upsert status snapshot + DB::table('device_status')->updateOrInsert( + ['device_id' => $device->id], + [ + 'is_car_on' => (bool)($data['is_car_on'] ?? false), + 'battery_percent' => $data['battery_percent'] ?? null, + 'external_power' => false, + 'reported_at' => $now, + 'last_gps_fix_at' => $recordedAt, + 'updated_at' => $now, + 'sleep_interval_sec'=> DB::raw('COALESCE(sleep_interval_sec, 60)'), + ] + ); + + $device->forceFill(['last_seen_at' => $now, 'last_ip' => $req->ip()])->save(); + + return response()->json(['ok' => true], 201); + } + + // GET /api/device/{imei}/commands?since=ISO8601 + public function commands(Request $req, string $imei) + { + $since = $req->query('since'); + + $device = Device::where('imei', $imei)->first(); + if (!$device) { + return response()->json(['commands' => []]); + } + + $q = Command::where('device_id', $device->id) + ->where('status', 'queued') + ->where(function ($q) { + $q->whereNull('not_before_at')->orWhere('not_before_at', '<=', now()); + }) + ->where(function ($q) { + $q->whereNull('expires_at')->orWhere('expires_at', '>', now()); + }) + ->orderBy('priority', 'asc') + ->orderBy('id', 'asc'); + + if ($since) { + $ts = Carbon::parse($since); + $q->where('created_at', '>=', $ts); + } + + $cmds = $q->limit(50)->get(['id','command_type','payload','priority','created_at']); + + // Mark as sent (optional) + if ($cmds->count()) { + Command::whereIn('id', $cmds->pluck('id'))->update(['status' => 'sent', 'updated_at' => now()]); + } + + return response()->json(['commands' => $cmds]); + } + + // POST /api/device/{imei}/command-receipts + public function commandReceipts(Request $req, string $imei) + { + $device = Device::where('imei', $imei)->first(); + if (!$device) { + return response()->json(['ok' => true], 200); + } + + $data = $req->validate([ + 'command_id' => ['required','integer'], + 'acked_at' => ['nullable','date'], + 'executed_at' => ['nullable','date'], + 'result' => ['nullable','in:ok,error'], + 'result_detail'=> ['nullable','string'], + ]); + + CommandReceipt::create([ + 'command_id' => $data['command_id'], + 'device_id' => $device->id, + 'acked_at' => isset($data['acked_at']) ? Carbon::parse($data['acked_at']) : null, + 'executed_at' => isset($data['executed_at']) ? Carbon::parse($data['executed_at']) : null, + 'result' => $data['result'] ?? null, + 'result_detail'=> $data['result_detail'] ?? null, + ]); + + // If execution OK, mark command acked/acked+executed + if (!empty($data['result'])) { + $newStatus = $data['result'] === 'ok' ? 'acked' : 'failed'; + Command::where('id', $data['command_id'])->where('device_id', $device->id) + ->update(['status' => $newStatus, 'updated_at' => now()]); + } + + return response()->json(['ok' => true], 200); + } +}