$id, 'name' => $id, 'site' => $site, 'last_fps' => null, 'last_temp' => null, 'last_throttle' => null, 'config_version' => 1, 'reported_config_version' => 1, 'last_heartbeat_at' => $heartbeat]; } /** @param array $points [tsOffsetFromNow => metric fields] */ private function metrics(string $deviceId, array $points): InMemoryDeviceMetricRepository { $repo = new InMemoryDeviceMetricRepository(); foreach ($points as $offset => $fields) { $repo->append($deviceId, $fields, self::NOW - $offset); } return $repo; } // --- device_fault --------------------------------------------------------------------- public function testSilentDeviceFilesDeviceFault(): void { $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 600)]), new InMemoryDeviceMetricRepository() ); $filed = $watch->run(self::NOW); self::assertCount(1, $filed); self::assertSame('device_fault', $filed[0]['event_type']); $row = $events->find($filed[0]['event_id']); self::assertSame('warning', $row['severity']); self::assertSame(DeviceHealthWatch::DEVICE_CAMERA, $row['camera_id']); self::assertSame('site-a', $row['site']); self::assertSame(600, $row['metrics']['silent_s']); } public function testHealthyHeartbeatFilesNothing(): void { $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 20)]), $this->metrics('pi-01', [20 => ['fps' => 12.0, 'cameras_erroring' => 0]]) ); self::assertSame([], $watch->run(self::NOW)); } public function testOutageFilesOnceAcrossRepeatedRuns(): void { // The cron runs every minute. An hour-long outage must leave ONE event, not sixty. $events = new InMemoryEventRepository(); $devices = $this->devices([$this->device('pi-01', self::NOW - 600)]); $watch = new DeviceHealthWatch($events, $devices, new InMemoryDeviceMetricRepository()); $first = $watch->run(self::NOW); $second = $watch->run(self::NOW + 60); $third = $watch->run(self::NOW + 120); self::assertCount(1, $first); self::assertSame([], $second); self::assertSame([], $third); } public function testRecoveredThenFailedAgainIsASecondEvent(): void { $events = new InMemoryEventRepository(); $devices = $this->devices([$this->device('pi-01', self::NOW - 600)]); $watch = new DeviceHealthWatch($events, $devices, new InMemoryDeviceMetricRepository()); self::assertCount(1, $watch->run(self::NOW)); // The node came back (new heartbeat) and then went silent again. $devices->statusRows = [$this->device('pi-01', self::NOW + 1000)]; self::assertCount(1, $watch->run(self::NOW + 2000)); } public function testNeverInstalledDeviceIsNotAFault(): void { // A provisioned row that has never reported is an uninstalled node. Alarming here would // make every new site's first day a wall of red. $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-new', null), $this->device('pi-zero', 0)]), new InMemoryDeviceMetricRepository() ); self::assertSame([], $watch->run(self::NOW)); } public function testSilentDeviceDoesNotAlsoFileDegraded(): void { // A node that is gone must not be reported twice for one failure. $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 600)]), $this->metrics('pi-01', [900 => ['fps' => 1.0], 700 => ['fps' => 1.0]]) ); $filed = $watch->run(self::NOW); self::assertCount(1, $filed); self::assertSame('device_fault', $filed[0]['event_type']); } // --- monitoring_degraded -------------------------------------------------------------- public function testSustainedLowFpsFilesDegraded(): void { $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $this->metrics('pi-01', [ 800 => ['fps' => 12.0], // healthy anchor 600 => ['fps' => 3.0], 300 => ['fps' => 3.0], 10 => ['fps' => 3.0], ]) ); $filed = $watch->run(self::NOW); self::assertCount(1, $filed); self::assertSame('monitoring_degraded', $filed[0]['event_type']); $row = $events->find($filed[0]['event_id']); self::assertSame(['low_fps'], $row['metrics']['reasons']); self::assertSame(590, $row['metrics']['degraded_s']); } public function testBriefDipDoesNotFileDegraded(): void { // One slow heartbeat is a hiccup. Requiring a sustained run is what separates this event // from noise the operator learns to ignore. $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $this->metrics('pi-01', [ 200 => ['fps' => 12.0], 100 => ['fps' => 12.0], 10 => ['fps' => 3.0], ]) ); self::assertSame([], $watch->run(self::NOW)); } public function testRecoveredFpsFilesNothingEvenAfterALongBadRun(): void { // The latest heartbeat is what decides. A node that already recovered must not alarm. $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $this->metrics('pi-01', [ 900 => ['fps' => 2.0], 600 => ['fps' => 2.0], 10 => ['fps' => 12.0], ]) ); self::assertSame([], $watch->run(self::NOW)); } public function testErroringCameraIsDegradedEvenAtFullFps(): void { $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $this->metrics('pi-01', [ 800 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 0], 600 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 1], 10 => ['fps' => 12.0, 'cameras_total' => 2, 'cameras_erroring' => 1], ]) ); $filed = $watch->run(self::NOW); self::assertCount(1, $filed); $row = $events->find($filed[0]['event_id']); self::assertSame(['cameras_erroring'], $row['metrics']['reasons']); self::assertSame(1, $row['metrics']['cameras_erroring']); } public function testThermalThrottleCountsAsDegraded(): void { $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $this->metrics('pi-01', [ 800 => ['fps' => 12.0, 'throttle' => 0], 600 => ['fps' => 12.0, 'throttle' => 1], 10 => ['fps' => 12.0, 'throttle' => 1], ]) ); $filed = $watch->run(self::NOW); self::assertCount(1, $filed); self::assertSame(['thermal_throttle'], $events->find($filed[0]['event_id'])['metrics']['reasons']); } public function testOngoingDegradationFilesOncePerRunNotPerTick(): void { $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $this->metrics('pi-01', [ 800 => ['fps' => 12.0], 600 => ['fps' => 3.0], 10 => ['fps' => 3.0], ]) ); self::assertCount(1, $watch->run(self::NOW)); self::assertSame([], $watch->run(self::NOW + 60)); self::assertSame([], $watch->run(self::NOW + 120)); } public function testMissingFpsIsNotTreatedAsZero(): void { // A node that cannot read a metric reports NULL. Coercing that to 0 would invent an // outage out of a missing reading. $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 10)]), $this->metrics('pi-01', [ 600 => ['fps' => null, 'cameras_erroring' => 0], 10 => ['fps' => null, 'cameras_erroring' => 0], ]) ); self::assertSame([], $watch->run(self::NOW)); } public function testNoHeartbeatSeriesFilesNothingWhileDeviceIsFresh(): void { // Fresh heartbeat but no metric rows yet (just installed): nothing to judge. $events = new InMemoryEventRepository(); $watch = new DeviceHealthWatch( $events, $this->devices([$this->device('pi-01', self::NOW - 5)]), new InMemoryDeviceMetricRepository() ); self::assertSame([], $watch->run(self::NOW)); } public function testEachDeviceIsJudgedIndependently(): void { $events = new InMemoryEventRepository(); $metrics = new InMemoryDeviceMetricRepository(); $metrics->append('pi-ok', ['fps' => 12.0], self::NOW - 10); $metrics->append('pi-slow', ['fps' => 12.0], self::NOW - 800); $metrics->append('pi-slow', ['fps' => 2.0], self::NOW - 600); $metrics->append('pi-slow', ['fps' => 2.0], self::NOW - 10); $watch = new DeviceHealthWatch($events, $this->devices([ $this->device('pi-ok', self::NOW - 10), $this->device('pi-slow', self::NOW - 10), $this->device('pi-dead', self::NOW - 3600), ]), $metrics); $filed = $watch->run(self::NOW); $byDevice = []; foreach ($filed as $f) $byDevice[$f['device_id']] = $f['event_type']; self::assertSame(['pi-slow' => 'monitoring_degraded', 'pi-dead' => 'device_fault'], $byDevice); } public function testUuidIsStableAndDistinctPerKindAndDevice(): void { $a = DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100); self::assertSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 100)); self::assertSame(32, strlen($a)); self::assertNotSame($a, DeviceHealthWatch::uuidFor('monitoring_degraded', 'pi-01', 100)); self::assertNotSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-02', 100)); self::assertNotSame($a, DeviceHealthWatch::uuidFor('device_fault', 'pi-01', 101)); } }