diff --git a/.skills/compose-ui/strings-index.txt b/.skills/compose-ui/strings-index.txt index 9a7c48713..93c51a572 100644 --- a/.skills/compose-ui/strings-index.txt +++ b/.skills/compose-ui/strings-index.txt @@ -43,7 +43,9 @@ advanced advanced_device_gps advanced_title ### AIR ### +air_quality air_quality_icon +air_quality_metrics_log air_quality_metrics_module_enabled air_quality_metrics_update_interval_seconds air_util_definition @@ -179,6 +181,7 @@ clear_time_zone client_notification close close_selection +co2 codec_2_enabled codec2_sample_rate coding_rate @@ -751,6 +754,7 @@ message_status_sfpp_confirmed message_status_sfpp_routing message_status_unknown messages +micrograms_per_cubic_meter min minimum_broadcast_seconds minimum_distance @@ -953,6 +957,9 @@ play plurals_hours plurals_minutes plurals_seconds +pm1_0 +pm10 +pm2_5 ### POSITION ### position position_config_set_fixed_from_phone @@ -968,6 +975,7 @@ power_metrics_module_enabled power_metrics_on_screen_enabled power_metrics_update_interval_seconds powered +ppm precise_location preferences_language preferences_system_default diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt index 63569f9d2..70e170dde 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/NodeManagerImpl.kt @@ -283,6 +283,7 @@ class NodeManagerImpl( telemetry.device_metrics?.let { nextNode = nextNode.copy(deviceMetrics = it) } telemetry.environment_metrics?.let { nextNode = nextNode.copy(environmentMetrics = it) } telemetry.power_metrics?.let { nextNode = nextNode.copy(powerMetrics = it) } + telemetry.air_quality_metrics?.let { nextNode = nextNode.copy(airQualityMetrics = it) } val telemetryTime = if (telemetry.time != 0) telemetry.time else node.lastHeard val newLastHeard = clampTimestampToNow(maxOf(node.lastHeard, telemetryTime)) nextNode.copy(lastHeard = newLastHeard) diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt index f444fb00c..c3bcbab97 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt @@ -116,6 +116,10 @@ class TelemetryPacketHandlerImpl( environment != null -> nextNode = nextNode.copy(environmentMetrics = environment) power != null -> nextNode = nextNode.copy(powerMetrics = power) + + t.air_quality_metrics != null -> { + t.air_quality_metrics?.let { aq -> nextNode = nextNode.copy(airQualityMetrics = aq) } + } } val telemetryTime = if (t.time != 0) t.time else nextNode.lastHeard diff --git a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt index 19d82afca..2e8f7f5d6 100644 --- a/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/org/meshtastic/core/data/repository/NodeRepositoryImpl.kt @@ -283,6 +283,7 @@ class NodeRepositoryImpl( isMuted = isMuted, environmentTelemetry = org.meshtastic.proto.Telemetry(environment_metrics = environmentMetrics), powerTelemetry = org.meshtastic.proto.Telemetry(power_metrics = powerMetrics), + airQualityTelemetry = org.meshtastic.proto.Telemetry(air_quality_metrics = airQualityMetrics), paxcounter = paxcounter, publicKey = publicKey, notes = notes, diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/40.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/40.json new file mode 100644 index 000000000..c89c017cb --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/40.json @@ -0,0 +1,1102 @@ +{ + "formatVersion": 1, + "database": { + "version": 40, + "identityHash": "cf7174b4ee405f9980bdad4068a9e879", + "entities": [ + { + "tableName": "my_node", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL, `model` TEXT, `firmwareVersion` TEXT, `couldUpdate` INTEGER NOT NULL, `shouldUpdate` INTEGER NOT NULL, `currentPacketId` INTEGER NOT NULL, `messageTimeoutMsec` INTEGER NOT NULL, `minAppVersion` INTEGER NOT NULL, `maxChannels` INTEGER NOT NULL, `hasWifi` INTEGER NOT NULL, `deviceId` TEXT, `pioEnv` TEXT, PRIMARY KEY(`myNodeNum`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "model", + "columnName": "model", + "affinity": "TEXT" + }, + { + "fieldPath": "firmwareVersion", + "columnName": "firmwareVersion", + "affinity": "TEXT" + }, + { + "fieldPath": "couldUpdate", + "columnName": "couldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "shouldUpdate", + "columnName": "shouldUpdate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "currentPacketId", + "columnName": "currentPacketId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "messageTimeoutMsec", + "columnName": "messageTimeoutMsec", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "minAppVersion", + "columnName": "minAppVersion", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "maxChannels", + "columnName": "maxChannels", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasWifi", + "columnName": "hasWifi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceId", + "columnName": "deviceId", + "affinity": "TEXT" + }, + { + "fieldPath": "pioEnv", + "columnName": "pioEnv", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum" + ] + } + }, + { + "tableName": "nodes", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `user` BLOB NOT NULL, `long_name` TEXT, `short_name` TEXT, `position` BLOB NOT NULL, `latitude` REAL NOT NULL, `longitude` REAL NOT NULL, `snr` REAL NOT NULL, `rssi` INTEGER NOT NULL, `last_heard` INTEGER NOT NULL, `device_metrics` BLOB NOT NULL, `channel` INTEGER NOT NULL, `via_mqtt` INTEGER NOT NULL, `hops_away` INTEGER NOT NULL, `is_favorite` INTEGER NOT NULL, `is_ignored` INTEGER NOT NULL DEFAULT 0, `is_muted` INTEGER NOT NULL DEFAULT 0, `environment_metrics` BLOB NOT NULL, `power_metrics` BLOB NOT NULL, `air_quality_metrics` BLOB NOT NULL DEFAULT x'', `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, `last_transport` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "user", + "columnName": "user", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "longName", + "columnName": "long_name", + "affinity": "TEXT" + }, + { + "fieldPath": "shortName", + "columnName": "short_name", + "affinity": "TEXT" + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "latitude", + "columnName": "latitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "longitude", + "columnName": "longitude", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastHeard", + "columnName": "last_heard", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "deviceTelemetry", + "columnName": "device_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "viaMqtt", + "columnName": "via_mqtt", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hopsAway", + "columnName": "hops_away", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isFavorite", + "columnName": "is_favorite", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "isIgnored", + "columnName": "is_ignored", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isMuted", + "columnName": "is_muted", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "environmentTelemetry", + "columnName": "environment_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "powerTelemetry", + "columnName": "power_metrics", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "airQualityTelemetry", + "columnName": "air_quality_metrics", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + }, + { + "fieldPath": "paxcounter", + "columnName": "paxcounter", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "publicKey", + "columnName": "public_key", + "affinity": "BLOB" + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "manuallyVerified", + "columnName": "manually_verified", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "nodeStatus", + "columnName": "node_status", + "affinity": "TEXT" + }, + { + "fieldPath": "lastTransport", + "columnName": "last_transport", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_nodes_last_heard", + "unique": false, + "columnNames": [ + "last_heard" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard` ON `${TABLE_NAME}` (`last_heard`)" + }, + { + "name": "index_nodes_short_name", + "unique": false, + "columnNames": [ + "short_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_short_name` ON `${TABLE_NAME}` (`short_name`)" + }, + { + "name": "index_nodes_long_name", + "unique": false, + "columnNames": [ + "long_name" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_long_name` ON `${TABLE_NAME}` (`long_name`)" + }, + { + "name": "index_nodes_hops_away", + "unique": false, + "columnNames": [ + "hops_away" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_hops_away` ON `${TABLE_NAME}` (`hops_away`)" + }, + { + "name": "index_nodes_is_favorite", + "unique": false, + "columnNames": [ + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_is_favorite` ON `${TABLE_NAME}` (`is_favorite`)" + }, + { + "name": "index_nodes_last_heard_is_favorite", + "unique": false, + "columnNames": [ + "last_heard", + "is_favorite" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_last_heard_is_favorite` ON `${TABLE_NAME}` (`last_heard`, `is_favorite`)" + }, + { + "name": "index_nodes_public_key", + "unique": false, + "columnNames": [ + "public_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_nodes_public_key` ON `${TABLE_NAME}` (`public_key`)" + } + ] + }, + { + "tableName": "packet", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `myNodeNum` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL, `contact_key` TEXT NOT NULL, `received_time` INTEGER NOT NULL, `read` INTEGER NOT NULL DEFAULT 1, `data` TEXT NOT NULL, `packet_id` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT -1, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `sfpp_hash` BLOB, `filtered` INTEGER NOT NULL DEFAULT 0, `message_text` TEXT NOT NULL DEFAULT '')", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "port_num", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_time", + "columnName": "received_time", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "read", + "columnName": "read", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "1" + }, + { + "fieldPath": "data", + "columnName": "data", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + }, + { + "fieldPath": "filtered", + "columnName": "filtered", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_packet_myNodeNum", + "unique": false, + "columnNames": [ + "myNodeNum" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_myNodeNum` ON `${TABLE_NAME}` (`myNodeNum`)" + }, + { + "name": "index_packet_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_port_num` ON `${TABLE_NAME}` (`port_num`)" + }, + { + "name": "index_packet_contact_key", + "unique": false, + "columnNames": [ + "contact_key" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key` ON `${TABLE_NAME}` (`contact_key`)" + }, + { + "name": "index_packet_contact_key_port_num_received_time", + "unique": false, + "columnNames": [ + "contact_key", + "port_num", + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_contact_key_port_num_received_time` ON `${TABLE_NAME}` (`contact_key`, `port_num`, `received_time`)" + }, + { + "name": "index_packet_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + }, + { + "name": "index_packet_received_time", + "unique": false, + "columnNames": [ + "received_time" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_received_time` ON `${TABLE_NAME}` (`received_time`)" + }, + { + "name": "index_packet_filtered", + "unique": false, + "columnNames": [ + "filtered" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_filtered` ON `${TABLE_NAME}` (`filtered`)" + }, + { + "name": "index_packet_read", + "unique": false, + "columnNames": [ + "read" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_packet_read` ON `${TABLE_NAME}` (`read`)" + } + ] + }, + { + "tableName": "packet_fts", + "createSql": "CREATE VIRTUAL TABLE IF NOT EXISTS `${TABLE_NAME}` USING FTS5(`message_text`, tokenize=`unicode61`, content=`packet`)", + "fields": [ + { + "fieldPath": "messageText", + "columnName": "message_text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [] + }, + "ftsVersion": "FTS5", + "ftsOptions": { + "tokenizer": "unicode61", + "tokenizerArgs": [], + "contentTable": "packet", + "languageIdColumnName": "", + "matchInfo": "FTS4", + "notIndexedColumns": [], + "prefixSizes": [], + "preferredOrder": "ASC", + "contentRowId": "", + "columnSize": true, + "detail": "FULL" + }, + "contentSyncTriggers": [ + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_UPDATE BEFORE UPDATE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_BEFORE_DELETE BEFORE DELETE ON `packet` BEGIN DELETE FROM `packet_fts` WHERE `rowid`=OLD.`rowid`; END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_UPDATE AFTER UPDATE ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END", + "CREATE TRIGGER IF NOT EXISTS room_fts_content_sync_packet_fts_AFTER_INSERT AFTER INSERT ON `packet` BEGIN INSERT INTO `packet_fts`(`rowid`, `message_text`) VALUES (NEW.`rowid`, NEW.`message_text`); END" + ] + }, + { + "tableName": "contact_settings", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`contact_key` TEXT NOT NULL, `muteUntil` INTEGER NOT NULL, `last_read_message_uuid` INTEGER, `last_read_message_timestamp` INTEGER, `filtering_disabled` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`contact_key`))", + "fields": [ + { + "fieldPath": "contact_key", + "columnName": "contact_key", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "muteUntil", + "columnName": "muteUntil", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastReadMessageUuid", + "columnName": "last_read_message_uuid", + "affinity": "INTEGER" + }, + { + "fieldPath": "lastReadMessageTimestamp", + "columnName": "last_read_message_timestamp", + "affinity": "INTEGER" + }, + { + "fieldPath": "filteringDisabled", + "columnName": "filtering_disabled", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "contact_key" + ] + } + }, + { + "tableName": "log", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` TEXT NOT NULL, `type` TEXT NOT NULL, `received_date` INTEGER NOT NULL, `message` TEXT NOT NULL, `from_num` INTEGER NOT NULL DEFAULT 0, `port_num` INTEGER NOT NULL DEFAULT 0, `from_radio` BLOB NOT NULL DEFAULT x'', PRIMARY KEY(`uuid`))", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message_type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "received_date", + "columnName": "received_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "raw_message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "fromNum", + "columnName": "from_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "portNum", + "columnName": "port_num", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "fromRadio", + "columnName": "from_radio", + "affinity": "BLOB", + "notNull": true, + "defaultValue": "x''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "uuid" + ] + }, + "indices": [ + { + "name": "index_log_from_num", + "unique": false, + "columnNames": [ + "from_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_from_num` ON `${TABLE_NAME}` (`from_num`)" + }, + { + "name": "index_log_port_num", + "unique": false, + "columnNames": [ + "port_num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_log_port_num` ON `${TABLE_NAME}` (`port_num`)" + } + ] + }, + { + "tableName": "quick_chat", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`uuid` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `message` TEXT NOT NULL, `mode` TEXT NOT NULL, `position` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "uuid", + "columnName": "uuid", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "message", + "columnName": "message", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mode", + "columnName": "mode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "uuid" + ] + } + }, + { + "tableName": "reactions", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`myNodeNum` INTEGER NOT NULL DEFAULT 0, `reply_id` INTEGER NOT NULL, `user_id` TEXT NOT NULL, `emoji` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `snr` REAL NOT NULL DEFAULT 0, `rssi` INTEGER NOT NULL DEFAULT 0, `hopsAway` INTEGER NOT NULL DEFAULT -1, `packet_id` INTEGER NOT NULL DEFAULT 0, `status` INTEGER NOT NULL DEFAULT 0, `routing_error` INTEGER NOT NULL DEFAULT 0, `relays` INTEGER NOT NULL DEFAULT 0, `relay_node` INTEGER, `to` TEXT, `channel` INTEGER NOT NULL DEFAULT 0, `sfpp_hash` BLOB, PRIMARY KEY(`myNodeNum`, `reply_id`, `user_id`, `emoji`))", + "fields": [ + { + "fieldPath": "myNodeNum", + "columnName": "myNodeNum", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "replyId", + "columnName": "reply_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "userId", + "columnName": "user_id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "emoji", + "columnName": "emoji", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "snr", + "columnName": "snr", + "affinity": "REAL", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "rssi", + "columnName": "rssi", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "hopsAway", + "columnName": "hopsAway", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "-1" + }, + { + "fieldPath": "packetId", + "columnName": "packet_id", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "routingError", + "columnName": "routing_error", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relays", + "columnName": "relays", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "relayNode", + "columnName": "relay_node", + "affinity": "INTEGER" + }, + { + "fieldPath": "to", + "columnName": "to", + "affinity": "TEXT" + }, + { + "fieldPath": "channel", + "columnName": "channel", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "sfpp_hash", + "columnName": "sfpp_hash", + "affinity": "BLOB" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "myNodeNum", + "reply_id", + "user_id", + "emoji" + ] + }, + "indices": [ + { + "name": "index_reactions_reply_id", + "unique": false, + "columnNames": [ + "reply_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_reply_id` ON `${TABLE_NAME}` (`reply_id`)" + }, + { + "name": "index_reactions_packet_id", + "unique": false, + "columnNames": [ + "packet_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_reactions_packet_id` ON `${TABLE_NAME}` (`packet_id`)" + } + ] + }, + { + "tableName": "metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`num` INTEGER NOT NULL, `proto` BLOB NOT NULL, `timestamp` INTEGER NOT NULL, PRIMARY KEY(`num`))", + "fields": [ + { + "fieldPath": "num", + "columnName": "num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "proto", + "columnName": "proto", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "num" + ] + }, + "indices": [ + { + "name": "index_metadata_num", + "unique": false, + "columnNames": [ + "num" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_metadata_num` ON `${TABLE_NAME}` (`num`)" + } + ] + }, + { + "tableName": "device_hardware", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`actively_supported` INTEGER NOT NULL, `architecture` TEXT NOT NULL, `display_name` TEXT NOT NULL, `has_ink_hud` INTEGER, `has_mui` INTEGER, `hwModel` INTEGER NOT NULL, `hw_model_slug` TEXT NOT NULL, `images` TEXT, `last_updated` INTEGER NOT NULL, `partition_scheme` TEXT, `platformio_target` TEXT NOT NULL, `requires_dfu` INTEGER, `support_level` INTEGER, `tags` TEXT, PRIMARY KEY(`platformio_target`))", + "fields": [ + { + "fieldPath": "activelySupported", + "columnName": "actively_supported", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "architecture", + "columnName": "architecture", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "hasInkHud", + "columnName": "has_ink_hud", + "affinity": "INTEGER" + }, + { + "fieldPath": "hasMui", + "columnName": "has_mui", + "affinity": "INTEGER" + }, + { + "fieldPath": "hwModel", + "columnName": "hwModel", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hwModelSlug", + "columnName": "hw_model_slug", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT" + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "partitionScheme", + "columnName": "partition_scheme", + "affinity": "TEXT" + }, + { + "fieldPath": "platformioTarget", + "columnName": "platformio_target", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requiresDfu", + "columnName": "requires_dfu", + "affinity": "INTEGER" + }, + { + "fieldPath": "supportLevel", + "columnName": "support_level", + "affinity": "INTEGER" + }, + { + "fieldPath": "tags", + "columnName": "tags", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "platformio_target" + ] + } + }, + { + "tableName": "firmware_release", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `page_url` TEXT NOT NULL, `release_notes` TEXT NOT NULL, `title` TEXT NOT NULL, `zip_url` TEXT NOT NULL, `last_updated` INTEGER NOT NULL, `release_type` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pageUrl", + "columnName": "page_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "releaseNotes", + "columnName": "release_notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "zipUrl", + "columnName": "zip_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastUpdated", + "columnName": "last_updated", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "releaseType", + "columnName": "release_type", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "traceroute_node_position", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`log_uuid` TEXT NOT NULL, `request_id` INTEGER NOT NULL, `node_num` INTEGER NOT NULL, `position` BLOB NOT NULL, PRIMARY KEY(`log_uuid`, `node_num`), FOREIGN KEY(`log_uuid`) REFERENCES `log`(`uuid`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "logUuid", + "columnName": "log_uuid", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "requestId", + "columnName": "request_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "nodeNum", + "columnName": "node_num", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "BLOB", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "log_uuid", + "node_num" + ] + }, + "indices": [ + { + "name": "index_traceroute_node_position_log_uuid", + "unique": false, + "columnNames": [ + "log_uuid" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_log_uuid` ON `${TABLE_NAME}` (`log_uuid`)" + }, + { + "name": "index_traceroute_node_position_request_id", + "unique": false, + "columnNames": [ + "request_id" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_traceroute_node_position_request_id` ON `${TABLE_NAME}` (`request_id`)" + } + ], + "foreignKeys": [ + { + "table": "log", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "log_uuid" + ], + "referencedColumns": [ + "uuid" + ] + } + ] + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'cf7174b4ee405f9980bdad4068a9e879')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index 6eade9488..82eb8b003 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -98,8 +98,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 36, to = 37), AutoMigration(from = 37, to = 38), AutoMigration(from = 38, to = 39), + AutoMigration(from = 39, to = 40), ], - version = 39, + version = 40, exportSchema = true, ) @androidx.room3.ConstructedBy(MeshtasticDatabaseConstructor::class) diff --git a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index c5ee575f3..2040d2308 100644 --- a/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -59,6 +59,7 @@ data class NodeWithRelations( isMuted = node.isMuted, environmentMetrics = node.environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(), powerMetrics = node.powerMetrics ?: org.meshtastic.proto.PowerMetrics(), + airQualityMetrics = node.airQualityMetrics ?: org.meshtastic.proto.AirQualityMetrics(), paxcounter = node.paxcounter, publicKey = node.publicKey ?: node.user.public_key, notes = node.notes, @@ -85,6 +86,7 @@ data class NodeWithRelations( isMuted = isMuted, environmentTelemetry = environmentTelemetry, powerTelemetry = powerTelemetry, + airQualityTelemetry = airQualityTelemetry, paxcounter = paxcounter, publicKey = publicKey ?: user.public_key, notes = notes, @@ -137,6 +139,8 @@ data class NodeEntity( @ColumnInfo(name = "environment_metrics", typeAffinity = ColumnInfo.BLOB) var environmentTelemetry: Telemetry = Telemetry(), @ColumnInfo(name = "power_metrics", typeAffinity = ColumnInfo.BLOB) var powerTelemetry: Telemetry = Telemetry(), + @ColumnInfo(name = "air_quality_metrics", typeAffinity = ColumnInfo.BLOB, defaultValue = "x''") + var airQualityTelemetry: Telemetry = Telemetry(), @ColumnInfo(typeAffinity = ColumnInfo.BLOB) var paxcounter: Paxcount = Paxcount(), @ColumnInfo(name = "public_key") var publicKey: ByteString? = null, @ColumnInfo(name = "notes", defaultValue = "") var notes: String = "", @@ -155,6 +159,9 @@ data class NodeEntity( val powerMetrics: org.meshtastic.proto.PowerMetrics? get() = powerTelemetry.power_metrics + val airQualityMetrics: org.meshtastic.proto.AirQualityMetrics? + get() = airQualityTelemetry.air_quality_metrics + val isUnknownUser get() = user.hw_model == HardwareModel.UNSET @@ -200,6 +207,7 @@ data class NodeEntity( isMuted = isMuted, environmentMetrics = environmentMetrics ?: org.meshtastic.proto.EnvironmentMetrics(), powerMetrics = powerMetrics ?: org.meshtastic.proto.PowerMetrics(), + airQualityMetrics = airQualityMetrics ?: org.meshtastic.proto.AirQualityMetrics(), paxcounter = paxcounter, publicKey = publicKey ?: user.public_key, notes = notes, diff --git a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt index f670cefba..28d12227c 100644 --- a/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +++ b/core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt @@ -24,6 +24,7 @@ import org.meshtastic.core.common.util.bearing import org.meshtastic.core.common.util.latLongToMeter import org.meshtastic.core.model.util.onlineTimeThreshold import org.meshtastic.core.model.util.toDistanceString +import org.meshtastic.proto.AirQualityMetrics import org.meshtastic.proto.Config import org.meshtastic.proto.DeviceMetadata import org.meshtastic.proto.DeviceMetrics @@ -58,6 +59,7 @@ data class Node( val isMuted: Boolean = false, val environmentMetrics: EnvironmentMetrics = EnvironmentMetrics(), val powerMetrics: PowerMetrics = PowerMetrics(), + val airQualityMetrics: AirQualityMetrics = AirQualityMetrics(), val paxcounter: Paxcount = Paxcount(), val publicKey: ByteString? = null, val notes: String = "", @@ -92,6 +94,9 @@ data class Node( val hasPowerMetrics: Boolean get() = powerMetrics != PowerMetrics() + val hasAirQualityMetrics: Boolean + get() = airQualityMetrics != AirQualityMetrics() + val batteryLevel get() = deviceMetrics.battery_level diff --git a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt index 2f27056c3..a0a93caf1 100644 --- a/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -79,6 +79,8 @@ sealed interface NodeDetailRoute : Route { @Serializable data class PaxMetrics(val destNum: Int) : NodeDetailRoute + @Serializable data class AirQualityMetrics(val destNum: Int) : NodeDetailRoute + @Serializable data class NeighborInfoLog(val destNum: Int) : NodeDetailRoute } diff --git a/core/resources/src/commonMain/composeResources/values/strings.xml b/core/resources/src/commonMain/composeResources/values/strings.xml index 414be1f99..134be20b6 100644 --- a/core/resources/src/commonMain/composeResources/values/strings.xml +++ b/core/resources/src/commonMain/composeResources/values/strings.xml @@ -61,7 +61,9 @@ Advanced Device GPS Advanced + Air Quality Air quality icon + Air Quality Metrics Log Air quality metrics module enabled Air quality metrics update interval Percent of airtime for transmission used within the last hour. @@ -197,6 +199,7 @@ Client Notification Close Close selection + CO₂ CODEC 2 enabled CODEC2 sample rate Coding Rate @@ -781,6 +784,7 @@ Routing via SF++ chain… Unknown Messages + µg/m³ Min Minimum broadcast (seconds) Smart Distance @@ -992,6 +996,9 @@ %1$d second %1$d seconds + PM1.0 + PM10 + PM2.5 Position Set from current phone location @@ -1007,6 +1014,7 @@ Power metrics on-screen enabled Power metrics update interval Powered + ppm Precise location Language System default diff --git a/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/Co2Severity.kt b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/Co2Severity.kt new file mode 100644 index 000000000..9c9562bd7 --- /dev/null +++ b/core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/Co2Severity.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.core.ui.component + +import androidx.compose.ui.graphics.Color + +/** + * CO₂ severity levels based on concentration thresholds (ppm). + * + * Thresholds per design/issues/53: + * - Good: 0–1000 ppm + * - Stuffy: 1000–2000 ppm + * - Poor: 2000–5000 ppm + * - Unsafe: 5000–30000 ppm + * - Evacuate: 30000+ ppm + */ +@Suppress("MagicNumber") +enum class Co2Severity(val color: Color, val label: String) { + GOOD(Color(0xFF4CAF50), "Good"), + STUFFY(Color(0xFFFFC107), "Stuffy"), + POOR(Color(0xFFFF9800), "Poor"), + UNSAFE(Color(0xFFF44336), "Unsafe"), + EVACUATE(Color(0xFFB71C1C), "Evacuate"), + ; + + companion object { + /** Returns the [Co2Severity] for the given [ppm] value, or null if ppm is 0 or negative. */ + fun fromPpm(ppm: Int): Co2Severity? = when { + ppm <= 0 -> null + ppm < 1000 -> GOOD + ppm < 2000 -> STUFFY + ppm < 5000 -> POOR + ppm < 30000 -> UNSAFE + else -> EVACUATE + } + } +} diff --git a/docs/en/user/telemetry-and-sensors.md b/docs/en/user/telemetry-and-sensors.md index 520f3b0f2..5ca4e0ab3 100644 --- a/docs/en/user/telemetry-and-sensors.md +++ b/docs/en/user/telemetry-and-sensors.md @@ -92,6 +92,25 @@ Useful for monitoring solar charging or battery health on remote nodes. > ⚠️ **Note:** Shorter intervals increase airtime usage and battery drain across the mesh. +## Air Quality Metrics + +Nodes with particulate matter or CO₂ sensors report air quality data: + +| Metric | Unit | Description | +|--------|------|-------------| +| PM1.0 | µg/m³ | Ultrafine particulate matter | +| PM2.5 | µg/m³ | Fine particulate matter | +| PM10 | µg/m³ | Coarse particulate matter | +| CO₂ | ppm | Carbon dioxide concentration | + +The CO₂ reading is color-coded by severity: +- 🟢 **Good** (< 1000 ppm) — normal indoor levels +- 🟡 **Moderate** (1000–2000 ppm) — elevated, consider ventilation +- 🟠 **Poor** (2000–5000 ppm) — drowsiness, poor concentration +- 🔴 **Hazardous** (≥ 5000 ppm) — immediate health concern + +Air quality data can be viewed as info cards on the node detail screen, charted over time, and exported to CSV. + ## Viewing Telemetry 1. Navigate to **Nodes** and select a node. @@ -99,6 +118,7 @@ Useful for monitoring solar charging or battery health on remote nodes. - Device Metrics (always available) - Environment Metrics (if sensors present) - Power Metrics (if INA sensor present) + - Air Quality Metrics (if PM/CO₂ sensor present) 3. Historical graphs show trends over time. ![Telemetry actions](../../assets/screenshots/node-metrics_telemetric_actions.png) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt new file mode 100644 index 000000000..fce8dea97 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +package org.meshtastic.feature.node.component + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.model.Node +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.co2 +import org.meshtastic.core.resources.micrograms_per_cubic_meter +import org.meshtastic.core.resources.pm10 +import org.meshtastic.core.resources.pm1_0 +import org.meshtastic.core.resources.pm2_5 +import org.meshtastic.core.resources.ppm +import org.meshtastic.core.ui.component.Co2Severity +import org.meshtastic.core.ui.icon.AirQuality +import org.meshtastic.core.ui.icon.MeshtasticIcons +import org.meshtastic.feature.node.model.VectorMetricInfo + +/** + * Displays air quality info cards for a node showing PM1.0, PM2.5, PM10 and CO₂ values. Cards with zero values are + * hidden. CO₂ value text is color-coded by severity. + */ +@Composable +internal fun AirQualityInfoCards(node: Node) { + val metrics = node.airQualityMetrics + val ugm3 = stringResource(Res.string.micrograms_per_cubic_meter) + val ppmUnit = stringResource(Res.string.ppm) + + val cards = buildList { + metrics.pm10_standard?.let { pm -> + if (pm != 0) { + add(VectorMetricInfo(Res.string.pm1_0, "$pm $ugm3", MeshtasticIcons.AirQuality)) + } + } + metrics.pm25_standard?.let { pm -> + if (pm != 0) { + add(VectorMetricInfo(Res.string.pm2_5, "$pm $ugm3", MeshtasticIcons.AirQuality)) + } + } + metrics.pm100_standard?.let { pm -> + if (pm != 0) { + add(VectorMetricInfo(Res.string.pm10, "$pm $ugm3", MeshtasticIcons.AirQuality)) + } + } + metrics.co2?.let { co2 -> + if (co2 != 0) { + add(VectorMetricInfo(Res.string.co2, "$co2 $ppmUnit", MeshtasticIcons.AirQuality)) + } + } + } + + if (cards.isEmpty()) return + + val co2Value = metrics.co2 ?: 0 + val co2Color = Co2Severity.fromPpm(co2Value)?.color + + FlowRow( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceEvenly, + verticalArrangement = Arrangement.SpaceEvenly, + ) { + cards.forEach { metric -> + val valueColor = + if (metric.label == Res.string.co2 && co2Color != null) { + co2Color + } else { + MaterialTheme.colorScheme.onSurface + } + InfoCard( + icon = metric.icon, + text = stringResource(metric.label), + value = metric.value, + valueColor = valueColor, + ) + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt index adef32d6f..9f7587127 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/InfoCard.kt @@ -61,6 +61,7 @@ fun InfoCard( icon: ImageVector? = null, iconRes: DrawableResource? = null, rotateIcon: Float = 0f, + valueColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurface, ) { val clipboard: Clipboard = LocalClipboard.current val coroutineScope = rememberCoroutineScope() @@ -107,7 +108,7 @@ fun InfoCard( style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) - Text(value, style = MaterialTheme.typography.labelLarge, color = MaterialTheme.colorScheme.onSurface) + Text(value, style = MaterialTheme.typography.labelLarge, color = valueColor) } } } diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt index 2ff709b75..8faf43bae 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/TelemetricActionsSection.kt @@ -179,6 +179,9 @@ private fun rememberTelemetricFeatures( titleRes = Res.string.request_air_quality_metrics, icon = Res.drawable.ic_air, requestAction = { NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY) }, + logsType = LogsType.AIR_QUALITY, + content = { node, _ -> AirQualityInfoCards(node) }, + hasContent = { it.hasAirQualityMetrics }, ), TelemetricFeature( titleRes = LogsType.POWER.titleRes, diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt index 5f68fea95..34c3a1b96 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/domain/usecase/CommonGetNodeDetailsUseCase.kt @@ -187,6 +187,7 @@ constructor( displayUnits = displayUnits, deviceMetrics = logs.telemetry.filter { it.device_metrics != null }, powerMetrics = logs.telemetry.filter { it.power_metrics != null }, + airQualityMetrics = logs.telemetry.filter { it.air_quality_metrics != null }, hostMetrics = logs.telemetry.filter { it.host_metrics != null }, signalMetrics = logs.packets.filter { it.isDirectSignal() }, positionLogs = logs.posPackets.mapNotNull { it.toPosition() }, @@ -211,6 +212,7 @@ constructor( if (environmentState.hasEnvironmentMetrics()) add(LogsType.ENVIRONMENT) if (metricsState.hasSignalMetrics()) add(LogsType.SIGNAL) if (metricsState.hasPowerMetrics()) add(LogsType.POWER) + if (metricsState.hasAirQualityMetrics()) add(LogsType.AIR_QUALITY) if (metricsState.hasTracerouteLogs()) add(LogsType.TRACEROUTE) if (metricsState.hasNeighborInfoLogs()) add(LogsType.NEIGHBOR_INFO) if (metricsState.hasHostMetrics()) add(LogsType.HOST) diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt new file mode 100644 index 000000000..bf97e8a14 --- /dev/null +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt @@ -0,0 +1,299 @@ +/* + * Copyright (c) 2026 Meshtastic LLC + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +@file:Suppress("MagicNumber") + +package org.meshtastic.feature.node.metrics + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.FilterChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.patrykandpatrick.vico.compose.cartesian.VicoScrollState +import com.patrykandpatrick.vico.compose.cartesian.axis.Axis +import com.patrykandpatrick.vico.compose.cartesian.data.lineSeries +import com.patrykandpatrick.vico.compose.cartesian.layer.LineCartesianLayer +import org.jetbrains.compose.resources.StringResource +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.common.util.DateFormatter +import org.meshtastic.core.common.util.NumberFormatter +import org.meshtastic.core.model.TelemetryType +import org.meshtastic.core.model.util.TimeConstants.MS_PER_SEC +import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.air_quality_metrics_log +import org.meshtastic.core.resources.co2 +import org.meshtastic.core.resources.pm10 +import org.meshtastic.core.resources.pm1_0 +import org.meshtastic.core.resources.pm2_5 +import org.meshtastic.core.ui.component.Co2Severity +import org.meshtastic.core.ui.theme.GraphColors.Blue +import org.meshtastic.core.ui.theme.GraphColors.Cyan +import org.meshtastic.core.ui.theme.GraphColors.Green +import org.meshtastic.core.ui.theme.GraphColors.Red +import org.meshtastic.core.ui.util.rememberSaveFileLauncher +import org.meshtastic.proto.Telemetry + +/** Selectable chart metric enum for air quality data series. */ +private enum class AirQuality(val labelRes: StringResource, val unit: String, val color: Color) { + PM1_0(Res.string.pm1_0, "µg/m³", Blue), + PM2_5(Res.string.pm2_5, "µg/m³", Cyan), + PM10(Res.string.pm10, "µg/m³", Green), + CO2(Res.string.co2, "ppm", Red), + ; + + fun getValue(telemetry: Telemetry): Float? { + val aq = telemetry.air_quality_metrics ?: return null + return when (this) { + PM1_0 -> aq.pm10_standard?.takeIf { it != 0 }?.toFloat() + PM2_5 -> aq.pm25_standard?.takeIf { it != 0 }?.toFloat() + PM10 -> aq.pm100_standard?.takeIf { it != 0 }?.toFloat() + CO2 -> aq.co2?.takeIf { it != 0 }?.toFloat() + } + } +} + +private val LEGEND_DATA = + AirQuality.entries.map { metric -> LegendData(nameRes = metric.labelRes, color = metric.color, isLine = true) } + +@Suppress("LongMethod") +@Composable +fun AirQualityMetricsScreen(viewModel: MetricsViewModel, onNavigateUp: () -> Unit, modifier: Modifier = Modifier) { + val state by viewModel.state.collectAsStateWithLifecycle() + val timeFrame by viewModel.timeFrame.collectAsStateWithLifecycle() + val availableTimeFrames by viewModel.availableTimeFrames.collectAsStateWithLifecycle() + val data = state.airQualityMetrics.filter { it.time.toLong() >= timeFrame.timeThreshold() } + + val exportLauncher = rememberSaveFileLauncher { uri -> viewModel.saveAirQualityMetricsCSV(uri, data) } + + val availableMetrics = + remember(data) { AirQuality.entries.filter { metric -> data.any { metric.getValue(it) != null } } } + var selectedMetrics by rememberSaveable { mutableStateOf(setOf(AirQuality.PM2_5, AirQuality.CO2)) } + + BaseMetricScreen( + onNavigateUp = onNavigateUp, + telemetryType = TelemetryType.AIR_QUALITY, + titleRes = Res.string.air_quality_metrics_log, + nodeName = state.node?.user?.long_name ?: "", + data = data, + timeProvider = { it.time.toDouble() }, + onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.AIR_QUALITY) }, + onExportCsv = { exportLauncher("air_quality_metrics.csv", "text/csv") }, + controlPart = { + Column { + TimeFrameSelector( + selectedTimeFrame = timeFrame, + availableTimeFrames = availableTimeFrames, + onTimeFrameSelected = viewModel::setTimeFrame, + modifier = Modifier.padding(horizontal = 16.dp), + ) + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = + Modifier.fillMaxWidth().padding(horizontal = 16.dp).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + availableMetrics.forEach { metric -> + FilterChip( + selected = metric in selectedMetrics, + onClick = { + selectedMetrics = + if (metric in selectedMetrics) { + selectedMetrics - metric + } else { + selectedMetrics + metric + } + }, + label = { Text(stringResource(metric.labelRes)) }, + ) + } + } + } + }, + chartPart = { modifier, selectedX, vicoScrollState, onPointSelected -> + AirQualityChart( + telemetries = data.reversed(), + selectedMetrics = selectedMetrics, + vicoScrollState = vicoScrollState, + selectedX = selectedX, + onSelectPoint = onPointSelected, + modifier = modifier, + ) + }, + listPart = { modifier, selectedX, lazyListState, onCardClick -> + LazyColumn(modifier = modifier.fillMaxSize(), state = lazyListState) { + itemsIndexed(data) { _, telemetry -> + AirQualityMetricsCard( + telemetry = telemetry, + isSelected = telemetry.time.toDouble() == selectedX, + onClick = { onCardClick(telemetry.time.toDouble()) }, + ) + } + } + }, + ) +} + +@Suppress("LongMethod") +@Composable +private fun AirQualityChart( + telemetries: List, + selectedMetrics: Set, + vicoScrollState: VicoScrollState, + selectedX: Double?, + onSelectPoint: (Double) -> Unit, + modifier: Modifier = Modifier, +) { + val activeMetrics = AirQuality.entries.filter { it in selectedMetrics } + val metricLabels = activeMetrics.associateWith { stringResource(it.labelRes) } + MetricChartScaffold( + isEmpty = telemetries.isEmpty() || activeMetrics.isEmpty(), + legendData = LEGEND_DATA.filter { ld -> activeMetrics.any { it.labelRes == ld.nameRes } }, + modifier = modifier, + ) { modelProducer, chartModifier -> + val marker = + ChartStyling.rememberMarker( + valueFormatter = + ChartStyling.createColoredMarkerValueFormatter { value, color -> + val metric = activeMetrics.firstOrNull { it.color == color } + if (metric != null) { + val label = metricLabels[metric] ?: "" + "$label: ${NumberFormatter.format(value.toFloat(), 0)} ${metric.unit}" + } else { + NumberFormatter.format(value.toFloat(), 0) + } + }, + ) + + val metricDataSets = + remember(telemetries, activeMetrics) { + activeMetrics.map { metric -> telemetries.filter { metric.getValue(it) != null } } + } + + LaunchedEffect(telemetries, activeMetrics) { + modelProducer.runTransaction { + activeMetrics.forEachIndexed { index, metric -> + val metricData = metricDataSets[index] + if (metricData.isNotEmpty()) { + lineSeries { + series(x = metricData.map { it.time }, y = metricData.map { metric.getValue(it) ?: 0f }) + } + } + } + } + } + + val layers = + remember(activeMetrics, metricDataSets) { + activeMetrics.mapIndexedNotNull { index, metric -> + if (metricDataSets[index].isNotEmpty()) { + metric to metricDataSets[index] + } else { + null + } + } + } + + val chartLayers = + layers.map { (metric, _) -> + rememberConditionalLayer( + hasData = true, + lineProvider = + LineCartesianLayer.LineProvider.series( + ChartStyling.createStyledLine(metric.color, ChartStyling.THIN_LINE_WIDTH_DP), + ), + verticalAxisPosition = Axis.Position.Vertical.Start, + ) + } + + val nonNullLayers = remember(chartLayers) { chartLayers.filterNotNull() } + + if (nonNullLayers.isNotEmpty()) { + GenericMetricChart( + modelProducer = modelProducer, + modifier = chartModifier, + layers = nonNullLayers, + marker = marker, + selectedX = selectedX, + onPointSelected = onSelectPoint, + vicoScrollState = vicoScrollState, + ) + } + } +} + +@Composable +private fun AirQualityMetricsCard(telemetry: Telemetry, isSelected: Boolean, onClick: () -> Unit) { + val aq = telemetry.air_quality_metrics ?: return + val time = DateFormatter.formatDateTime(telemetry.time.toLong() * MS_PER_SEC) + + SelectableMetricCard(isSelected = isSelected, onClick = onClick) { + Text( + text = time, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Column { + aq.pm10_standard + ?.takeIf { it != 0 } + ?.let { Text("PM1.0: $it µg/m³", style = MaterialTheme.typography.bodySmall) } + aq.pm25_standard + ?.takeIf { it != 0 } + ?.let { Text("PM2.5: $it µg/m³", style = MaterialTheme.typography.bodySmall) } + aq.pm100_standard + ?.takeIf { it != 0 } + ?.let { Text("PM10: $it µg/m³", style = MaterialTheme.typography.bodySmall) } + } + Column { + aq.co2 + ?.takeIf { it != 0 } + ?.let { co2 -> + val severity = Co2Severity.fromPpm(co2) + Text( + text = "CO₂: $co2 ppm", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = severity?.color ?: MaterialTheme.colorScheme.onSurface, + ) + } + } + } + } +} diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt index 2608b6d32..c605e4711 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/MetricsViewModel.kt @@ -436,6 +436,52 @@ open class MetricsViewModel( } } + @Suppress("CyclomaticComplexMethod") + fun saveAirQualityMetricsCSV(uri: CommonUri, data: List) { + exportCsv( + uri = uri, + header = + "\"date\",\"time\",\"pm10_standard\",\"pm25_standard\",\"pm100_standard\"," + + "\"pm10_environmental\",\"pm25_environmental\",\"pm100_environmental\"," + + "\"particles_03um\",\"particles_05um\",\"particles_10um\"," + + "\"particles_25um\",\"particles_50um\",\"particles_100um\"," + + "\"co2\",\"co2_temperature\",\"co2_humidity\"," + + "\"form_formaldehyde\",\"form_humidity\",\"form_temperature\"," + + "\"pm40_standard\",\"particles_40um\"," + + "\"pm_temperature\",\"pm_humidity\",\"pm_voc_idx\",\"pm_nox_idx\"," + + "\"particles_tps\"\n", + rows = data, + epochSeconds = { it.time.toLong() }, + ) { t -> + val aq = t.air_quality_metrics + "\"${aq?.pm10_standard?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.pm25_standard?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.pm100_standard?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.pm10_environmental?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.pm25_environmental?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.pm100_environmental?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.particles_03um?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.particles_05um?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.particles_10um?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.particles_25um?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.particles_50um?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.particles_100um?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.co2?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.co2_temperature?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.co2_humidity?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.form_formaldehyde?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.form_humidity?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.form_temperature?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.pm40_standard?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.particles_40um?.takeIf { it != 0 } ?: ""}\"," + + "\"${aq?.pm_temperature?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.pm_humidity?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.pm_voc_idx?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.pm_nox_idx?.takeIf { it != 0f } ?: ""}\"," + + "\"${aq?.particles_tps?.takeIf { it != 0f } ?: ""}\"" + } + } + // endregion @Suppress("MagicNumber", "CyclomaticComplexMethod", "ReturnCount") diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt index bb87d55a4..055c2d1e4 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt @@ -21,9 +21,11 @@ import org.jetbrains.compose.resources.StringResource import org.meshtastic.core.navigation.NodeDetailRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.air_quality_metrics_log import org.meshtastic.core.resources.device_metrics_log import org.meshtastic.core.resources.env_metrics_log import org.meshtastic.core.resources.host_metrics_log +import org.meshtastic.core.resources.ic_air import org.meshtastic.core.resources.ic_charging_station import org.meshtastic.core.resources.ic_group import org.meshtastic.core.resources.ic_groups @@ -46,6 +48,7 @@ enum class LogsType(val titleRes: StringResource, val icon: DrawableResource, va ENVIRONMENT(Res.string.env_metrics_log, Res.drawable.ic_thermostat, { NodeDetailRoute.EnvironmentMetrics(it) }), SIGNAL(Res.string.signal_quality, Res.drawable.ic_signal_cellular_alt, { NodeDetailRoute.SignalMetrics(it) }), POWER(Res.string.power_metrics_log, Res.drawable.ic_power, { NodeDetailRoute.PowerMetrics(it) }), + AIR_QUALITY(Res.string.air_quality_metrics_log, Res.drawable.ic_air, { NodeDetailRoute.AirQualityMetrics(it) }), TRACEROUTE(Res.string.traceroute_log, Res.drawable.ic_route, { NodeDetailRoute.TracerouteLog(it) }), NEIGHBOR_INFO(Res.string.neighbor_info, Res.drawable.ic_groups, { NodeDetailRoute.NeighborInfoLog(it) }), HOST(Res.string.host_metrics_log, Res.drawable.ic_memory, { NodeDetailRoute.HostMetricsLog(it) }), diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt index ee3c1d5f8..f945daf84 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/MetricsState.kt @@ -46,6 +46,7 @@ data class MetricsState( val latestStableFirmware: FirmwareRelease = FirmwareRelease(), val latestAlphaFirmware: FirmwareRelease = FirmwareRelease(), val paxMetrics: List = emptyList(), + val airQualityMetrics: List = emptyList(), /** The PlatformIO environment reported by the device (if known). */ val reportedTarget: String? = null, ) { @@ -65,10 +66,12 @@ data class MetricsState( fun hasPaxMetrics() = paxMetrics.isNotEmpty() + fun hasAirQualityMetrics() = airQualityMetrics.isNotEmpty() + /** Finds the oldest timestamp (in seconds) among all collected metric types. */ @Suppress("MagicNumber") fun oldestTimestampSeconds(): Long? { - val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics).map { it.time.toLong() } + val telemetryTimes = (deviceMetrics + powerMetrics + hostMetrics + airQualityMetrics).map { it.time.toLong() } val signalTimes = signalMetrics.map { it.rx_time.toLong() } val logTimes = (tracerouteRequests + tracerouteResults + neighborInfoRequests + neighborInfoResults + paxMetrics).map { diff --git a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt index ee4955c32..6d5a61879 100644 --- a/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt +++ b/feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/navigation/NodesNavigation.kt @@ -34,9 +34,11 @@ import org.meshtastic.core.navigation.NodeDetailRoute import org.meshtastic.core.navigation.NodesRoute import org.meshtastic.core.navigation.Route import org.meshtastic.core.resources.Res +import org.meshtastic.core.resources.air_quality import org.meshtastic.core.resources.device import org.meshtastic.core.resources.environment import org.meshtastic.core.resources.host +import org.meshtastic.core.resources.ic_air import org.meshtastic.core.resources.ic_cell_tower import org.meshtastic.core.resources.ic_group import org.meshtastic.core.resources.ic_groups @@ -56,6 +58,7 @@ import org.meshtastic.core.ui.component.ScrollToTopEvent import org.meshtastic.feature.node.compass.CompassViewModel import org.meshtastic.feature.node.detail.NodeDetailScreen import org.meshtastic.feature.node.detail.NodeDetailViewModel +import org.meshtastic.feature.node.metrics.AirQualityMetricsScreen import org.meshtastic.feature.node.metrics.DeviceMetricsScreen import org.meshtastic.feature.node.metrics.EnvironmentMetricsScreen import org.meshtastic.feature.node.metrics.HostMetricsLogScreen @@ -157,6 +160,9 @@ fun EntryProviderScope.nodeDetailGraph(backStack: NavBackStack) NodeDetailRoute.PaxMetrics::class -> addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.AirQualityMetrics::class -> + addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } + NodeDetailRoute.NeighborInfoLog::class -> addNodeDetailScreenComposable(backStack, routeInfo) { it.destNum } @@ -243,4 +249,10 @@ enum class NodeDetailScreen( Res.drawable.ic_group, { metricsVM, onNavigateUp -> PaxMetricsScreen(metricsVM, onNavigateUp) }, ), + AIR_QUALITY( + Res.string.air_quality, + NodeDetailRoute.AirQualityMetrics::class, + Res.drawable.ic_air, + { metricsVM, onNavigateUp -> AirQualityMetricsScreen(metricsVM, onNavigateUp) }, + ), } diff --git a/specs/20260601-074653-air-quality-telemetry/checklists/requirements.md b/specs/20260601-074653-air-quality-telemetry/checklists/requirements.md new file mode 100644 index 000000000..e7568af81 --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/checklists/requirements.md @@ -0,0 +1,38 @@ +# Specification Quality Checklist: Air Quality Telemetry Display + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2025-06-01 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- All items pass validation. Spec is ready for `/speckit.clarify` or `/speckit.plan`. +- Upstream design decisions from design/issues/51 and design/issues/53 incorporated directly into spec. +- CO₂ color-coded thresholds specified per Oscar's guidance. +- Chart style guidance (thin lines, dot at cursor only) captured in FR-007 and User Story 3. +- Gas resistance explicitly excluded (FR-013) per Oscar's "no much point" assessment. diff --git a/specs/20260601-074653-air-quality-telemetry/contracts/ui-contracts.md b/specs/20260601-074653-air-quality-telemetry/contracts/ui-contracts.md new file mode 100644 index 000000000..12ae66ace --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/contracts/ui-contracts.md @@ -0,0 +1,119 @@ +# UI Contracts: Air Quality Telemetry + +This feature is internal to the mobile app (no public API, library, or external service interface). The contracts below define the UI component interfaces for implementation consistency. + +## Info Card Contract + +### AirQualityInfoCards + +**Input**: `Node` with `hasAirQualityMetrics == true` + +**Output**: List of `VectorMetricInfo` items for rendering via existing `InfoCard` composable + +**Card set** (shown when value > 0): + +| Card | Label String | Value Format | Unit | Icon | +|------|-------------|--------------|------|------| +| PM1.0 | `Res.string.pm1_0` | Integer | µg/m³ | `MeshtasticIcons.AirQuality` | +| PM2.5 | `Res.string.pm2_5` | Integer | µg/m³ | `MeshtasticIcons.AirQuality` | +| PM10 | `Res.string.pm10` | Integer | µg/m³ | `MeshtasticIcons.AirQuality` | +| CO₂ | `Res.string.co2` | Integer | ppm | `MeshtasticIcons.AirQuality` | + +**CO₂ special behavior**: Value text color determined by `Co2Severity.fromPpm(value)`. + +## Log Screen Contract + +### AirQualityMetricsScreen + +**Route**: `NodeDetailRoute.AirQualityMetrics(destNum: Int)` + +**Composable signature**: +```kotlin +@Composable +fun AirQualityMetricsScreen( + nodeNum: Int, + modifier: Modifier = Modifier, +) +``` + +**Delegates to**: `BaseMetricScreen` with: +- `metricsState`: `AirQualityMetricsState` (implements existing metric state interface) +- `chartContent`: Thin-line Vico chart with `AirQuality` enum for series selection +- `historyContent`: LazyColumn of timestamped metric cards +- `exportAction`: `saveAirQualityMetricsCSV()` from `MetricsViewModel` +- `timeFrameSelector`: Reuses existing time frame filter UI +- `onRequestTelemetry`: `{ viewModel.requestTelemetry(TelemetryType.AIR_QUALITY) }` — renders a "Request" FAB/button allowing users to manually fetch fresh readings from the node + +## Request→Response→Display Contract + +### Full Loop + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ User taps "Request Air-Quality Metrics" │ +│ (node detail TelemetricActionsSection OR log screen button) │ +└─────────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ MetricsViewModel.requestTelemetry(TelemetryType.AIR_QUALITY) │ +│ → CommandSender.requestTelemetry(destNum, AIR_QUALITY) │ +└─────────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ CommandSenderImpl encodes AdminMessage with │ +│ Telemetry(air_quality_metrics = AirQualityMetrics()) │ +│ → sends via MeshProtos.ToRadio │ +└─────────────────────────┬───────────────────────────────────────┘ + ▼ (mesh radio) +┌─────────────────────────────────────────────────────────────────┐ +│ Remote node responds: Telemetry packet with populated │ +│ air_quality_metrics field │ +└─────────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ TelemetryPacketHandlerImpl.handle() │ +│ → air_quality_metrics != null branch (NEW) │ +│ → nextNode = nextNode.copy(airQualityMetrics = airQuality) │ +└─────────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ NodeManager persists → NodeEntity.air_quality_metrics (BLOB) │ +│ Node state Flow emits update │ +└─────────────────────────┬───────────────────────────────────────┘ + ▼ +┌─────────────────────────────────────────────────────────────────┐ +│ UI recomposes: │ +│ • Info cards show updated PM/CO₂ values │ +│ • Log screen appends new history entry │ +│ • Chart adds new data point │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Existing (no changes needed)**: +- `TelemetricActionsSection.kt` line 181 — button UI +- `CommandSenderImpl.kt` line 303 — request encoding +- `MetricsViewModel.requestTelemetry()` — method already handles any `TelemetryType` + +**New code required**: +- `TelemetryPacketHandlerImpl.kt` — add `air_quality_metrics` branch to `when` block +- Air Quality log screen — wire `onRequestTelemetry` callback to `BaseMetricScreen` + +## CSV Export Contract + +### Column Format + +```csv +"date","time","pm10_standard","pm25_standard","pm100_standard","pm10_environmental","pm25_environmental","pm100_environmental","particles_03um","particles_05um","particles_10um","particles_25um","particles_50um","particles_100um","co2","co2_temperature","co2_humidity","form_formaldehyde","form_humidity","form_temperature","pm40_standard","particles_40um","pm_temperature","pm_humidity","pm_voc_idx","pm_nox_idx","particles_tps" +``` + +- Date format: locale-aware via `epochSeconds` → `exportCsv` helper +- Missing/zero fields: empty string in CSV cell +- Float fields: raw numeric (no formatting applied to CSV output) + +## Navigation Contract + +### Entry Points + +1. **LogsType list** → `LogsType.AIR_QUALITY` entry visible when `node.hasAirQualityMetrics` +2. **Route** → `NodeDetailRoute.AirQualityMetrics(destNum)` registered in `NodesNavigation.kt` +3. **Back navigation** → `NavigationBackHandler` returns to node detail screen diff --git a/specs/20260601-074653-air-quality-telemetry/data-model.md b/specs/20260601-074653-air-quality-telemetry/data-model.md new file mode 100644 index 000000000..e194dc5d2 --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/data-model.md @@ -0,0 +1,142 @@ +# Data Model: Air Quality Telemetry Display + +## Entities + +### AirQualityMetrics (Proto — read-only upstream) + +Source: `core/proto/src/main/proto/meshtastic/telemetry.proto` (field 4 of `Telemetry` oneof) + +| Field | Type | Unit | Display | Description | +|-------|------|------|---------|-------------| +| `pm10_standard` | uint32 | µg/m³ | Primary | PM1.0 standard concentration | +| `pm25_standard` | uint32 | µg/m³ | Primary | PM2.5 standard concentration | +| `pm100_standard` | uint32 | µg/m³ | Primary | PM10.0 standard concentration | +| `pm10_environmental` | uint32 | µg/m³ | CSV-only | PM1.0 environmental concentration | +| `pm25_environmental` | uint32 | µg/m³ | CSV-only | PM2.5 environmental concentration | +| `pm100_environmental` | uint32 | µg/m³ | CSV-only | PM10.0 environmental concentration | +| `particles_03um` | uint32 | #/0.1L | CSV-only | 0.3µm particle count | +| `particles_05um` | uint32 | #/0.1L | CSV-only | 0.5µm particle count | +| `particles_10um` | uint32 | #/0.1L | CSV-only | 1.0µm particle count | +| `particles_25um` | uint32 | #/0.1L | CSV-only | 2.5µm particle count | +| `particles_50um` | uint32 | #/0.1L | CSV-only | 5.0µm particle count | +| `particles_100um` | uint32 | #/0.1L | CSV-only | 10.0µm particle count | +| `co2` | uint32 | ppm | Primary | CO₂ concentration (color-coded) | +| `co2_temperature` | float | °C | CSV-only | CO₂ sensor temperature | +| `co2_humidity` | float | %RH | CSV-only | CO₂ sensor relative humidity | +| `form_formaldehyde` | float | ppb | CSV-only | Formaldehyde concentration | +| `form_humidity` | float | %RH | CSV-only | Formaldehyde sensor humidity | +| `form_temperature` | float | °C | CSV-only | Formaldehyde sensor temperature | +| `pm40_standard` | uint32 | µg/m³ | CSV-only | PM4.0 standard concentration | +| `particles_40um` | uint32 | #/0.1L | CSV-only | 4.0µm particle count | +| `pm_temperature` | float | °C | CSV-only | PM sensor temperature | +| `pm_humidity` | float | %RH | CSV-only | PM sensor humidity | +| `pm_voc_idx` | float | ppb | CSV-only | VOC index | +| `pm_nox_idx` | float | ppb | CSV-only | NOx index | +| `particles_tps` | float | µm | CSV-only | Typical particle size | + +### Node (Domain Model) + +File: `core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt` + +**New fields:** + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `airQualityMetrics` | `AirQualityMetrics` | `AirQualityMetrics()` | Latest air quality readings | + +**New computed properties:** + +| Property | Type | Logic | +|----------|------|-------| +| `hasAirQualityMetrics` | `Boolean` | `airQualityMetrics != AirQualityMetrics()` | + +### NodeEntity (Database Entity) + +File: `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt` + +**New column:** + +| Column | Affinity | Type | Default | Description | +|--------|----------|------|---------|-------------| +| `air_quality_metrics` | BLOB | `Telemetry` | `Telemetry()` | Serialized Telemetry proto containing air_quality_metrics oneof | + +**New accessor property:** + +```kotlin +val airQualityMetrics: AirQualityMetrics? + get() = airQualityTelemetry.air_quality_metrics +``` + +### Database Migration + +| From | To | Type | Change | +|------|-----|------|--------| +| 38 | 39 | Auto-migration | Add nullable `air_quality_metrics` BLOB column to `node_entity` table | + +## Relationships + +``` +Telemetry Proto (oneof) + └── AirQualityMetrics (field 4) + +MeshPacket + → TelemetryPacketHandlerImpl (decode) + → Node.airQualityMetrics (in-memory state) + → NodeEntity.air_quality_metrics (persisted BLOB) + +Node + ├── hasAirQualityMetrics → drives info card visibility + └── airQualityMetrics → feeds info card values + log screen history +``` + +## Enumerations + +### Co2Severity + +New utility in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/Co2Severity.kt` + +| Level | Range (ppm) | Color Token | Label | +|-------|-------------|-------------|-------| +| GOOD | 0–1000 | M3 tertiary/green | Good | +| STUFFY | 1000–2000 | M3 secondary/yellow | Stuffy | +| POOR | 2000–5000 | Custom warning/orange | Poor | +| UNSAFE | 5000–30000 | M3 error/red | Unsafe | +| EVACUATE | 30000+ | M3 error/red + emphasis | Evacuate | + +### LogsType.AIR_QUALITY + +New enum entry in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt` + +```kotlin +AIR_QUALITY(Res.string.air_quality_metrics_log, MeshtasticIcons.AirQuality, { NodeDetailRoute.AirQualityMetrics(it) }) +``` + +### AirQuality (Chart Metric Enum) + +New enum for selectable chart metrics in the Air Quality log screen: + +| Entry | Label | Unit | Proto Field | +|-------|-------|------|-------------| +| PM1_0 | PM1.0 | µg/m³ | `pm10_standard` | +| PM2_5 | PM2.5 | µg/m³ | `pm25_standard` | +| PM10 | PM10 | µg/m³ | `pm100_standard` | +| CO2 | CO₂ | ppm | `co2` | + +## Validation Rules + +- Zero/null proto field values → field is "not reported" → hide from info cards +- CO₂ color severity only applied when `co2 > 0` +- Float fields (temperatures, VOC, NOx) pre-formatted with `NumberFormatter.format()` before display +- No upper-bound validation on sensor values (raw display per spec non-goals) + +## State Transitions + +No complex state machine. The data flow is unidirectional: + +``` +Packet received → Node updated → UI recomposes +``` + +The only "state" is presence/absence of data: +- No telemetry → no info cards shown, empty state on log screen +- Telemetry received → info cards visible, log entries populated diff --git a/specs/20260601-074653-air-quality-telemetry/plan.md b/specs/20260601-074653-air-quality-telemetry/plan.md new file mode 100644 index 000000000..d7b84e92b --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/plan.md @@ -0,0 +1,103 @@ +# Implementation Plan: Air Quality Telemetry Display + +**Branch**: `20260601-074653-air-quality-telemetry` | **Date**: 2025-06-01 | **Spec**: `specs/20260601-074653-air-quality-telemetry/spec.md` + +**Input**: Feature specification from `specs/20260601-074653-air-quality-telemetry/spec.md` + +## Summary + +Display air quality telemetry (PM1.0, PM2.5, PM10, CO₂) from the `AirQualityMetrics` proto message on node detail info cards with CO₂ severity color-coding, and provide a dedicated metrics log screen with history, thin-line charting, and CSV export. The full request→response→display loop must work end-to-end: request infrastructure already exists (button in `TelemetricActionsSection`, encoding in `CommandSenderImpl`), but the **response** path is missing — `TelemetryPacketHandlerImpl` must handle the `air_quality_metrics` oneof to store data on the Node model, triggering UI updates. The log screen includes its own "Request" action button via `BaseMetricScreen`'s `onRequestTelemetry` callback. Implementation follows the established Environment/Power metrics patterns: BLOB-persisted `Telemetry` proto in `NodeEntity`, oneof handling in `TelemetryPacketHandlerImpl`, `BaseMetricScreen` composable for the log, and metric-specific CSV export in `MetricsViewModel`. + +## Technical Context + +**Language/Version**: Kotlin 2.3+ targeting JDK 21 + +**Primary Dependencies**: Compose Multiplatform (UI), Room KMP (database), Koin 4.2+ (DI), Wire/protobuf (proto), Vico (charts), Okio (filesystem/CSV) + +**Storage**: Room KMP — new BLOB column `air_quality_metrics` on `NodeEntity` storing serialized `Telemetry` proto (same pattern as `environment_metrics` and `power_metrics` columns) + +**Testing**: `./gradlew :core:model:test :core:data:test :feature:node:test` for unit tests; Compose screenshot tests for UI verification + +**Target Platform**: Android (minSdk 24) + Compose Desktop (JVM) + +**Project Type**: Mobile app (KMP multi-target) + +**Performance Goals**: Info cards render within same frame budget as Environment cards; chart smooth with 1,000+ data points (NFR-001, NFR-002) + +**Constraints**: All business logic and UI in `commonMain`; no `java.*`/`android.*` imports in common code; read-only proto submodule + +**Scale/Scope**: 1 new database column, 1 new navigation route, ~3 new composable files, ~1 new ViewModel extension, database migration 38→39 + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +- **I. Kotlin Multiplatform Core**: ✅ All new code resides in `commonMain` source sets across `core:model`, `core:data`, `core:database`, `core:navigation`, `core:ui`, `core:resources`, and `feature:node`. No platform-specific (`androidMain`/`desktopMain`) code required — existing BLOB serialization, Room KMP, and Compose Multiplatform patterns handle all platform concerns. + +- **II. Zero Lint Tolerance**: ✅ Will run: + ``` + ./gradlew spotlessApply spotlessCheck detekt + ``` + Scoped module tests: `:core:model:test :core:data:test :core:database:test :feature:node:test` + +- **III. Compose Multiplatform UI**: ✅ All UI uses Compose Multiplatform composables (`BaseMetricScreen`, `InfoCard`, `SelectableMetricCard`). Float values pre-formatted with `NumberFormatter.format()`. Navigation via `MeshtasticNavDisplay` using serializable `NodeDetailRoute.AirQualityMetrics` route. No Jetpack-only APIs. + +- **IV. Privacy First**: ✅ Only raw sensor numerics displayed/stored. No PII, location, or crypto keys involved. Proto submodule (`core/proto`) not modified — `AirQualityMetrics` message already exists in upstream proto. + +- **V. Design Standards Compliance**: ✅ Cross-platform design specs referenced: `meshtastic/design/issues/51` and `meshtastic/design/issues/53`. Chart style (thin lines, dot only at selection) per Oscar's guidance. UI reuses existing metric card patterns already validated against design standards. + +- **VI. Verify Before Push**: ✅ Local verification: + ``` + ./gradlew spotlessApply spotlessCheck detekt :core:model:test :core:data:test :core:database:test :feature:node:test + ``` + Post-push: `gh pr checks ` or `gh run list --branch 20260601-074653-air-quality-telemetry --limit 5` + +## Project Structure + +### Documentation (this feature) + +```text +specs/20260601-074653-air-quality-telemetry/ +├── plan.md # This file +├── research.md # Phase 0 output +├── data-model.md # Phase 1 output +├── quickstart.md # Phase 1 output +├── contracts/ # Phase 1 output (internal UI contracts) +└── tasks.md # Phase 2 output (/speckit.tasks command) +``` + +### Source Code (repository root) + +```text +core/ +├── model/src/commonMain/kotlin/org/meshtastic/core/model/ +│ └── Node.kt # Add airQualityMetrics field + hasAirQualityMetrics +├── data/src/commonMain/kotlin/org/meshtastic/core/data/manager/ +│ └── TelemetryPacketHandlerImpl.kt # Handle air_quality_metrics oneof (response path) +├── database/src/commonMain/kotlin/org/meshtastic/core/database/ +│ ├── entity/NodeEntity.kt # Add air_quality_metrics BLOB column + accessor +│ └── MeshtasticDatabase.kt # Bump version 38→39 (auto-migration) +├── navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/ +│ └── Routes.kt # Add NodeDetailRoute.AirQualityMetrics +├── ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/ +│ └── Co2Severity.kt # CO₂ threshold color utility (new) +└── resources/src/commonMain/composeResources/values/ + └── strings.xml # Add air quality string resources + +feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/ +├── component/ +│ └── AirQualityMetrics.kt # Info card composable (new) +├── metrics/ +│ └── AirQualityMetrics.kt # Log screen + chart + request button (new) +├── model/ +│ └── LogsType.kt # Add AIR_QUALITY enum entry +├── detail/ +│ └── NodesNavigation.kt # Register AirQualityMetrics route +└── MetricsViewModel.kt # Add air quality CSV export + chart state + requestTelemetry(AIR_QUALITY) +``` + +**Structure Decision**: KMP multi-module mobile app structure. New code distributed across existing `core:*` and `feature:node` modules following the established Environment/Power metrics pattern. No new modules required. + +## Complexity Tracking + +> No constitution violations. All gates pass without exception. diff --git a/specs/20260601-074653-air-quality-telemetry/quickstart.md b/specs/20260601-074653-air-quality-telemetry/quickstart.md new file mode 100644 index 000000000..7e00f4be0 --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/quickstart.md @@ -0,0 +1,91 @@ +# Quickstart: Air Quality Telemetry Display + +## Prerequisites + +- Kotlin 2.3+ / JDK 21 +- Android Studio with KMP plugin or IntelliJ with Compose Multiplatform +- Project builds successfully: `./gradlew assembleDebug` +- Proto submodule initialized: `git submodule update --init` + +## Build & Verify + +```bash +# Full build +./gradlew assembleDebug + +# Lint + format +./gradlew spotlessApply spotlessCheck detekt + +# Unit tests for touched modules +./gradlew :core:model:test :core:data:test :core:database:test :feature:node:test +``` + +## Key Files to Modify + +### 1. Node Model (`core:model`) +``` +core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt +``` +Add `airQualityMetrics` field and `hasAirQualityMetrics` computed property. + +### 2. Telemetry Handler (`core:data`) +``` +core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt +``` +Add `air_quality_metrics` branch to the telemetry oneof `when` block. + +### 3. Database Entity (`core:database`) +``` +core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +``` +Add BLOB column + bump version to 39. + +### 4. Navigation Route (`core:navigation`) +``` +core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt +``` +Add `NodeDetailRoute.AirQualityMetrics(destNum: Int)`. + +### 5. Info Cards (`feature:node`) +``` +feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt +``` +New composable building `VectorMetricInfo` list from `AirQualityMetrics` proto. + +### 6. Log Screen (`feature:node`) +``` +feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt +``` +New composable using `BaseMetricScreen` with chart + history + export. + +### 7. LogsType Enum +``` +feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt +``` +Add `AIR_QUALITY` entry. + +### 8. String Resources +``` +core/resources/src/commonMain/composeResources/values/strings.xml +``` +Add air quality labels, then run `python3 scripts/sort-strings.py`. + +## Testing Approach + +1. **Unit test** the CO₂ severity threshold mapping +2. **Unit test** the info card list builder (given metrics, expect correct card output) +3. **Unit test** CSV export column generation +4. **Integration test** telemetry packet handler correctly updates Node state +5. **Screenshot test** (if applicable) info cards and log screen composables + +## Patterns to Follow + +| Pattern | Reference File | +|---------|---------------| +| Info cards | `feature/node/src/commonMain/.../component/EnvironmentMetrics.kt` | +| Log screen | `feature/node/src/commonMain/.../metrics/EnvironmentMetrics.kt` | +| CSV export | `MetricsViewModel.kt` → `saveEnvironmentMetricsCSV()` | +| Route registration | `NodesNavigation.kt` → `NodeDetailRoute.EnvironmentMetrics::class` | +| Database column | `NodeEntity.kt` → `environment_metrics` BLOB column | +| Icon usage | `core/ui/.../icon/Telemetry.kt` → `MeshtasticIcons.AirQuality` | diff --git a/specs/20260601-074653-air-quality-telemetry/research.md b/specs/20260601-074653-air-quality-telemetry/research.md new file mode 100644 index 000000000..85dc0beb7 --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/research.md @@ -0,0 +1,133 @@ +# Research: Air Quality Telemetry Display + +## R1: Telemetry Packet Handling Pattern + +**Decision**: Add `air_quality_metrics` oneof handling to `TelemetryPacketHandlerImpl` following the exact pattern used for `environment_metrics` and `power_metrics`. + +**Rationale**: The handler at `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt` already pattern-matches on the Telemetry oneof variants. The `air_quality_metrics` variant (field 4 in the Telemetry proto) is not yet handled — it simply falls through. Adding a branch that copies the metrics to the Node model is trivial and consistent. + +**Alternatives considered**: +- Separate handler class → rejected: adds indirection for a single oneof branch; other metrics don't do this. + +## R2: Database Storage Strategy + +**Decision**: Add a new BLOB column `air_quality_metrics` (type `Telemetry`) to `NodeEntity`, auto-migrating from version 38 to 39. + +**Rationale**: Environment (`environment_metrics`) and Power (`power_metrics`) use the same pattern — store the full `Telemetry` proto as a binary BLOB. Room KMP auto-migration handles new nullable columns cleanly (existing rows get null/default). The `NodeEntity` accessor property unwraps the oneof for type-safe access. + +**Alternatives considered**: +- Individual columns per metric field → rejected: 25 fields in `AirQualityMetrics` makes this unwieldy; BLOB serialization is proven. +- Shared column with environment → rejected: different telemetry type, different update cadence, violates existing separation pattern. + +## R3: Node Model Extension + +**Decision**: Add `airQualityMetrics: AirQualityMetrics = AirQualityMetrics()` field and `hasAirQualityMetrics` boolean accessor to the `Node` data class. + +**Rationale**: Mirrors `environmentMetrics`/`hasEnvironmentMetrics` pattern exactly. The `has*` accessor compares against the default empty instance to determine if data is present. + +**Alternatives considered**: None — this is the established pattern. + +## R4: CO₂ Severity Color Thresholds + +**Decision**: Create a `Co2Severity` enum/utility in `core:ui` that maps CO₂ ppm to M3-compatible color tokens: +- Good: 400–1000 ppm → `Color.Green` / M3 tertiary +- Stuffy: 1000–2000 ppm → `Color.Yellow` / M3 secondary +- Poor: 2000–5000 ppm → `Color.Orange` / custom warning token +- Unsafe: 5000+ ppm → `Color.Red` / M3 error +- Evacuate: 30000+ ppm → `Color.Red` + bold / M3 error with emphasis + +**Rationale**: Per design/issues/53 (Oscar's recommendation). Using M3-compatible color tokens ensures theme consistency across light/dark modes. Existing `IndoorAirQuality.kt` in `core/ui/component/` provides a precedent for threshold-based coloring (IAQ severity levels). + +**Alternatives considered**: +- Hardcoded hex colors → rejected: breaks M3 theming and dark mode. +- Reuse IAQ severity → rejected: different scale (IAQ 0-500 vs CO₂ 400-40000 ppm), different semantics. + +## R5: Chart Rendering Style + +**Decision**: Use thin-line charts via Vico library with dot marker visible only at the selected/cursor position. No persistent markers on data points. + +**Rationale**: Per design/issues/53 recommendation. The existing chart infrastructure in `BaseMetricScreen` already uses Vico for line charts. The thin-line-only style may differ from current Environment charts (which may show dots) — this feature follows the updated design guidance. + +**Alternatives considered**: +- Thick lines with dots at every point → rejected: explicitly against design guidance ("avoid clutter"). +- Bar charts for PM data → rejected: line charts show temporal trends better for continuous monitoring. + +## R6: Navigation Integration + +**Decision**: Add `NodeDetailRoute.AirQualityMetrics(destNum: Int)` as a new serializable data class in the `NodeDetailRoute` sealed interface. Register in `NodesNavigation.kt` via `addNodeDetailScreenComposable`. + +**Rationale**: Exact pattern used by all other metric routes (Device, Environment, Power, Signal, etc.). The `LogsType.AIR_QUALITY` enum entry drives the navigation. + +**Alternatives considered**: None — established pattern is clear. + +## R7: CSV Export Column Set + +**Decision**: Export ALL proto fields as CSV columns, including secondary fields (particle counts, VOC, NOx, formaldehyde, co-read temp/humidity). Headers: +``` +"date","time","pm10_standard","pm25_standard","pm100_standard","pm10_environmental","pm25_environmental","pm100_environmental","particles_03um","particles_05um","particles_10um","particles_25um","particles_50um","particles_100um","co2","co2_temperature","co2_humidity","form_formaldehyde","form_humidity","form_temperature","pm40_standard","particles_40um","pm_temperature","pm_humidity","pm_voc_idx","pm_nox_idx","particles_tps" +``` + +**Rationale**: FR-008 requires all available proto fields. External analysis tools benefit from complete data. Empty/zero fields exported as empty cells per spec edge cases. + +**Alternatives considered**: +- Only export displayed (primary) fields → rejected: spec explicitly requires all proto fields for external analysis use case. + +## R8: Info Card Display Logic + +**Decision**: Display info cards for PM1.0 (standard), PM2.5 (standard), PM10 (standard), and CO₂ when non-zero. Use `VectorMetricInfo` pattern from `EnvironmentMetrics.kt` component. Hide cards for zero/null values per existing convention. + +**Rationale**: FR-003 specifies standard concentrations as primary display metrics. The existing `EnvironmentMetrics.kt` component pattern (build info cards list, filter nulls/NaN/zero) is well-established. + +**Alternatives considered**: +- Show all 25 fields on info cards → rejected: overwhelming; design guidance says PM+CO₂ are primary. +- Show environmental concentrations instead of standard → rejected: spec explicitly calls for standard concentrations. + +## R9: Icon Selection + +**Decision**: Use existing `MeshtasticIcons.AirQuality` (maps to `ic_air` drawable) for the info card and log type entry. + +**Rationale**: Icon already exists in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/icon/Telemetry.kt` — no new vector asset needed. + +**Alternatives considered**: None — purpose-built icon already available. + +## R10: Telemetry Request Button + +**Decision**: No new work needed for the request button on the **node detail screen** — `TelemetricActionsSection` already includes it (line 179/181) and `CommandSenderImpl` already encodes the request (line 303). However, the **response path** is entirely missing — `TelemetryPacketHandlerImpl` does not yet handle the `air_quality_metrics` oneof, so responses are silently dropped. + +**Rationale**: Verified in codebase: +- Request UI: `TelemetricActionsSection.kt` line 181 → `NodeMenuAction.RequestTelemetry(it, TelemetryType.AIR_QUALITY)` +- Request encoding: `CommandSenderImpl.kt` line 303 → constructs `Telemetry(air_quality_metrics = AirQualityMetrics())` +- Response handling: `TelemetryPacketHandlerImpl.kt` — the `when` block only handles `device_metrics`, `environment_metrics`, and `power_metrics`; `air_quality_metrics` falls through unhandled + +The critical gap is in the handler. Without R1 (adding the oneof branch), the request button does nothing visible. + +**Alternatives considered**: N/A — infrastructure exists, just needs the response path wired up. + +## R11: Log Screen Request Action Button + +**Decision**: The Air Quality log screen must include a "Request" action button via `BaseMetricScreen`'s `onRequestTelemetry` callback, calling `viewModel.requestTelemetry(TelemetryType.AIR_QUALITY)`. + +**Rationale**: Environment metrics log screen already does this (line 96 of `EnvironmentMetrics.kt`): +```kotlin +onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.ENVIRONMENT) }, +``` +The `MetricsViewModel.requestTelemetry()` method already exists and supports `TelemetryType.AIR_QUALITY` — it delegates to `CommandSender`. The only work is wiring the callback in the new Air Quality log screen composable. + +**Alternatives considered**: +- Omit request button from log screen → rejected: inconsistent with Environment/Power patterns, and users may want to refresh data while reviewing history. + +## R12: End-to-End Request→Response→Display Flow + +**Decision**: Document and verify the complete loop: + +1. **User taps "Request Air-Quality Metrics"** (node detail OR log screen) +2. **`MetricsViewModel.requestTelemetry(TelemetryType.AIR_QUALITY)`** → delegates to `CommandSender` +3. **`CommandSenderImpl`** constructs `AdminMessage` with `Telemetry(air_quality_metrics = AirQualityMetrics())` and sends via mesh +4. **Node responds** with a `Telemetry` packet containing populated `air_quality_metrics` +5. **`TelemetryPacketHandlerImpl`** decodes the response, matches `air_quality_metrics != null`, calls `nextNode = nextNode.copy(airQualityMetrics = airQuality)` **(NEW CODE)** +6. **`NodeManager`** persists updated Node → `NodeEntity.air_quality_metrics` BLOB column **(NEW COLUMN)** +7. **UI recomposes** — info cards and log screen observe Node state via Flow + +**Rationale**: This is the exact same flow used by Environment metrics. The only missing pieces are step 5 (handler branch) and step 6 (database column) — both addressed by R1 and R2. + +**Alternatives considered**: None — this is the established unidirectional data flow. diff --git a/specs/20260601-074653-air-quality-telemetry/spec.md b/specs/20260601-074653-air-quality-telemetry/spec.md new file mode 100644 index 000000000..999cc7234 --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/spec.md @@ -0,0 +1,233 @@ +# Feature Specification: Air Quality Telemetry Display + +**Feature Branch**: `20260601-074653-air-quality-telemetry` +**Created**: 2025-06-01 +**Status**: Draft +**Input**: User description: "Display raw air quality / particulate sensor data from the AirQualityMetrics proto message on node detail info cards and in a dedicated metrics log screen with history, graphing, and CSV export — matching the existing patterns for Environment and Power metrics." +**Cross-Platform Spec**: https://github.com/meshtastic/design/issues/51, https://github.com/meshtastic/design/issues/53 + +## Summary + +Add support for displaying air quality and particulate sensor telemetry data received from nodes equipped with air quality sensors (SEN5X, PMSA003I, SCD30, SCD4X). The feature focuses on the primary displayable metrics — PM1.0, PM2.5, PM10 (standard concentrations) and CO₂ — with CO₂ presented using color-coded severity thresholds per upstream design guidance. Secondary fields (particle counts, VOC index, NOx index, formaldehyde) are stored and exportable but given less visual prominence. The feature provides node detail info cards for at-a-glance status and a dedicated metrics log screen with timestamped history, thin-line charting, and CSV export, following the established patterns used by Environment and Power metrics. + +### Upstream Design Decisions (design/issues/51 + design/issues/53) + +Per Oscar (@oscgonfer) from the Meshtastic design team: + +- **PM data** (PM1.0, PM2.5, PM10) — useful as raw µg/m³ values. Primary display metrics. +- **CO₂** — useful as raw ppm. Display with color-coded thresholds: + - Good: 400–1000 ppm + - Stuffy: 1000–2000 ppm + - Poor: 2000–5000 ppm + - Unsafe (8h work): 5000+ ppm + - Evacuate: 30000–40000+ ppm +- **Gas resistance (Ohms)** — low value as raw display; only useful after IAQ processing. Not included in air quality display (IAQ already shown in Environment metrics). +- **Chart style** — thin lines only; dot marker shown only at the selected/cursor position to avoid clutter. +- **Telemetry category** — Air Quality is distinct from Environment/Weather. PM and chemical pollutants are its domain. + +## Goals + +1. Display the most recent air quality readings on the node detail info card so users can quickly assess current conditions without navigating away +2. Provide a dedicated Air Quality metrics log screen with historical readings, selectable line charts, and time frame filtering for trend analysis +3. Enable CSV export of air quality data for external analysis and reporting +4. Persist air quality telemetry to the local database so readings survive app restarts and are available for historical review +5. Integrate seamlessly into existing telemetry navigation and UI patterns so users experience consistent behavior across all metric types + +## Non-Goals + +- Calculating or displaying derived air quality indices (AQI) — only raw sensor values are shown (with CO₂ threshold coloring as the one exception per design guidance) +- Displaying raw gas resistance — this is handled by the existing IAQ display in Environment metrics +- Configuring air quality sensor hardware settings from the app +- Setting alert thresholds or push notifications for unhealthy readings +- Aggregating air quality data from multiple nodes into a combined view +- Displaying air quality data on the map layer +- Modifying the proto definitions (upstream read-only) +- Processing or displaying data best sent via MQTT for external analysis (per design/issues/51) + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - View Current Air Quality Readings (Priority: P1) + +A user with an air quality sensor-equipped node wants to see the latest PM2.5, PM10, and CO₂ values at a glance on the node detail screen without extra navigation. + +**Why this priority**: This is the most common interaction — users check current conditions frequently and need immediate visibility of key readings. + +**Independent Test**: Can be fully tested by receiving a single air quality telemetry packet and verifying the info cards render correct values on the node detail screen. + +**Acceptance Scenarios**: + +1. **Given** a node has received air quality telemetry, **When** the user views the node detail screen, **Then** info cards display the latest PM1.0, PM2.5, PM10 (standard concentrations) and CO₂ with appropriate labels and units (µg/m³ for PM, ppm for CO₂) +2. **Given** a node has received air quality telemetry with CO₂ data, **When** the CO₂ info card is displayed, **Then** the value is color-coded according to severity thresholds (Good ≤1000, Stuffy 1000–2000, Poor 2000–5000, Unsafe 5000+, Evacuate 30000+) +3. **Given** a node has never received air quality telemetry, **When** the user views the node detail screen, **Then** no air quality info cards are shown +4. **Given** a node receives updated air quality telemetry while the detail screen is open, **When** the new packet arrives, **Then** the info cards update to reflect the latest values + +--- + +### User Story 2 - Browse Air Quality History (Priority: P2) + +A user wants to review historical air quality readings to understand how conditions changed throughout the day — for example, checking if PM2.5 spiked after a nearby event. + +**Why this priority**: Historical context transforms raw numbers into actionable insight; this is the primary reason users track metrics over time. + +**Independent Test**: Can be fully tested by populating telemetry history and verifying the log screen shows timestamped cards in chronological order with correct values. + +**Acceptance Scenarios**: + +1. **Given** multiple air quality telemetry readings exist for a node, **When** the user navigates to the Air Quality metrics log, **Then** timestamped history cards are displayed in reverse-chronological order showing key metric values +2. **Given** the user selects a time frame filter, **When** the filter is applied, **Then** only readings within the selected time frame are displayed +3. **Given** no air quality telemetry exists for a node, **When** the user navigates to the Air Quality metrics log, **Then** an appropriate empty state is shown + +--- + +### User Story 3 - Graph Air Quality Trends (Priority: P2) + +A user wants to visually identify trends and correlations in air quality data by viewing line charts of selected metrics over time. + +**Why this priority**: Graphing enables pattern recognition (e.g., daily PM cycles) that raw numbers alone cannot convey. + +**Independent Test**: Can be fully tested by populating telemetry history and verifying chart renders with correct data points and legend entries. + +**Acceptance Scenarios**: + +1. **Given** air quality history exists, **When** the user views the chart on the Air Quality metrics log, **Then** thin line charts (no large dot markers) plot the selected metrics over time with a legend identifying each series +2. **Given** the user taps a point on the chart, **When** the selection is made, **Then** a single dot marker appears at the selected position and the corresponding history card is highlighted/scrolled to +3. **Given** some metric values are zero or absent for certain readings, **When** the chart renders, **Then** those data points are omitted gracefully without breaking the chart line + +--- + +### User Story 4 - Export Air Quality Data (Priority: P3) + +A user wants to export air quality readings to CSV for external analysis, regulatory reporting, or sharing with environmental agencies. + +**Why this priority**: Export enables integration with external tools but is a secondary workflow compared to in-app viewing. + +**Independent Test**: Can be fully tested by triggering CSV export and verifying the file contains correct headers and values matching the displayed history. + +**Acceptance Scenarios**: + +1. **Given** air quality history exists, **When** the user taps the export action, **Then** a CSV file is generated containing all displayed readings with appropriate column headers +2. **Given** a time frame filter is active, **When** the user exports, **Then** only the filtered readings are included in the CSV +3. **Given** some readings have partial data (e.g., only PM values, no CO₂), **When** CSV is exported, **Then** missing values are represented as empty cells + +--- + +### User Story 5 - Navigate to Air Quality Metrics Log (Priority: P1) + +A user sees air quality info cards on the node detail screen and wants to drill into the full history and charts. + +**Why this priority**: Navigation is foundational — without it, the log screen is inaccessible. + +**Independent Test**: Can be fully tested by verifying the Air Quality entry appears in the logs list and navigation leads to the correct screen. + +**Acceptance Scenarios**: + +1. **Given** a node has air quality telemetry, **When** the user views available metric logs for the node, **Then** an "Air Quality" option is listed with appropriate icon +2. **Given** the user selects the Air Quality log entry, **When** navigation occurs, **Then** the Air Quality metrics log screen opens for that node + +--- + +### Edge Cases + +- What happens when only a subset of air quality fields are populated (e.g., PM-only sensor with no CO₂)? Only populated fields are displayed; empty/zero fields are hidden. +- What happens when a sensor reports unrealistic values (e.g., PM2.5 = 0 from a fresh boot)? Zero values are displayed as-is since the app shows raw sensor data without validation. +- What happens when the device receives air quality telemetry from a very old firmware version that has fewer fields? Newer fields default to zero/absent per proto semantics and are simply not displayed. +- What happens when thousands of air quality readings accumulate? The same pagination/scrolling approach used by other metric logs applies (LazyColumn with efficient composable reuse). + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: System MUST store the latest AirQualityMetrics on the Node model when received via telemetry +- **FR-002**: System MUST persist air quality telemetry to the database so data survives app restarts +- **FR-003**: System MUST display info cards for PM1.0, PM2.5, PM10 (standard concentrations) and CO₂ on the node detail screen when non-zero values are present +- **FR-004**: System MUST color-code the CO₂ display value using severity thresholds: Good (0–1000 ppm), Stuffy (1000–2000 ppm), Poor (2000–5000 ppm), Unsafe (5000–30000 ppm), Evacuate (30000+ ppm). Values below outdoor ambient (~420 ppm) are still categorized as Good. +- **FR-005**: System MUST provide a dedicated Air Quality metrics log screen accessible from the node detail logs list +- **FR-006**: System MUST display timestamped history cards on the Air Quality log screen showing PM and CO₂ values +- **FR-007**: System MUST render thin-line charts (dot marker only at selection point) for air quality metrics over time +- **FR-008**: System MUST support CSV export of displayed air quality readings with all available proto fields as columns (including secondary fields: particle counts, VOC index, NOx index, formaldehyde, co-read temperature/humidity) +- **FR-009**: System MUST support time frame filtering on the Air Quality log screen +- **FR-010**: System MUST handle partial data gracefully — only display fields that have meaningful non-zero values +- **FR-011**: System MUST handle the telemetry packet for `Telemetry.air_quality_metrics` oneof variant in the packet handler +- **FR-012**: System MUST include a database migration adding the air quality metrics column +- **FR-013**: System MUST NOT display raw gas_resistance in the Air Quality screen (IAQ is already shown in Environment metrics) + +### Non-Functional Requirements + +- **NFR-001**: Air quality info cards render within the same frame budget as existing Environment info cards (no perceptible additional lag) +- **NFR-002**: Chart rendering with 1,000+ data points remains smooth and scrollable +- **NFR-003**: All new UI composables and business logic reside in the `commonMain` source set for cross-platform compatibility + +## Architecture + +### Key Components + +| Component | Module / File | Purpose | +|-----------|---------------|---------| +| Node model | `core/model/` | Store `airQualityMetrics` field and `hasAirQualityMetrics` accessor | +| TelemetryPacketHandlerImpl | `core/data/` | Handle `air_quality_metrics` oneof variant | +| NodeEntity | `core/database/` | Persist air quality telemetry as BLOB column | +| Database Migration | `core/database/` | Add `air_quality_metrics` column to NodeEntity | +| AirQualityMetrics info cards | `feature/node/component/` | Display current readings on node detail | +| AirQualityMetrics log screen | `feature/node/metrics/` | History, chart, CSV export | +| LogsType.AIR_QUALITY | `feature/node/model/` | Enum entry for navigation | +| NodeDetailRoute.AirQualityMetrics | `core/navigation/` | Route definition | +| MetricsViewModel extensions | `feature/node/` | Air quality graphing data and CSV export logic | + +### Data Flow + +``` +MeshPacket (air_quality_metrics) + → TelemetryPacketHandlerImpl (decode + update Node) + → NodeManager (persist to NodeEntity via database) + → UI observes Node state + → Info Cards (node detail screen) + → Metrics Log Screen (history + chart + export) +``` + +## Source-Set Impact + +| Source Set | Impact | Justification | +|-----------|--------|---------------| +| `commonMain` | New composables, model updates, packet handler logic, navigation route, database migration | All business logic and UI per Constitution I, III | +| `androidMain` | None | No platform-specific code needed | +| `jvmMain` | None | No platform-specific code needed | + +## Design Standards Compliance + +- [ ] New screens reviewed against [design standards](https://raw.githubusercontent.com/meshtastic/design/refs/heads/master/standards/meshtastic_design_standards_latest.md) +- [ ] M3 component selection verified — uses existing `InfoCard`, `SelectableMetricCard`, `BaseMetricScreen` composables +- [ ] Accessibility: TalkBack semantics on info cards, adequate touch targets, units included in content descriptions +- [ ] Typography: Consistent with existing metric cards (`labelSmall` for labels, `labelLarge` for values) + +## Privacy Assessment + +- [ ] No PII, location data, or cryptographic keys logged or exposed — only raw sensor numerics +- [ ] No new network calls that transmit user data — reads from existing mesh telemetry only +- [ ] Proto submodule (`core/proto`) not modified (read-only upstream) + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Users can view current air quality readings on the node detail screen within 1 second of receiving telemetry +- **SC-002**: Users can access and browse full air quality history for any node with fewer than 3 taps from the node detail screen +- **SC-003**: Exported CSV files contain all air quality fields with correct headers and are importable by standard spreadsheet applications +- **SC-004**: Air quality metrics persist across app restarts with zero data loss +- **SC-005**: The Air Quality log screen supports the same time frame filters and chart interactions available on the Environment metrics log screen + +## Assumptions + +- All business logic and UI composables reside in `commonMain` source set +- String resources added to `core/resources/src/commonMain/composeResources/values/strings.xml` +- Icons use `MeshtasticIcons` (from `core/ui/icon/`) — a new air quality icon vector may be needed +- Float values pre-formatted with `NumberFormatter.format()` (CMP constraint) +- The `AirQualityMetrics` proto message is already available in the proto submodule and need not be modified +- The existing `BaseMetricScreen` composable framework is reused for the log screen (chart + list + export pattern) +- The telemetry request button for AIR_QUALITY already exists in `TelemetricActionsSection` and `CommandSenderImpl` +- Database migration follows the sequential numbering pattern established by prior migrations +- Zero-value fields from proto deserialization are treated as "not reported" and hidden from display (consistent with Environment metrics behavior) +- Primary display metrics: PM1.0, PM2.5, PM10 (standard concentrations in µg/m³) and CO₂ (ppm) +- Secondary metrics stored and exported but not prominently displayed: particle counts (particles/0.1L), VOC index (ppb), NOx index (ppb), formaldehyde, co-read temperature/humidity +- CO₂ threshold colors follow Oscar's guidance from design/issues/53 and use M3-compatible color tokens +- Chart rendering uses thin lines per design/issues/53 recommendation to avoid clutter; existing Environment metrics charts may use a different dot style — this feature follows the updated guidance +- Gas resistance is intentionally excluded from this feature; it is already surfaced as IAQ in the Environment metrics display diff --git a/specs/20260601-074653-air-quality-telemetry/tasks.md b/specs/20260601-074653-air-quality-telemetry/tasks.md new file mode 100644 index 000000000..fab153cb2 --- /dev/null +++ b/specs/20260601-074653-air-quality-telemetry/tasks.md @@ -0,0 +1,226 @@ +# Tasks: Air Quality Telemetry Display + +**Input**: Design documents from `/specs/20260601-074653-air-quality-telemetry/` + +**Prerequisites**: plan.md ✅, spec.md ✅, research.md ✅, data-model.md ✅, contracts/ ✅ + +**Tests**: Not explicitly requested in spec. Test tasks omitted per convention. + +**Verification**: Constitution-required validation (spotlessCheck, detekt, module tests) included in final phase. + +## Format: `[ID] [P?] [Story] Description` + +- **[P]**: Can run in parallel (different files, no dependencies) +- **[Story]**: Which user story this task belongs to (e.g., US1, US2, US3) +- Include exact file paths in descriptions + +--- + +## Phase 1: Setup (Shared Infrastructure) + +**Purpose**: String resources and shared utilities needed by all subsequent phases + +- [X] T001 Add air quality string resources (pm1_0, pm2_5, pm10, co2, air_quality_metrics_log, units) in `core/resources/src/commonMain/composeResources/values/strings.xml` then run `python3 scripts/sort-strings.py` +- [X] T002 [P] Create `Co2Severity` enum and `fromPpm()` color utility in `core/ui/src/commonMain/kotlin/org/meshtastic/core/ui/component/Co2Severity.kt` mapping thresholds: Good 0–1000, Stuffy 1000–2000, Poor 2000–5000, Unsafe 5000–30000, Evacuate 30000+ to M3-compatible color tokens + +--- + +## Phase 2: Foundational (Blocking Prerequisites) + +**Purpose**: Data layer changes that MUST be complete before ANY user story UI can function + +**⚠️ CRITICAL**: No user story work can begin until this phase is complete — without these changes, air quality telemetry packets are silently dropped and no data persists. + +- [X] T003 Add `airQualityMetrics: AirQualityMetrics = AirQualityMetrics()` field and `hasAirQualityMetrics` computed property to `Node` data class in `core/model/src/commonMain/kotlin/org/meshtastic/core/model/Node.kt` +- [X] T004 Add `air_quality_metrics` BLOB column (type `Telemetry`, default `Telemetry()`) to `NodeEntity` in `core/database/src/commonMain/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt` with `airQualityMetrics` accessor property +- [X] T005 Bump database version 38→39 with auto-migration adding nullable `air_quality_metrics` column in `core/database/src/commonMain/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt` +- [X] T006 Handle `air_quality_metrics` oneof variant in `TelemetryPacketHandlerImpl` — add branch to `when` block that copies metrics to Node model via `nextNode.copy(airQualityMetrics = airQuality)` in `core/data/src/commonMain/kotlin/org/meshtastic/core/data/manager/TelemetryPacketHandlerImpl.kt` + +**Checkpoint**: Foundation ready — telemetry packets are now decoded, stored in-memory, and persisted to database. UI phases can begin. + +--- + +## Phase 3: User Story 1 — View Current Air Quality Readings (Priority: P1) 🎯 MVP + +**Goal**: Display latest PM1.0, PM2.5, PM10, and CO₂ values on the node detail info cards with CO₂ color-coded by severity thresholds. + +**Independent Test**: Receive a single air quality telemetry packet and verify info cards render correct values with appropriate units and CO₂ coloring on the node detail screen. + +### Implementation for User Story 1 + +- [X] T007 [US1] Create `AirQualityInfoCards` composable in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt` — build `VectorMetricInfo` list for PM1.0, PM2.5, PM10 (µg/m³) and CO₂ (ppm) from `Node.airQualityMetrics`, filtering zero values, using `MeshtasticIcons.AirQuality` icon +- [X] T008 [US1] Apply `Co2Severity.fromPpm()` color to the CO₂ info card value text in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/component/AirQualityMetrics.kt` +- [X] T009 [US1] Integrate `AirQualityInfoCards` into the node detail screen — render cards when `node.hasAirQualityMetrics` is true, positioned after existing Environment/Power info cards + +**Checkpoint**: User Story 1 complete — users see at-a-glance air quality readings on the node detail screen with CO₂ severity coloring. Cards hide when no data is present and update live when new packets arrive. + +--- + +## Phase 4: User Story 5 — Navigate to Air Quality Metrics Log (Priority: P1) + +**Goal**: Provide discoverable navigation from the node detail screen to the Air Quality metrics log. + +**Independent Test**: Verify "Air Quality" entry appears in the logs list with correct icon, and tapping it navigates to the Air Quality log screen. + +### Implementation for User Story 5 + +- [X] T010 [P] [US5] Add `NodeDetailRoute.AirQualityMetrics(destNum: Int)` serializable data class to the `NodeDetailRoute` sealed interface in `core/navigation/src/commonMain/kotlin/org/meshtastic/core/navigation/Routes.kt` +- [X] T011 [P] [US5] Add `AIR_QUALITY` enum entry to `LogsType` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/model/LogsType.kt` — use `Res.string.air_quality_metrics_log`, `MeshtasticIcons.AirQuality` icon, and `NodeDetailRoute.AirQualityMetrics(it)` factory +- [X] T012 [US5] Register `NodeDetailRoute.AirQualityMetrics` route in `NodesNavigation.kt` via `addNodeDetailScreenComposable` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/detail/NodesNavigation.kt` + +**Checkpoint**: User Story 5 complete — Air Quality appears in the logs list and navigates to the metrics log screen (screen content implemented in next phases). + +--- + +## Phase 5: User Story 2 — Browse Air Quality History (Priority: P2) + +**Goal**: Display timestamped history cards on the Air Quality log screen with reverse-chronological readings and time frame filtering. + +**Independent Test**: Populate telemetry history and verify the log screen shows timestamped cards in correct order with proper values and time frame filter works. + +### Implementation for User Story 2 + +- [X] T013 [US2] Create `AirQualityMetricsScreen` composable in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt` — delegate to `BaseMetricScreen` with history content, time frame selector, and `onRequestTelemetry = { viewModel.requestTelemetry(TelemetryType.AIR_QUALITY) }` callback +- [X] T014 [US2] Implement air quality metrics state class (`AirQualityMetricsState`) providing timestamped history cards from `NodeEntity.air_quality_metrics` BLOB list, showing PM1.0, PM2.5, PM10, CO₂ values with CO₂ severity color in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt` +- [X] T015 [US2] Add air quality telemetry list query/accessor to `MetricsViewModel` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/MetricsViewModel.kt` — load historical telemetry entries for the node, support time frame filtering + +**Checkpoint**: User Story 2 complete — users can browse timestamped air quality history with time frame filtering on the dedicated log screen. + +--- + +## Phase 6: User Story 3 — Graph Air Quality Trends (Priority: P2) + +**Goal**: Render thin-line Vico charts for selectable air quality metrics (PM1.0, PM2.5, PM10, CO₂) with dot marker only at the selected position. + +**Independent Test**: Populate telemetry history and verify chart renders with correct data points, thin lines, legend entries, and tap-to-select behavior. + +### Implementation for User Story 3 + +- [X] T016 [P] [US3] Create `AirQuality` chart metric enum (PM1_0, PM2_5, PM10, CO2) with label, unit, and proto field mapping in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt` +- [X] T017 [US3] Implement chart content section in `AirQualityMetricsScreen` using Vico thin-line chart with selectable metric series, dot marker only at cursor position, and graceful handling of zero/absent data points in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt` +- [X] T018 [US3] Wire chart selection to history list — when user taps a chart point, highlight/scroll to corresponding history card in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt` + +**Checkpoint**: User Story 3 complete — users can visualize air quality trends with interactive thin-line charts and chart-to-list synchronization. + +--- + +## Phase 7: User Story 4 — Export Air Quality Data (Priority: P3) + +**Goal**: Enable CSV export of all air quality proto fields (27 columns) with time frame filtering applied to exported data. + +**Independent Test**: Trigger CSV export and verify file contains correct headers (date, time, all proto fields) and values matching displayed history, with missing values as empty cells. + +### Implementation for User Story 4 + +- [X] T019 [US4] Implement `saveAirQualityMetricsCSV()` in `MetricsViewModel` in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/MetricsViewModel.kt` — generate CSV with all 27 proto field columns per contracts/ui-contracts.md, respecting active time frame filter, empty cells for zero/missing values +- [X] T020 [US4] Wire export action into `AirQualityMetricsScreen`'s `BaseMetricScreen` `exportAction` parameter in `feature/node/src/commonMain/kotlin/org/meshtastic/feature/node/metrics/AirQualityMetrics.kt` + +**Checkpoint**: User Story 4 complete — users can export filtered air quality data to CSV for external analysis. + +--- + +## Phase 8: Polish & Cross-Cutting Concerns + +**Purpose**: Verification, consistency checks, and constitution compliance + +- [X] T021 [P] Review `AirQualityInfoCards` and `AirQualityMetricsScreen` against Meshtastic design standards — verify M3 component usage, typography (labelSmall/labelLarge), TalkBack semantics, touch targets, and units in content descriptions +- [X] T022 [P] Confirm no logs, telemetry, or config changes expose PII, location data, secrets, or modify `core/proto` — verify only raw sensor numerics are stored/displayed +- [ ] T023 [P] Run constitution-required verification: `./gradlew spotlessApply spotlessCheck detekt :core:model:test :core:data:test :core:database:test :feature:node:test` +- [ ] T024 Validate end-to-end request→response→display loop works: tap request button on node detail and log screen, verify telemetry packet is handled, Node state updates, info cards refresh, log screen appends entry +- [X] T025 [P] Update `docs/en/user/telemetry-and-sensors.md` to document the Air Quality metrics log screen, info cards, CO₂ severity color-coding, chart usage, and CSV export. Update `last_updated` frontmatter. Verify DocBundleLoader registration if a new page is created. + +--- + +## Dependencies & Execution Order + +### Phase Dependencies + +- **Setup (Phase 1)**: No dependencies — can start immediately +- **Foundational (Phase 2)**: Depends on T001 (strings) for label references — BLOCKS all user stories +- **User Story 1 (Phase 3)**: Depends on Phase 2 completion (T003–T006 provide data flow) +- **User Story 5 (Phase 4)**: Depends on Phase 2 (needs Node model) — can run in parallel with Phase 3 +- **User Story 2 (Phase 5)**: Depends on Phase 4 (needs route registered for navigation) +- **User Story 3 (Phase 6)**: Depends on Phase 5 (builds on log screen composable) +- **User Story 4 (Phase 7)**: Depends on Phase 5 (exports from same ViewModel data) +- **Polish (Phase 8)**: Depends on all user story phases complete + +### User Story Dependencies + +- **US1 (P1)**: Phase 2 only — fully independent of other stories +- **US5 (P1)**: Phase 2 only — fully independent of other stories, can parallel with US1 +- **US2 (P2)**: Requires US5 (needs route/navigation to exist) +- **US3 (P2)**: Requires US2 (builds chart into log screen created in US2) +- **US4 (P3)**: Requires US2 (exports data from ViewModel state created in US2) + +### Parallel Opportunities + +- **Phase 1**: T001 and T002 can run in parallel (different files) +- **Phase 2**: T003 and T004 can run in parallel (different modules); T005 depends on T004; T006 depends on T003 +- **Phase 3+4**: US1 (T007–T009) and US5 (T010–T012) can run in parallel after Phase 2 +- **Phase 5+7**: US3 (T016) enum creation can parallel with US2 history implementation +- **Phase 8**: T021, T022, T023 all run in parallel (independent checks) + +--- + +## Parallel Example: Phase 2 (Foundation) + +```bash +# Launch independent model + entity changes together: +Task T003: "Add airQualityMetrics field to Node model" +Task T004: "Add air_quality_metrics BLOB column to NodeEntity" + +# Then sequential (depends on T004): +Task T005: "Bump database version 38→39" + +# Then sequential (depends on T003): +Task T006: "Handle air_quality_metrics in TelemetryPacketHandlerImpl" +``` + +## Parallel Example: MVP Stories (Phases 3 + 4) + +```bash +# After Phase 2 completes, run both P1 stories in parallel: +# Developer A: User Story 1 (info cards) +Task T007: "Create AirQualityInfoCards composable" +Task T008: "Apply CO₂ severity color" +Task T009: "Integrate into node detail screen" + +# Developer B: User Story 5 (navigation) +Task T010: "Add NodeDetailRoute.AirQualityMetrics" +Task T011: "Add AIR_QUALITY to LogsType enum" +Task T012: "Register route in NodesNavigation.kt" +``` + +--- + +## Implementation Strategy + +### MVP First (User Stories 1 + 5 Only) + +1. Complete Phase 1: Setup (strings + CO₂ utility) +2. Complete Phase 2: Foundational (model + entity + migration + handler) +3. Complete Phase 3: User Story 1 (info cards on node detail) +4. Complete Phase 4: User Story 5 (navigation plumbing) +5. **STOP and VALIDATE**: Info cards show live data, navigation works +6. Run `./gradlew spotlessCheck detekt :core:model:test :core:data:test :core:database:test :feature:node:test` + +### Incremental Delivery + +1. Setup + Foundational → Data pipeline works end-to-end +2. Add US1 + US5 → MVP: View readings + navigate (deployable) +3. Add US2 → History browsing with time frame filter +4. Add US3 → Charts for trend analysis +5. Add US4 → CSV export for external tools +6. Polish → Design review, privacy check, full CI validation + +--- + +## Notes + +- All new code in `commonMain` source sets (Constitution I) +- Follow `EnvironmentMetrics` patterns exactly (info cards, log screen, CSV export) +- `Co2Severity` thresholds per design/issues/53: Good ≤1000, Stuffy 1000–2000, Poor 2000–5000, Unsafe 5000+, Evacuate 30000+ +- Chart style: thin lines only, dot marker at selection point only (design/issues/53) +- Gas resistance intentionally excluded (already shown as IAQ in Environment metrics) +- Proto submodule is read-only — `AirQualityMetrics` message already exists upstream +- Request button infrastructure already exists (TelemetricActionsSection + CommandSenderImpl) — only the response handler is new