From 12f5f97a53048acc47f51fcfa6ba25bb257c91a8 Mon Sep 17 00:00:00 2001 From: Theo Weijmer Date: Fri, 11 Sep 2026 14:36:00 +0200 Subject: [PATCH] feat: StationMapper maps getstationsdata to outdoor/indoor/rain Co-Authored-By: Claude Fable 5.1 --- .phpunit.result.cache | 2 +- src/StationMapper.php | 149 ++++++++++++++++++++++++++++ tests/StationMapperTest.php | 112 +++++++++++++++++++++ tests/fixtures/getstationsdata.json | 116 ++++++++++++++++++++++ 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/StationMapper.php create mode 100644 tests/StationMapperTest.php create mode 100644 tests/fixtures/getstationsdata.json diff --git a/.phpunit.result.cache b/.phpunit.result.cache index 77a9cb4..0db9609 100644 --- a/.phpunit.result.cache +++ b/.phpunit.result.cache @@ -1 +1 @@ -{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.001,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0}} \ No newline at end of file +{"version":2,"defects":[],"times":{"Weerscherm\\Tests\\ConfigTest::testDefaultsApplyWhenKeysAreMissing":0.004,"Weerscherm\\Tests\\ConfigTest::testFileValuesOverrideDefaults":0,"Weerscherm\\Tests\\ConfigTest::testUnknownKeyReturnsGivenDefault":0,"Weerscherm\\Tests\\ConfigTest::testFileMustReturnArray":0.001,"Weerscherm\\Tests\\StationMapperTest::testStationMetadata":0,"Weerscherm\\Tests\\StationMapperTest::testOutdoor":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorDefaultOrderAndUnreachableModule":0,"Weerscherm\\Tests\\StationMapperTest::testIndoorConfigDeterminesOrderAndLabels":0,"Weerscherm\\Tests\\StationMapperTest::testRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testWithoutRainModule":0,"Weerscherm\\Tests\\StationMapperTest::testUnreachableOutdoorModuleGivesNulls":0,"Weerscherm\\Tests\\StationMapperTest::testUnknownDeviceIdFallsBackToFirstStation":0,"Weerscherm\\Tests\\StationMapperTest::testEmptyDevicesGivesNullsWithoutCrashing":0}} \ No newline at end of file diff --git a/src/StationMapper.php b/src/StationMapper.php new file mode 100644 index 0000000..ff9a0b0 --- /dev/null +++ b/src/StationMapper.php @@ -0,0 +1,149 @@ + null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null]; + + /** + * @param array $body + * @param array $indoorModules _id => label, in de gewenste volgorde + * @return array{timezone: ?string, lat: ?float, lon: ?float, device_id: ?string, measured_at: ?int, + * outdoor: array{temp: ?float, trend: ?string, min: ?float, min_at: ?int, max: ?float, max_at: ?int}, + * indoor: list, + * rain_module_id: ?string, sum_rain_24: ?float} + */ + public static function map(array $body, ?string $deviceId, array $indoorModules): array + { + $device = self::pickDevice($body['devices'] ?? [], $deviceId); + if ($device === null) { + return [ + 'timezone' => null, 'lat' => null, 'lon' => null, 'device_id' => null, 'measured_at' => null, + 'outdoor' => self::EMPTY_OUTDOOR, 'indoor' => [], 'rain_module_id' => null, 'sum_rain_24' => null, + ]; + } + + $modules = is_array($device['modules'] ?? null) ? $device['modules'] : []; + $outdoor = self::firstOfType($modules, 'NAModule1'); + $rain = self::firstOfType($modules, 'NAModule3'); + $location = $device['place']['location'] ?? null; + + return [ + 'timezone' => self::stringOrNull($device['place']['timezone'] ?? null), + 'lat' => is_array($location) && isset($location[1]) ? (float) $location[1] : null, + 'lon' => is_array($location) && isset($location[0]) ? (float) $location[0] : null, + 'device_id' => self::stringOrNull($device['_id'] ?? null), + 'measured_at' => self::intOrNull(self::dashboard($device)['time_utc'] ?? self::dashboard($outdoor)['time_utc'] ?? null), + 'outdoor' => self::mapOutdoor($outdoor), + 'indoor' => self::mapIndoor($device, $modules, $indoorModules), + 'rain_module_id' => $rain === null ? null : self::stringOrNull($rain['_id'] ?? null), + 'sum_rain_24' => self::floatOrNull(self::dashboard($rain)['sum_rain_24'] ?? null), + ]; + } + + /** @param list> $devices */ + private static function pickDevice(array $devices, ?string $deviceId): ?array + { + foreach ($devices as $device) { + if ($deviceId !== null && ($device['_id'] ?? null) === $deviceId) { + return $device; + } + } + return $devices[0] ?? null; + } + + /** @param list> $modules */ + private static function firstOfType(array $modules, string $type): ?array + { + foreach ($modules as $module) { + if (($module['type'] ?? null) === $type) { + return $module; + } + } + return null; + } + + /** Dashboard-data van een module, of leeg als de module onbereikbaar is. */ + private static function dashboard(?array $module): array + { + if ($module === null || ($module['reachable'] ?? true) === false) { + return []; + } + return is_array($module['dashboard_data'] ?? null) ? $module['dashboard_data'] : []; + } + + private static function mapOutdoor(?array $module): array + { + $d = self::dashboard($module); + if ($d === []) { + return self::EMPTY_OUTDOOR; + } + return [ + 'temp' => self::floatOrNull($d['Temperature'] ?? null), + 'trend' => self::stringOrNull($d['temp_trend'] ?? null), + 'min' => self::floatOrNull($d['min_temp'] ?? null), + 'min_at' => self::intOrNull($d['date_min_temp'] ?? null), + 'max' => self::floatOrNull($d['max_temp'] ?? null), + 'max_at' => self::intOrNull($d['date_max_temp'] ?? null), + ]; + } + + /** + * @param list> $modules + * @param array $indoorModules + */ + private static function mapIndoor(array $device, array $modules, array $indoorModules): array + { + $candidates = []; + foreach (array_merge([$device], $modules) as $module) { + $type = $module['type'] ?? null; + if ($type !== 'NAMain' && $type !== 'NAModule4') { + continue; + } + $id = self::stringOrNull($module['_id'] ?? null); + if ($id === null) { + continue; + } + $d = self::dashboard($module); + $candidates[$id] = [ + 'id' => $id, + 'name' => $indoorModules[$id] ?? self::stringOrNull($module['module_name'] ?? null) ?? $id, + 'temp' => self::floatOrNull($d['Temperature'] ?? null), + 'min' => self::floatOrNull($d['min_temp'] ?? null), + 'max' => self::floatOrNull($d['max_temp'] ?? null), + 'reachable' => $d !== [], + ]; + } + + $ordered = []; + foreach (array_keys($indoorModules) as $id) { + if (isset($candidates[$id])) { + $ordered[] = $candidates[$id]; + unset($candidates[$id]); + } + } + return array_merge($ordered, array_values($candidates)); + } + + private static function floatOrNull(mixed $v): ?float + { + return is_int($v) || is_float($v) ? (float) $v : null; + } + + private static function intOrNull(mixed $v): ?int + { + return is_int($v) || is_float($v) ? (int) $v : null; + } + + private static function stringOrNull(mixed $v): ?string + { + return is_string($v) && $v !== '' ? $v : null; + } +} diff --git a/tests/StationMapperTest.php b/tests/StationMapperTest.php new file mode 100644 index 0000000..ffa9db8 --- /dev/null +++ b/tests/StationMapperTest.php @@ -0,0 +1,112 @@ + */ + private function body(): array + { + $json = json_decode((string) file_get_contents(__DIR__ . '/fixtures/getstationsdata.json'), true); + return $json['body']; + } + + public function testStationMetadata(): void + { + $m = StationMapper::map($this->body(), null, []); + + self::assertSame('Europe/Berlin', $m['timezone']); + self::assertSame(51.82, $m['lat']); + self::assertSame(6.55, $m['lon']); + self::assertSame('70:ee:50:00:00:01', $m['device_id']); + self::assertSame(1789129812, $m['measured_at']); + } + + public function testOutdoor(): void + { + $m = StationMapper::map($this->body(), null, []); + + self::assertSame([ + 'temp' => 16.4, + 'trend' => 'down', + 'min' => 9.2, + 'min_at' => 1789099800, + 'max' => 19.8, + 'max_at' => 1789124700, + ], $m['outdoor']); + } + + public function testIndoorDefaultOrderAndUnreachableModule(): void + { + $m = StationMapper::map($this->body(), null, []); + + self::assertCount(3, $m['indoor']); + self::assertSame(['Woonkamer', 'Slaapkamer', 'Kantoor'], array_column($m['indoor'], 'name')); + self::assertSame(['id' => '70:ee:50:00:00:01', 'name' => 'Woonkamer', 'temp' => 21.3, 'min' => 20.4, 'max' => 22.1, 'reachable' => true], $m['indoor'][0]); + self::assertSame(['id' => '03:00:00:00:00:03', 'name' => 'Kantoor', 'temp' => null, 'min' => null, 'max' => null, 'reachable' => false], $m['indoor'][2]); + } + + public function testIndoorConfigDeterminesOrderAndLabels(): void + { + $m = StationMapper::map($this->body(), null, [ + '03:00:00:00:00:03' => 'Studeerkamer', + '70:ee:50:00:00:01' => 'Living', + ]); + + self::assertSame(['Studeerkamer', 'Living', 'Slaapkamer'], array_column($m['indoor'], 'name')); + } + + public function testRainModule(): void + { + $m = StationMapper::map($this->body(), null, []); + + self::assertSame('05:00:00:00:00:04', $m['rain_module_id']); + self::assertSame(2.4, $m['sum_rain_24']); + } + + public function testWithoutRainModule(): void + { + $body = $this->body(); + $body['devices'][0]['modules'] = array_values(array_filter( + $body['devices'][0]['modules'], + fn(array $mod) => $mod['type'] !== 'NAModule3', + )); + $m = StationMapper::map($body, null, []); + + self::assertNull($m['rain_module_id']); + self::assertNull($m['sum_rain_24']); + } + + public function testUnreachableOutdoorModuleGivesNulls(): void + { + $body = $this->body(); + $body['devices'][0]['modules'][0]['reachable'] = false; + unset($body['devices'][0]['modules'][0]['dashboard_data']); + $m = StationMapper::map($body, null, []); + + self::assertSame(['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null], $m['outdoor']); + } + + public function testUnknownDeviceIdFallsBackToFirstStation(): void + { + $m = StationMapper::map($this->body(), 'ff:ff:ff:ff:ff:ff', []); + self::assertSame('70:ee:50:00:00:01', $m['device_id']); + } + + public function testEmptyDevicesGivesNullsWithoutCrashing(): void + { + $m = StationMapper::map(['devices' => []], null, []); + + self::assertNull($m['device_id']); + self::assertNull($m['timezone']); + self::assertNull($m['measured_at']); + self::assertSame([], $m['indoor']); + self::assertNull($m['outdoor']['temp']); + self::assertNull($m['rain_module_id']); + } +} diff --git a/tests/fixtures/getstationsdata.json b/tests/fixtures/getstationsdata.json new file mode 100644 index 0000000..c963888 --- /dev/null +++ b/tests/fixtures/getstationsdata.json @@ -0,0 +1,116 @@ +{ + "body": { + "devices": [ + { + "_id": "70:ee:50:00:00:01", + "type": "NAMain", + "station_name": "Thuis", + "module_name": "Woonkamer", + "reachable": true, + "firmware": 204, + "wifi_status": 50, + "co2_calibrating": false, + "place": { + "altitude": 20, + "city": "Voorbeeldstad", + "country": "DE", + "timezone": "Europe/Berlin", + "location": [ + 6.55, + 51.82 + ] + }, + "dashboard_data": { + "time_utc": 1789129812, + "Temperature": 21.3, + "CO2": 612, + "Humidity": 48, + "Noise": 38, + "Pressure": 1014.2, + "AbsolutePressure": 1011.8, + "min_temp": 20.4, + "max_temp": 22.1, + "date_max_temp": 1789126800, + "date_min_temp": 1789098600, + "temp_trend": "stable", + "pressure_trend": "up" + }, + "modules": [ + { + "_id": "02:00:00:00:00:01", + "type": "NAModule1", + "module_name": "Buiten", + "reachable": true, + "battery_percent": 71, + "rf_status": 70, + "dashboard_data": { + "time_utc": 1789129780, + "Temperature": 16.4, + "Humidity": 71, + "min_temp": 9.2, + "max_temp": 19.8, + "date_min_temp": 1789099800, + "date_max_temp": 1789124700, + "temp_trend": "down" + } + }, + { + "_id": "03:00:00:00:00:02", + "type": "NAModule4", + "module_name": "Slaapkamer", + "reachable": true, + "battery_percent": 64, + "rf_status": 66, + "dashboard_data": { + "time_utc": 1789129680, + "Temperature": 18.9, + "CO2": 540, + "Humidity": 52, + "min_temp": 18.1, + "max_temp": 19.6, + "date_min_temp": 1789101000, + "date_max_temp": 1789120800, + "temp_trend": "up" + } + }, + { + "_id": "03:00:00:00:00:03", + "type": "NAModule4", + "module_name": "Kantoor", + "reachable": false, + "battery_percent": 5, + "rf_status": 90 + }, + { + "_id": "05:00:00:00:00:04", + "type": "NAModule3", + "module_name": "Regenmeter", + "reachable": true, + "battery_percent": 80, + "rf_status": 68, + "dashboard_data": { + "time_utc": 1789129780, + "Rain": 0, + "sum_rain_1": 0, + "sum_rain_24": 2.4 + } + } + ] + } + ], + "user": { + "mail": "x@example.org", + "administrative": { + "lang": "nl-NL", + "reg_locale": "nl-NL", + "unit": 0, + "windunit": 0, + "pressureunit": 0, + "feel_like_algo": 0 + } + } + }, + "status": "ok", + "time_exec": 0.05, + "time_server": 1789129925 +}