From 5135c08e18603adfb4d07077540d20b2f3efe1ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EC=B0=BD=EC=9A=B1?= Date: Sun, 12 Jul 2026 01:13:02 +0900 Subject: [PATCH] feat(cloud): device config + heartbeat sync API (HMAC) --- cloud/src/Api/ConfigController.php | 29 +++++++++++++++++ cloud/src/Api/HeartbeatController.php | 30 +++++++++++++++++ cloud/tests/ApiSyncTest.php | 46 +++++++++++++++++++++++++++ 3 files changed, 105 insertions(+) create mode 100644 cloud/src/Api/ConfigController.php create mode 100644 cloud/src/Api/HeartbeatController.php create mode 100644 cloud/tests/ApiSyncTest.php diff --git a/cloud/src/Api/ConfigController.php b/cloud/src/Api/ConfigController.php new file mode 100644 index 0000000..edf3f0f --- /dev/null +++ b/cloud/src/Api/ConfigController.php @@ -0,0 +1,29 @@ +query('device_id'); + $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; + $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; + if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { + return Response::json(['error' => 'unauthorized'], 401); + } + return Response::json([ + 'version' => $this->devices->configVersion($deviceId), + 'zones' => $this->zones->forDevice($deviceId), + 'thresholds' => $this->thresholds->forDevice($deviceId), + ], 200); + } +} diff --git a/cloud/src/Api/HeartbeatController.php b/cloud/src/Api/HeartbeatController.php new file mode 100644 index 0000000..1f9cd32 --- /dev/null +++ b/cloud/src/Api/HeartbeatController.php @@ -0,0 +1,30 @@ +json(); + $deviceId = (string)($b['device_id'] ?? ''); + $secret = $deviceId !== '' ? $this->devices->secretFor($deviceId) : null; + $nonce = $req->header('X-Nonce') ?? ''; $ts = $req->header('X-Timestamp') ?? ''; $sig = $req->header('X-Signature') ?? ''; + if ($secret === null || !Hmac::verify($secret, $nonce, $ts, $req->body(), $sig)) { + return Response::json(['error' => 'unauthorized'], 401); + } + $this->devices->touchHeartbeat($deviceId, [ + 'fps' => (float)($b['fps'] ?? 0), 'temp' => (float)($b['temp'] ?? 0), + 'throttle' => (bool)($b['throttle'] ?? false), 'config_version' => (int)($b['config_version'] ?? 0), + 'at' => $now, + ]); + return Response::json(['ok' => true], 200); + } +} diff --git a/cloud/tests/ApiSyncTest.php b/cloud/tests/ApiSyncTest.php new file mode 100644 index 0000000..776a684 --- /dev/null +++ b/cloud/tests/ApiSyncTest.php @@ -0,0 +1,46 @@ + $nonce, 'X-Timestamp' => $ts, 'X-Signature' => $sig], $raw, $q, []); + } + public function testConfigReturnsZonesAndThresholds(): void { + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek'], ['pi-1' => 7]); + $zones = new InMemoryZoneRepository(); $zones->save(['device_id' => 'pi-1', 'camera_id' => 'cam-1', 'level' => 'restricted', 'polygon_json' => '[[0,0],[9,0],[9,9]]']); + $thr = new InMemoryThresholdRepository(['pi-1' => ['fall_prone_s' => 3.0]]); + $c = new ConfigController($dev, $zones, $thr); + $res = $c->get($this->signed('GET', '/api/config', 'sek', ['device_id' => 'pi-1'])); + $this->assertSame(200, $res->status()); + $data = json_decode($res->body(), true); + $this->assertSame(7, $data['version']); + $this->assertCount(1, $data['zones']); + $this->assertEquals(3.0, $data['thresholds']['fall_prone_s']); + } + public function testConfigRejectsBadSignature(): void { + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $c = new ConfigController($dev, new InMemoryZoneRepository(), new InMemoryThresholdRepository()); + $res = $c->get($this->signed('GET', '/api/config', 'WRONG', ['device_id' => 'pi-1'])); + $this->assertSame(401, $res->status()); + } + public function testHeartbeatStored(): void { + $cfg = Config::fromArray([]); + $dev = new InMemoryDeviceRepository(['pi-1' => 'sek']); + $c = new HeartbeatController($cfg, $dev); + $body = ['device_id' => 'pi-1', 'fps' => 12.0, 'temp' => 55.0, 'throttle' => false, 'config_version' => 7]; + $res = $c->post($this->signed('POST', '/api/heartbeat', 'sek', [], $body), now: 1000); + $this->assertSame(200, $res->status()); + $this->assertSame(12.0, $dev->heartbeats['pi-1']['fps']); + } +}