From 5d2f61749ed5baff0c14cea034444190b5cbd830 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sun, 15 Mar 2026 20:14:01 +0100 Subject: [PATCH] Update sensor parsing layout --- lib/providers/sensors_provider.dart | 58 +- lib/screens/sensors_tab.dart | 22 +- lib/services/cayenne_lpp_parser.dart | 424 +++++++++++++- .../sensors/sensor_telemetry_card.dart | 524 +++++++++++++++++- test/providers/sensors_provider_test.dart | 64 +++ test/services/cayenne_lpp_parser_test.dart | 56 ++ test/widgets/sensor_telemetry_card_test.dart | 74 ++- 7 files changed, 1186 insertions(+), 36 deletions(-) diff --git a/lib/providers/sensors_provider.dart b/lib/providers/sensors_provider.dart index 4a0e1ab..f9a08e1 100644 --- a/lib/providers/sensors_provider.dart +++ b/lib/providers/sensors_provider.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../models/contact.dart'; +import '../widgets/sensors/sensor_telemetry_card.dart'; import 'connection_provider.dart'; import 'contacts_provider.dart'; @@ -223,6 +224,22 @@ class SensorsProvider with ChangeNotifier { _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields, ); + Set effectiveVisibleFieldsFor( + String publicKeyHex, + Iterable availableFieldKeys, + ) { + final storedVisibleFields = + _visibleFieldsBySensor[publicKeyHex] ?? _defaultVisibleFields; + if (!_shouldAutoIncludeAvailableFields(publicKeyHex, storedVisibleFields)) { + return Set.unmodifiable(storedVisibleFields); + } + + return Set.unmodifiable({ + ...storedVisibleFields, + ...availableFieldKeys, + }); + } + bool showsField(String publicKeyHex, String fieldKey) => visibleFieldsFor(publicKeyHex).contains(fieldKey); @@ -440,14 +457,14 @@ class SensorsProvider with ChangeNotifier { } _watchedSensorKeys.add(contact.publicKeyHex); + final initialVisibleFields = _initialVisibleFieldsForContact(contact); await _persistWatchedSensors(); - _visibleFieldsBySensor[contact.publicKeyHex] = Set.from( - _defaultVisibleFields, - ); + _visibleFieldsBySensor[contact.publicKeyHex] = initialVisibleFields; _fieldSpansBySensor[contact.publicKeyHex] = {'gps': 2}; _metricLabelsBySensor[contact.publicKeyHex] = {}; - _metricOrderBySensor[contact.publicKeyHex] = List.from( - _defaultMetricOrder, + _metricOrderBySensor[contact.publicKeyHex] = metricOrderFor( + contact.publicKeyHex, + initialVisibleFields, ); await _persistVisibleMetrics(); await _persistFieldSpans(); @@ -456,6 +473,37 @@ class SensorsProvider with ChangeNotifier { notifyListeners(); } + bool _shouldAutoIncludeAvailableFields( + String publicKeyHex, + Set storedVisibleFields, + ) { + if (!setEquals(storedVisibleFields, _defaultVisibleFields)) { + return false; + } + + final storedOrder = _metricOrderBySensor[publicKeyHex]; + if (storedOrder != null && !listEquals(storedOrder, _defaultMetricOrder)) { + return false; + } + + final storedLabels = _metricLabelsBySensor[publicKeyHex]; + if (storedLabels != null && storedLabels.isNotEmpty) { + return false; + } + + final storedSpans = _fieldSpansBySensor[publicKeyHex]; + if (storedSpans != null && + !(storedSpans.length == 1 && storedSpans['gps'] == 2)) { + return false; + } + + return true; + } + + Set _initialVisibleFieldsForContact(Contact contact) { + return {..._defaultVisibleFields, ...sensorMetricKeysFor(contact)}; + } + Future removeSensor(String publicKeyHex) async { _watchedSensorKeys.remove(publicKeyHex); _refreshStates.remove(publicKeyHex); diff --git a/lib/screens/sensors_tab.dart b/lib/screens/sensors_tab.dart index 2396035..6c7ed0d 100644 --- a/lib/screens/sensors_tab.dart +++ b/lib/screens/sensors_tab.dart @@ -294,19 +294,20 @@ class _SensorsTabState extends State { break; } } + final availableFieldKeys = sensorMetricKeysFor(contact); + final visibleFields = sensorsProvider + .effectiveVisibleFieldsFor(key, availableFieldKeys); return SensorTelemetryCard( contact: contact, state: sensorsProvider.stateFor(key), - visibleFields: sensorsProvider.visibleFieldsFor(key), + visibleFields: visibleFields, fieldOrder: sensorsProvider.metricOrderFor( key, - sensorsProvider.visibleFieldsFor(key), + availableFieldKeys, ), labelOverrides: sensorsProvider.labelOverridesFor(key), fieldSpans: { - for (final field in sensorsProvider.visibleFieldsFor( - key, - )) + for (final field in visibleFields) field: sensorsProvider.fieldSpanFor(key, field), }, onRemove: () async { @@ -359,14 +360,17 @@ class _SensorCustomizeView extends StatelessWidget { } } - final visibleFields = sensorsProvider.visibleFieldsFor(publicKeyHex); - final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor( - publicKeyHex, - ); final options = sensorMetricOptionsFor( contact, labelOverrides: sensorsProvider.labelOverridesFor(publicKeyHex), ); + final visibleFields = sensorsProvider.effectiveVisibleFieldsFor( + publicKeyHex, + options.map((option) => option.key), + ); + final autoRefreshMinutes = sensorsProvider.autoRefreshMinutesFor( + publicKeyHex, + ); final orderedFieldKeys = sensorsProvider.metricOrderFor( publicKeyHex, options.map((option) => option.key), diff --git a/lib/services/cayenne_lpp_parser.dart b/lib/services/cayenne_lpp_parser.dart index 332df6c..3dc5e0a 100644 --- a/lib/services/cayenne_lpp_parser.dart +++ b/lib/services/cayenne_lpp_parser.dart @@ -19,7 +19,61 @@ class CayenneLppParser { static const int _lppDirection = 132; static const int _lppUnixTime = 133; static const int _lppColour = 135; + static const int _lppGust = 137; + static const int _lppDewPoint = 138; + static const int _lppRain = 139; static const int _lppSwitch = 142; + static const int _lppBinaryBool = 143; + static const int _lppBinaryPowerSwitch = 144; + static const int _lppBinaryOpen = 145; + static const int _lppBinaryBatteryLow = 146; + static const int _lppBinaryCharging = 147; + static const int _lppBinaryCarbonMonoxide = 148; + static const int _lppBinaryCold = 149; + static const int _lppBinaryConnectivity = 150; + static const int _lppBinaryDoor = 151; + static const int _lppBinaryGarageDoor = 152; + static const int _lppBinaryGas = 153; + static const int _lppBinaryHeat = 154; + static const int _lppBinaryLight = 155; + static const int _lppBinaryLock = 156; + static const int _lppBinaryMoisture = 157; + static const int _lppBinaryMotion = 158; + static const int _lppBinaryMoving = 159; + static const int _lppBinaryOccupancy = 160; + static const int _lppBinaryPlug = 161; + static const int _lppBinaryPresence = 162; + static const int _lppBinaryProblem = 163; + static const int _lppBinaryRunning = 164; + static const int _lppBinarySafety = 165; + static const int _lppBinarySmoke = 166; + static const int _lppBinarySound = 167; + static const int _lppBinaryTamper = 168; + static const int _lppBinaryVibration = 169; + static const int _lppBinaryWindow = 170; + static const int _lppButtonEvent = 171; + static const int _lppDimmer = 172; + static const int _lppUv = 173; + static const int _lppLightLevel = 174; + static const int _lppPm25 = 175; + static const int _lppPm10 = 176; + static const int _lppCo2 = 177; + static const int _lppTvoc = 178; + static const int _lppRpm = 179; + static const int _lppConductivity = 180; + static const int _lppRotation = 181; + static const int _lppDuration = 182; + static const int _lppAcceleration = 183; + static const int _lppGyroRate = 184; + static const int _lppVolume = 185; + static const int _lppFlowRate = 186; + static const int _lppVolumeStorage = 187; + static const int _lppWater = 188; + static const int _lppGasVolume = 189; + static const int _lppMass = 190; + static const int _lppSignedSpeed = 191; + static const int _lppSignedPower = 192; + static const int _lppSignedCurrent = 193; /// Parse Cayenne LPP data into ContactTelemetry static ContactTelemetry parse(Uint8List data) { @@ -345,6 +399,212 @@ class CayenneLppParser { }; break; + case _lppGust: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 100.0; + debugPrint(' Gust: ${value}m/s'); + extraSensorData['gust_$channel'] = value; + break; + + case _lppDewPoint: + final rawValue = reader.readInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Dew point: ${value.toStringAsFixed(1)}°C'); + extraSensorData['dew_$channel'] = value; + break; + + case _lppRain: + final rawValue = reader.readUInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Rain: ${value}mm'); + extraSensorData['rain_$channel'] = value; + break; + + case _lppBinaryBool: + case _lppBinaryPowerSwitch: + case _lppBinaryOpen: + case _lppBinaryBatteryLow: + case _lppBinaryCharging: + case _lppBinaryCarbonMonoxide: + case _lppBinaryCold: + case _lppBinaryConnectivity: + case _lppBinaryDoor: + case _lppBinaryGarageDoor: + case _lppBinaryGas: + case _lppBinaryHeat: + case _lppBinaryLight: + case _lppBinaryLock: + case _lppBinaryMoisture: + case _lppBinaryMotion: + case _lppBinaryMoving: + case _lppBinaryOccupancy: + case _lppBinaryPlug: + case _lppBinaryPresence: + case _lppBinaryProblem: + case _lppBinaryRunning: + case _lppBinarySafety: + case _lppBinarySmoke: + case _lppBinarySound: + case _lppBinaryTamper: + case _lppBinaryVibration: + case _lppBinaryWindow: + final value = reader.readByte(); + debugPrint(' Binary state: $value'); + extraSensorData['${_binaryMetricKeyForType(type)}_$channel'] = + value; + break; + + case _lppButtonEvent: + final value = reader.readByte(); + debugPrint(' Button event: $value'); + extraSensorData['button_event_$channel'] = value; + break; + + case _lppDimmer: + final value = _readInt8(reader); + debugPrint(' Dimmer: $value'); + extraSensorData['dimmer_$channel'] = value; + break; + + case _lppUv: + final value = reader.readByte() / 10.0; + debugPrint(' UV index: $value'); + extraSensorData['uv_$channel'] = value; + break; + + case _lppLightLevel: + final value = reader.readByte(); + debugPrint(' Light level: $value'); + extraSensorData['light_level_$channel'] = value; + break; + + case _lppPm25: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' PM2.5: $value'); + extraSensorData['pm25_$channel'] = value; + break; + + case _lppPm10: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' PM10: $value'); + extraSensorData['pm10_$channel'] = value; + break; + + case _lppCo2: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' CO2: $value'); + extraSensorData['co2_$channel'] = value; + break; + + case _lppTvoc: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' TVOC: $value'); + extraSensorData['tvoc_$channel'] = value; + break; + + case _lppRpm: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' RPM: $value'); + extraSensorData['rpm_$channel'] = value; + break; + + case _lppConductivity: + final value = reader.readUInt16BE().toDouble(); + debugPrint(' Conductivity: $value'); + extraSensorData['conductivity_$channel'] = value; + break; + + case _lppRotation: + final rawValue = reader.readInt16BE(); + final value = rawValue / 10.0; + debugPrint(' Rotation: $value'); + extraSensorData['rotation_$channel'] = value; + break; + + case _lppDuration: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Duration: $value s'); + extraSensorData['duration_$channel'] = value; + break; + + case _lppAcceleration: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000000.0; + debugPrint(' Acceleration: $value'); + extraSensorData['acceleration_$channel'] = value; + break; + + case _lppGyroRate: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Gyro rate: $value'); + extraSensorData['gyro_rate_$channel'] = value; + break; + + case _lppVolume: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Volume: $value'); + extraSensorData['volume_$channel'] = value; + break; + + case _lppFlowRate: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Flow rate: $value'); + extraSensorData['flow_rate_$channel'] = value; + break; + + case _lppVolumeStorage: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Storage volume: $value'); + extraSensorData['volume_storage_$channel'] = value; + break; + + case _lppWater: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Water: $value'); + extraSensorData['water_$channel'] = value; + break; + + case _lppGasVolume: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Gas volume: $value'); + extraSensorData['gas_volume_$channel'] = value; + break; + + case _lppMass: + final rawValue = _readUInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Mass: $value'); + extraSensorData['mass_$channel'] = value; + break; + + case _lppSignedSpeed: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000000.0; + debugPrint(' Signed speed: $value'); + extraSensorData['signed_speed_$channel'] = value; + break; + + case _lppSignedPower: + final rawValue = _readInt32BE(reader); + final value = rawValue / 100.0; + debugPrint(' Signed power: $value'); + extraSensorData['signed_power_$channel'] = value; + break; + + case _lppSignedCurrent: + final rawValue = _readInt32BE(reader); + final value = rawValue / 1000.0; + debugPrint(' Signed current: $value'); + extraSensorData['signed_current_$channel'] = value; + break; + case _lppSwitch: final value = reader.readByte(); debugPrint(' Switch: $value'); @@ -352,11 +612,16 @@ class CayenneLppParser { break; default: - debugPrint( - ' ⚠️ Unknown type, skipping remaining ${reader.remainingBytesCount} bytes', - ); - // Unknown type, skip remaining to avoid parsing errors - reader.skip(reader.remainingBytesCount); + final size = _payloadSizeForType(type); + if (size == null || reader.remainingBytesCount < size) { + debugPrint( + ' ⚠️ Unknown type $type with unsupported size, stopping parse', + ); + reader.skip(reader.remainingBytesCount); + break; + } + debugPrint(' ⚠️ Unknown type $type, skipping $size bytes'); + reader.skip(size); break; } } catch (e) { @@ -420,7 +685,154 @@ class CayenneLppParser { return (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]; } - static String _sourceChannelKey(String fieldKey) => '__source_channel:$fieldKey'; + static int _readInt32BE(BufferReader reader) { + final value = _readUInt32BE(reader); + if ((value & 0x80000000) != 0) { + return value - 0x100000000; + } + return value; + } + + static int _readInt8(BufferReader reader) { + final value = reader.readByte(); + if ((value & 0x80) != 0) { + return value - 0x100; + } + return value; + } + + static String _sourceChannelKey(String fieldKey) => + '__source_channel:$fieldKey'; + + static String _binaryMetricKeyForType(int type) { + switch (type) { + case _lppBinaryBool: + return 'binary_bool'; + case _lppBinaryPowerSwitch: + return 'binary_power_switch'; + case _lppBinaryOpen: + return 'binary_open'; + case _lppBinaryBatteryLow: + return 'binary_battery_low'; + case _lppBinaryCharging: + return 'binary_charging'; + case _lppBinaryCarbonMonoxide: + return 'binary_carbon_monoxide'; + case _lppBinaryCold: + return 'binary_cold'; + case _lppBinaryConnectivity: + return 'binary_connectivity'; + case _lppBinaryDoor: + return 'binary_door'; + case _lppBinaryGarageDoor: + return 'binary_garage_door'; + case _lppBinaryGas: + return 'binary_gas'; + case _lppBinaryHeat: + return 'binary_heat'; + case _lppBinaryLight: + return 'binary_light'; + case _lppBinaryLock: + return 'binary_lock'; + case _lppBinaryMoisture: + return 'binary_moisture'; + case _lppBinaryMotion: + return 'binary_motion'; + case _lppBinaryMoving: + return 'binary_moving'; + case _lppBinaryOccupancy: + return 'binary_occupancy'; + case _lppBinaryPlug: + return 'binary_plug'; + case _lppBinaryPresence: + return 'binary_presence'; + case _lppBinaryProblem: + return 'binary_problem'; + case _lppBinaryRunning: + return 'binary_running'; + case _lppBinarySafety: + return 'binary_safety'; + case _lppBinarySmoke: + return 'binary_smoke'; + case _lppBinarySound: + return 'binary_sound'; + case _lppBinaryTamper: + return 'binary_tamper'; + case _lppBinaryVibration: + return 'binary_vibration'; + case _lppBinaryWindow: + return 'binary_window'; + } + return 'binary_state'; + } + + static int? _payloadSizeForType(int type) { + if (type >= _lppBinaryBool && type <= _lppBinaryWindow) { + return 1; + } + switch (type) { + case MeshCoreConstants.lppDigitalInput: + case MeshCoreConstants.lppDigitalOutput: + case MeshCoreConstants.lppPresenceSensor: + case MeshCoreConstants.lppHumiditySensor: + case _lppPercentage: + case _lppSwitch: + case _lppButtonEvent: + case _lppDimmer: + case _lppUv: + case _lppLightLevel: + return 1; + case MeshCoreConstants.lppAnalogInput: + case MeshCoreConstants.lppAnalogOutput: + case MeshCoreConstants.lppIlluminanceSensor: + case MeshCoreConstants.lppTemperatureSensor: + case MeshCoreConstants.lppBarometer: + case MeshCoreConstants.lppVoltageSensor: + case _lppCurrent: + case _lppAltitude: + case _lppConcentration: + case _lppPower: + case _lppSpeed: + case _lppDirection: + case _lppGust: + case _lppDewPoint: + case _lppRain: + case _lppPm25: + case _lppPm10: + case _lppCo2: + case _lppTvoc: + case _lppRpm: + case _lppConductivity: + case _lppRotation: + return 2; + case MeshCoreConstants.lppAccelerometer: + case MeshCoreConstants.lppGyrometer: + return 6; + case MeshCoreConstants.lppGps: + return 9; + case _lppGenericSensor: + case _lppFrequency: + case _lppDistance: + case _lppEnergy: + case _lppUnixTime: + case _lppDuration: + case _lppAcceleration: + case _lppGyroRate: + case _lppVolume: + case _lppFlowRate: + case _lppVolumeStorage: + case _lppWater: + case _lppGasVolume: + case _lppMass: + case _lppSignedSpeed: + case _lppSignedPower: + case _lppSignedCurrent: + return 4; + case _lppColour: + return 3; + } + return null; + } static bool _isZeroPaddedTail(Uint8List data, int remainingBytes) { final start = data.length - remainingBytes; diff --git a/lib/widgets/sensors/sensor_telemetry_card.dart b/lib/widgets/sensors/sensor_telemetry_card.dart index d9deb28..55bd158 100644 --- a/lib/widgets/sensors/sensor_telemetry_card.dart +++ b/lib/widgets/sensors/sensor_telemetry_card.dart @@ -253,6 +253,17 @@ SensorMetricCardData? _buildOptionPreviewCardData( return null; } + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + return SensorMetricCardData( + fieldKey: fieldKey, + icon: previewValue == 'On' ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: previewValue, + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + } + switch (metricKey.baseKey) { case 'altitude': return SensorMetricCardData( @@ -350,6 +361,25 @@ SensorMetricCardData? _buildOptionPreviewCardData( accent: const Color(0xFF3E657C), channel: metricKey.channel, ); + case 'button_event': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.touch_app_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + case 'dimmer': + case 'light_level': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.tune, + label: label, + value: previewValue, + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); case 'current': return SensorMetricCardData( fieldKey: fieldKey, @@ -359,6 +389,15 @@ SensorMetricCardData? _buildOptionPreviewCardData( accent: const Color(0xFF1C7C54), channel: metricKey.channel, ); + case 'signed_current': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.electric_bolt, + label: label, + value: previewValue, + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); case 'frequency': return SensorMetricCardData( fieldKey: fieldKey, @@ -389,6 +428,7 @@ SensorMetricCardData? _buildOptionPreviewCardData( channel: metricKey.channel, ); case 'power': + case 'signed_power': return SensorMetricCardData( fieldKey: fieldKey, icon: Icons.flash_on_outlined, @@ -406,6 +446,42 @@ SensorMetricCardData? _buildOptionPreviewCardData( accent: const Color(0xFF2B78A0), channel: metricKey.channel, ); + case 'signed_speed': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + case 'gust': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.air, + label: label, + value: previewValue, + accent: const Color(0xFF1E88A8), + channel: metricKey.channel, + ); + case 'dew': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.device_thermostat, + label: label, + value: previewValue, + accent: const Color(0xFF2F7AA1), + channel: metricKey.channel, + ); + case 'rain': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.grain, + label: label, + value: previewValue, + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); case 'distance': return SensorMetricCardData( fieldKey: fieldKey, @@ -415,6 +491,15 @@ SensorMetricCardData? _buildOptionPreviewCardData( accent: const Color(0xFF577590), channel: metricKey.channel, ); + case 'duration': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.timer_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); case 'energy': return SensorMetricCardData( fieldKey: fieldKey, @@ -424,7 +509,22 @@ SensorMetricCardData? _buildOptionPreviewCardData( accent: const Color(0xFF9C6644), channel: metricKey.channel, ); + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + case 'mass': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.inventory_2_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5F7C8A), + channel: metricKey.channel, + ); case 'direction': + case 'rotation': final degrees = _previewAsDouble(value); return SensorMetricCardData( fieldKey: fieldKey, @@ -481,6 +581,33 @@ SensorMetricCardData? _buildOptionPreviewCardData( accent: const Color(0xFF0A7D61), channel: metricKey.channel, ); + case 'conductivity': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.science_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + case 'acceleration': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.speed_outlined, + label: label, + value: previewValue, + accent: const Color(0xFF5A4C99), + channel: metricKey.channel, + ); + case 'gyro_rate': + return SensorMetricCardData( + fieldKey: fieldKey, + icon: Icons.sync, + label: label, + value: previewValue, + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); case 'pm25': case 'pm10': return SensorMetricCardData( @@ -543,6 +670,12 @@ String _previewFormatCardinalDirection(double degrees) { String? _sensorMetricPreviewValue(String rawKey, dynamic value) { final metricKey = _parseMetricKey(rawKey); + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + final isOn = _previewAsBool(value); + if (isOn == null) return null; + return isOn ? 'On' : 'Off'; + } + switch (metricKey.baseKey) { case 'altitude': final meters = _previewAsDouble(value); @@ -568,10 +701,21 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { case 'analog_input': case 'analog_output': case 'generic_sensor': + case 'light_level': final reading = _previewAsDouble(value); if (reading == null) return null; return _formatPreviewNumber(reading, maxFractionDigits: 3); + case 'button_event': + final eventCode = _previewAsInt(value); + if (eventCode == null) return null; + return _formatButtonEvent(eventCode); + + case 'dimmer': + final reading = _previewAsInt(value); + if (reading == null) return null; + return reading.toString(); + case 'accelerometer': final vector = _previewAsVector3(value); if (vector == null) return null; @@ -591,6 +735,11 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { if (amps == null) return null; return _formatPreviewCurrent(amps); + case 'signed_current': + final amps = _previewAsDouble(value); + if (amps == null) return null; + return _formatPreviewCurrent(amps); + case 'frequency': final hertz = _previewAsDouble(value); if (hertz == null) return null; @@ -613,26 +762,75 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { if (watts == null) return null; return _formatPreviewPower(watts); + case 'signed_power': + final watts = _previewAsDouble(value); + if (watts == null) return null; + return _formatPreviewPower(watts); + case 'speed': final metersPerSecond = _previewAsDouble(value); if (metersPerSecond == null) return null; return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + case 'signed_speed': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + + case 'gust': + final metersPerSecond = _previewAsDouble(value); + if (metersPerSecond == null) return null; + return '${_formatPreviewNumber(metersPerSecond, maxFractionDigits: 2)} m/s'; + + case 'dew': + final degreesCelsius = _previewAsDouble(value); + if (degreesCelsius == null) return null; + return '${_formatPreviewNumber(degreesCelsius, maxFractionDigits: 1)}°C'; + + case 'rain': + final millimeters = _previewAsDouble(value); + if (millimeters == null) return null; + return '${_formatPreviewNumber(millimeters, maxFractionDigits: 1)} mm'; + case 'distance': final meters = _previewAsDouble(value); if (meters == null) return null; return _formatPreviewDistance(meters); + case 'duration': + final seconds = _previewAsDouble(value); + if (seconds == null) return null; + return '${_formatPreviewNumber(seconds, maxFractionDigits: 2)} s'; + case 'energy': final kilowattHours = _previewAsDouble(value); if (kilowattHours == null) return null; return _formatPreviewEnergy(kilowattHours); + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + final liters = _previewAsDouble(value); + if (liters == null) return null; + return '${_formatPreviewNumber(liters, maxFractionDigits: 3)} L'; + + case 'mass': + final kilograms = _previewAsDouble(value); + if (kilograms == null) return null; + return '${_formatPreviewNumber(kilograms, maxFractionDigits: 3)} kg'; + case 'direction': final degrees = _previewAsDouble(value); if (degrees == null) return null; return '${_formatPreviewNumber(degrees, maxFractionDigits: 0)} deg'; + case 'rotation': + final degrees = _previewAsDouble(value); + if (degrees == null) return null; + return '${_formatPreviewNumber(degrees, maxFractionDigits: 1)} deg'; + case 'unixtime': final seconds = _previewAsInt(value); if (seconds == null) return null; @@ -659,6 +857,21 @@ String? _sensorMetricPreviewValue(String rawKey, dynamic value) { if (volts == null) return null; return '${_formatPreviewNumber(volts, maxFractionDigits: 3)} V'; + case 'conductivity': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return _formatPreviewNumber(reading, maxFractionDigits: 0); + + case 'acceleration': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 3)} m/s2'; + + case 'gyro_rate': + final reading = _previewAsDouble(value); + if (reading == null) return null; + return '${_formatPreviewNumber(reading, maxFractionDigits: 3)} deg/s'; + case 'pm25': case 'pm10': final reading = _previewAsDouble(value); @@ -1179,6 +1392,19 @@ class SensorTelemetryCard extends StatelessWidget { labelOverrides: labelOverrides, ); + if (_isBinaryMetricBaseKey(metricKey.baseKey)) { + final isOn = _asBool(value); + if (isOn == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: isOn ? Icons.toggle_on : Icons.toggle_off, + label: label, + value: isOn ? 'On' : 'Off', + accent: const Color(0xFF4B7B5A), + channel: metricKey.channel, + ); + } + switch (metricKey.baseKey) { case 'altitude': final meters = _asDouble(value); @@ -1310,6 +1536,31 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'button_event': + final reading = _asInt(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.touch_app_outlined, + label: label, + value: _formatButtonEvent(reading), + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); + + case 'dimmer': + case 'light_level': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.tune, + label: label, + value: _formatNumber(reading, maxFractionDigits: 2), + accent: const Color(0xFF5A6C84), + channel: metricKey.channel, + ); + case 'current': final amps = _asDouble(value); if (amps == null) return null; @@ -1322,6 +1573,18 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'signed_current': + final amps = _asDouble(value); + if (amps == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.electric_bolt, + label: label, + value: _formatCurrent(amps), + accent: const Color(0xFF1C7C54), + channel: metricKey.channel, + ); + case 'frequency': final hz = _asDouble(value); if (hz == null) return null; @@ -1370,6 +1633,18 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'signed_power': + final watts = _asDouble(value); + if (watts == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.flash_on_outlined, + label: label, + value: _formatPower(watts), + accent: const Color(0xFFB5622E), + channel: metricKey.channel, + ); + case 'speed': final metersPerSecond = _asDouble(value); if (metersPerSecond == null) return null; @@ -1382,6 +1657,54 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'signed_speed': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + accent: const Color(0xFF2B78A0), + channel: metricKey.channel, + ); + + case 'gust': + final metersPerSecond = _asDouble(value); + if (metersPerSecond == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.air, + label: label, + value: '${_formatNumber(metersPerSecond, maxFractionDigits: 2)} m/s', + accent: const Color(0xFF1E88A8), + channel: metricKey.channel, + ); + + case 'dew': + final degreesCelsius = _asDouble(value); + if (degreesCelsius == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.device_thermostat, + label: label, + value: '${_formatNumber(degreesCelsius, maxFractionDigits: 1)}°C', + accent: const Color(0xFF2F7AA1), + channel: metricKey.channel, + ); + + case 'rain': + final millimeters = _asDouble(value); + if (millimeters == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.grain, + label: label, + value: '${_formatNumber(millimeters, maxFractionDigits: 1)} mm', + accent: const Color(0xFF2C6BA0), + channel: metricKey.channel, + ); + case 'distance': final meters = _asDouble(value); if (meters == null) return null; @@ -1394,6 +1717,18 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'duration': + final seconds = _asDouble(value); + if (seconds == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.timer_outlined, + label: label, + value: '${_formatNumber(seconds, maxFractionDigits: 2)} s', + accent: const Color(0xFF577590), + channel: metricKey.channel, + ); + case 'energy': final kwh = _asDouble(value); if (kwh == null) return null; @@ -1406,6 +1741,24 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'volume': + case 'flow_rate': + case 'volume_storage': + case 'water': + case 'gas_volume': + case 'mass': + final reading = _asDouble(value); + if (reading == null) return null; + final unit = metricKey.baseKey == 'mass' ? 'kg' : 'L'; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.inventory_2_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} $unit', + accent: const Color(0xFF5F7C8A), + channel: metricKey.channel, + ); + case 'direction': final degrees = _asDouble(value); if (degrees == null) return null; @@ -1419,6 +1772,18 @@ class SensorTelemetryCard extends StatelessWidget { channel: metricKey.channel, ); + case 'rotation': + final degrees = _asDouble(value); + if (degrees == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.explore_outlined, + label: label, + value: '${_formatNumber(degrees, maxFractionDigits: 1)} deg', + accent: const Color(0xFF8A5A44), + channel: metricKey.channel, + ); + case 'unixtime': final seconds = _asInt(value); if (seconds == null) return null; @@ -1475,6 +1840,42 @@ class SensorTelemetryCard extends StatelessWidget { accent: const Color(0xFF0A7D61), channel: metricKey.channel, ); + + case 'conductivity': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.science_outlined, + label: label, + value: _formatNumber(reading, maxFractionDigits: 0), + accent: const Color(0xFF4D6D9A), + channel: metricKey.channel, + ); + + case 'acceleration': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.speed_outlined, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} m/s2', + accent: const Color(0xFF5A4C99), + channel: metricKey.channel, + ); + + case 'gyro_rate': + final reading = _asDouble(value); + if (reading == null) return null; + return SensorMetricCardData( + fieldKey: _extraFieldKey(rawKey), + icon: Icons.sync, + label: label, + value: '${_formatNumber(reading, maxFractionDigits: 3)} deg/s', + accent: const Color(0xFF6C4F96), + channel: metricKey.channel, + ); } switch (metricKey.baseKey) { @@ -2135,6 +2536,32 @@ String _selectorMetricLabel(String label, int? channel) { return '$label (ch $channel)'; } +bool _isBinaryMetricBaseKey(String baseKey) { + return baseKey.startsWith('binary_'); +} + +String _formatButtonEvent(int value) { + switch (value) { + case 0: + return 'None'; + case 1: + return 'Press'; + case 2: + return 'Double press'; + case 3: + return 'Triple press'; + case 4: + return 'Long press'; + case 5: + return 'Long double'; + case 6: + return 'Long triple'; + case 128: + return 'Hold'; + } + return value.toString(); +} + String _formatExtraFieldLabel(String rawKey) { final metricKey = _parseMetricKey(rawKey); return _knownMetricLabels[metricKey.baseKey] ?? @@ -2168,13 +2595,60 @@ const List _knownMetricBaseKeys = [ 'energy', 'power', 'speed', + 'gust', + 'dew', + 'rain', 'pm25', 'pm10', 'tvoc', 'co2', 'rpm', - 'cond', + 'conductivity', 'uv', + 'button_event', + 'dimmer', + 'light_level', + 'rotation', + 'duration', + 'acceleration', + 'gyro_rate', + 'volume', + 'flow_rate', + 'volume_storage', + 'water', + 'gas_volume', + 'mass', + 'signed_speed', + 'signed_power', + 'signed_current', + 'binary_bool', + 'binary_power_switch', + 'binary_open', + 'binary_battery_low', + 'binary_charging', + 'binary_carbon_monoxide', + 'binary_cold', + 'binary_connectivity', + 'binary_door', + 'binary_garage_door', + 'binary_gas', + 'binary_heat', + 'binary_light', + 'binary_lock', + 'binary_moisture', + 'binary_motion', + 'binary_moving', + 'binary_occupancy', + 'binary_plug', + 'binary_presence', + 'binary_problem', + 'binary_running', + 'binary_safety', + 'binary_smoke', + 'binary_sound', + 'binary_tamper', + 'binary_vibration', + 'binary_window', ]; const Map _knownMetricLabels = { @@ -2185,32 +2659,78 @@ const Map _knownMetricLabels = { 'co2': 'CO2', 'colour': 'Color', 'concentration': 'Concentration', - 'cond': 'Conductivity', + 'conductivity': 'Conductivity', 'current': 'Current', + 'button_event': 'Button', + 'dimmer': 'Dimmer', 'digital_input': 'Digital input', 'digital_output': 'Digital output', 'direction': 'Direction', 'distance': 'Distance', + 'duration': 'Duration', 'energy': 'Energy', 'frequency': 'Frequency', + 'flow_rate': 'Flow rate', + 'gas_volume': 'Gas volume', 'generic_sensor': 'Generic sensor', + 'gyro_rate': 'Gyro rate', 'gyrometer': 'Gyrometer', 'humidity': 'Humidity', 'illuminance': 'Illuminance', + 'light_level': 'Light level', + 'mass': 'Mass', 'percentage': 'Percentage', 'pm10': 'PM10', 'pm25': 'PM2.5', 'power': 'Power', 'presence': 'Presence', 'pressure': 'Pressure', + 'rain': 'Rain', + 'rotation': 'Rotation', 'rpm': 'RPM', + 'gust': 'Wind gust', + 'signed_current': 'Signed current', + 'signed_power': 'Signed power', + 'signed_speed': 'Signed speed', 'speed': 'Speed', 'switch': 'Switch', + 'dew': 'Dew point', 'temperature': 'Temperature', 'tvoc': 'TVOC', 'unixtime': 'Time', 'uv': 'UV index', + 'volume': 'Volume', + 'volume_storage': 'Storage volume', 'voltage': 'Voltage', + 'water': 'Water', + 'binary_battery_low': 'Battery low', + 'binary_bool': 'Binary', + 'binary_carbon_monoxide': 'Carbon monoxide', + 'binary_charging': 'Charging', + 'binary_cold': 'Cold', + 'binary_connectivity': 'Connectivity', + 'binary_door': 'Door', + 'binary_garage_door': 'Garage door', + 'binary_gas': 'Gas', + 'binary_heat': 'Heat', + 'binary_light': 'Light', + 'binary_lock': 'Lock', + 'binary_moisture': 'Moisture', + 'binary_motion': 'Motion', + 'binary_moving': 'Moving', + 'binary_occupancy': 'Occupancy', + 'binary_open': 'Open', + 'binary_plug': 'Plug', + 'binary_power_switch': 'Power switch', + 'binary_presence': 'Presence', + 'binary_problem': 'Problem', + 'binary_running': 'Running', + 'binary_safety': 'Safety', + 'binary_smoke': 'Smoke', + 'binary_sound': 'Sound', + 'binary_tamper': 'Tamper', + 'binary_vibration': 'Vibration', + 'binary_window': 'Window', }; _ParsedMetricKey _parseMetricKey(String rawKey) { diff --git a/test/providers/sensors_provider_test.dart b/test/providers/sensors_provider_test.dart index 071e3f6..f74a107 100644 --- a/test/providers/sensors_provider_test.dart +++ b/test/providers/sensors_provider_test.dart @@ -75,6 +75,42 @@ void main() { ); } + Contact buildTelemetrySensorContact() { + final publicKey = Uint8List(32); + publicKey[0] = 0x55; + + return Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'Weather Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + temperature: 11.8, + humidity: 51, + pressure: 921.9, + timestamp: DateTime.now(), + extraSensorData: const { + '__source_channel:temperature': 3, + '__source_channel:humidity': 2, + '__source_channel:pressure': 2, + 'illuminance_2': 19150.0, + 'temperature_2': 1.99, + 'voltage_2': 5.2, + 'switch_2': 0, + 'speed_2': 2.8, + 'speed_3': 3.7, + 'uv_2': 1.0, + }, + ), + ); + } + test('metric label overrides persist across reloads', () async { SharedPreferences.setMockInitialValues({}); final contact = buildSensorContact(); @@ -206,4 +242,32 @@ void main() { ); expect(connectionProvider.pingCalls, 2); }); + + test( + 'addSensor includes available extra telemetry fields by default', + () async { + SharedPreferences.setMockInitialValues({}); + final contact = buildTelemetrySensorContact(); + + final provider = SensorsProvider(); + await waitUntilLoaded(provider); + await provider.addSensor(contact); + + expect( + provider.visibleFieldsFor(contact.publicKeyHex), + containsAll({ + 'temperature', + 'humidity', + 'pressure', + 'extra:illuminance_2', + 'extra:temperature_2', + 'extra:voltage_2', + 'extra:switch_2', + 'extra:speed_2', + 'extra:speed_3', + 'extra:uv_2', + }), + ); + }, + ); } diff --git a/test/services/cayenne_lpp_parser_test.dart b/test/services/cayenne_lpp_parser_test.dart index 82379e3..e429305 100644 --- a/test/services/cayenne_lpp_parser_test.dart +++ b/test/services/cayenne_lpp_parser_test.dart @@ -373,6 +373,9 @@ void main() { const lppDirection = 132; const lppUnixTime = 133; const lppColour = 135; + const lppGust = 137; + const lppDewPoint = 138; + const lppRain = 139; const lppSwitch = 142; final payload = Uint8List.fromList([ @@ -388,6 +391,9 @@ void main() { 11, lppDirection, 0x01, 0x0E, // 270 deg 12, lppUnixTime, 0x65, 0xF0, 0x00, 0x00, // 1710221312 13, lppColour, 0xFF, 0x80, 0x40, // #FF8040 + 15, lppGust, 0x01, 0x72, // 3.70 m/s + 16, lppDewPoint, 0x00, 0x14, // 2.0 C + 17, lppRain, 0x00, 0x7B, // 12.3 mm 14, lppSwitch, 0x01, // on ]); @@ -409,9 +415,59 @@ void main() { decoded.extraSensorData!['colour_13'], equals({'r': 255, 'g': 128, 'b': 64}), ); + expect(decoded.extraSensorData!['gust_15'], closeTo(3.7, 0.001)); + expect(decoded.extraSensorData!['dew_16'], closeTo(2.0, 0.001)); + expect(decoded.extraSensorData!['rain_17'], closeTo(12.3, 0.001)); expect(decoded.extraSensorData!['switch_14'], equals(1)); }); + test('new MeshCore custom telemetry types decode without truncation', () { + const lppBinaryMoisture = 157; + const lppButtonEvent = 171; + const lppDimmer = 172; + const lppUv = 173; + const lppPm25 = 175; + const lppConductivity = 180; + const lppDuration = 182; + const lppSignedSpeed = 191; + const lppSignedPower = 192; + const lppSignedCurrent = 193; + + final payload = Uint8List.fromList([ + 2, lppBinaryMoisture, 0x01, + 3, lppButtonEvent, 0x04, + 4, lppDimmer, 0xFB, // -5 + 5, lppUv, 0x0A, // 1.0 + 6, lppPm25, 0x00, 0x0C, // 12 + 7, lppConductivity, 0x01, 0xF4, // 500 + 8, lppDuration, 0x00, 0x00, 0x13, 0x88, // 5.0 s + 9, lppSignedSpeed, 0xFF, 0xE1, 0x7B, 0x80, // -2.0 m/s + 10, lppSignedPower, 0xFF, 0xFF, 0xFC, 0x18, // -10.0 W + 11, lppSignedCurrent, 0xFF, 0xFF, 0xFC, 0x18, // -1.0 A + 12, MeshCoreConstants.lppTemperatureSensor, 0x00, 0x76, // 11.8 C + ]); + + final decoded = CayenneLppParser.parse(payload); + + expect(decoded.extraSensorData!['binary_moisture_2'], equals(1)); + expect(decoded.extraSensorData!['button_event_3'], equals(4)); + expect(decoded.extraSensorData!['dimmer_4'], equals(-5)); + expect(decoded.extraSensorData!['uv_5'], closeTo(1.0, 0.001)); + expect(decoded.extraSensorData!['pm25_6'], equals(12.0)); + expect(decoded.extraSensorData!['conductivity_7'], equals(500.0)); + expect(decoded.extraSensorData!['duration_8'], closeTo(5.0, 0.001)); + expect(decoded.extraSensorData!['signed_speed_9'], closeTo(-2.0, 0.001)); + expect( + decoded.extraSensorData!['signed_power_10'], + closeTo(-10.0, 0.001), + ); + expect( + decoded.extraSensorData!['signed_current_11'], + closeTo(-1.0, 0.001), + ); + expect(decoded.temperature, closeTo(11.8, 0.1)); + }); + test( 'percentage battery and non-battery voltage channels are preserved separately', () { diff --git a/test/widgets/sensor_telemetry_card_test.dart b/test/widgets/sensor_telemetry_card_test.dart index 0a8e560..262ebe0 100644 --- a/test/widgets/sensor_telemetry_card_test.dart +++ b/test/widgets/sensor_telemetry_card_test.dart @@ -50,9 +50,10 @@ void main() { 'temperature': 'Ambient', 'extra:illuminance_2': 'Light', }, - fieldSpans: sensorFullWidthFieldSpans( - const {'temperature', 'extra:illuminance_2'}, - ), + fieldSpans: sensorFullWidthFieldSpans(const { + 'temperature', + 'extra:illuminance_2', + }), ), ), ), @@ -111,18 +112,12 @@ void main() { 'temperature', 'extra:illuminance_2', }, - fieldOrder: const [ - 'extra:illuminance_2', - 'temperature', + fieldOrder: const ['extra:illuminance_2', 'temperature', 'battery'], + fieldSpans: sensorFullWidthFieldSpans(const { 'battery', - ], - fieldSpans: sensorFullWidthFieldSpans( - const { - 'battery', - 'temperature', - 'extra:illuminance_2', - }, - ), + 'temperature', + 'extra:illuminance_2', + }), ), ), ), @@ -141,4 +136,55 @@ void main() { expect(illuminanceTop.dy, lessThan(temperatureTop.dy)); expect(temperatureTop.dy, lessThan(batteryTop.dy)); }); + + testWidgets('renders MeshCore custom weather metrics', (tester) async { + final publicKey = Uint8List(32); + publicKey[0] = 0x46; + final contact = Contact( + publicKey: publicKey, + type: ContactType.sensor, + flags: 0, + outPathLen: 0, + outPath: Uint8List(64), + advName: 'WX Station', + lastAdvert: DateTime.now().millisecondsSinceEpoch ~/ 1000, + advLat: 0, + advLon: 0, + lastMod: DateTime.now().millisecondsSinceEpoch ~/ 1000, + telemetry: ContactTelemetry( + timestamp: DateTime.now().subtract(const Duration(minutes: 1)), + extraSensorData: const {'gust_2': 3.7, 'dew_2': 2.0, 'rain_2': 12.3}, + ), + ); + + await tester.pumpWidget( + MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: SensorTelemetryCard( + contact: contact, + state: SensorRefreshState.idle, + visibleFields: const { + 'extra:gust_2', + 'extra:dew_2', + 'extra:rain_2', + }, + fieldSpans: sensorFullWidthFieldSpans(const { + 'extra:gust_2', + 'extra:dew_2', + 'extra:rain_2', + }), + ), + ), + ), + ); + + expect(find.text('Wind gust'), findsOneWidget); + expect(find.text('Dew point'), findsOneWidget); + expect(find.text('Rain'), findsOneWidget); + expect(find.text('3.7 m/s'), findsOneWidget); + expect(find.text('2°C'), findsOneWidget); + expect(find.text('12.3 mm'), findsOneWidget); + }); }