feat: StationMapper maps getstationsdata to outdoor/indoor/rain
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -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}}
|
||||
{"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}}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm;
|
||||
|
||||
/**
|
||||
* Zet de body van Netatmo getstationsdata om naar een platte tussenstructuur.
|
||||
* Ruwe waarden, unix-timestamps; de formattering gebeurt later.
|
||||
*/
|
||||
final class StationMapper
|
||||
{
|
||||
private const EMPTY_OUTDOOR = ['temp' => null, 'trend' => null, 'min' => null, 'min_at' => null, 'max' => null, 'max_at' => null];
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $body
|
||||
* @param array<string, string> $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<array{id: string, name: string, temp: ?float, min: ?float, max: ?float, reachable: bool}>,
|
||||
* 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<array<string, mixed>> $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<array<string, mixed>> $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<array<string, mixed>> $modules
|
||||
* @param array<string, string> $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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Weerscherm\Tests;
|
||||
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Weerscherm\StationMapper;
|
||||
|
||||
final class StationMapperTest extends TestCase
|
||||
{
|
||||
/** @return array<string, mixed> */
|
||||
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']);
|
||||
}
|
||||
}
|
||||
Vendored
+116
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user