feat(cloud): device config + heartbeat sync API (HMAC)
This commit is contained in:
parent
92c630bf58
commit
5135c08e18
3 changed files with 105 additions and 0 deletions
29
cloud/src/Api/ConfigController.php
Normal file
29
cloud/src/Api/ConfigController.php
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Api;
|
||||||
|
|
||||||
|
use App\Http\{Request, Response};
|
||||||
|
use App\Security\Hmac;
|
||||||
|
use App\Repository\{DeviceRepository, ZoneRepository, ThresholdRepository};
|
||||||
|
|
||||||
|
final class ConfigController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private DeviceRepository $devices, private ZoneRepository $zones, private ThresholdRepository $thresholds
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function get(Request $req): Response
|
||||||
|
{
|
||||||
|
$deviceId = (string)$req->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
cloud/src/Api/HeartbeatController.php
Normal file
30
cloud/src/Api/HeartbeatController.php
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Api;
|
||||||
|
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\{Request, Response};
|
||||||
|
use App\Security\Hmac;
|
||||||
|
use App\Repository\DeviceRepository;
|
||||||
|
|
||||||
|
final class HeartbeatController
|
||||||
|
{
|
||||||
|
public function __construct(private Config $cfg, private DeviceRepository $devices) {}
|
||||||
|
|
||||||
|
public function post(Request $req, int $now): Response
|
||||||
|
{
|
||||||
|
$b = $req->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
46
cloud/tests/ApiSyncTest.php
Normal file
46
cloud/tests/ApiSyncTest.php
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
namespace App\Tests;
|
||||||
|
use App\Config;
|
||||||
|
use App\Http\Request;
|
||||||
|
use App\Security\Hmac;
|
||||||
|
use App\Api\{ConfigController, HeartbeatController};
|
||||||
|
use App\Tests\Support\{InMemoryDeviceRepository, InMemoryZoneRepository, InMemoryThresholdRepository};
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
|
||||||
|
final class ApiSyncTest extends TestCase
|
||||||
|
{
|
||||||
|
private function signed(string $method, string $path, string $secret, array $q = [], array $body = []): Request {
|
||||||
|
$raw = $body ? json_encode($body) : '';
|
||||||
|
$nonce = 'n1'; $ts = '1000';
|
||||||
|
$sig = Hmac::sign($secret, $nonce, $ts, $raw);
|
||||||
|
return new Request($method, $path, ['X-Nonce' => $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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue