From 355d2260e899fffb562932ff627c482c72efc29e Mon Sep 17 00:00:00 2001 From: James Rich <2199651+jamesarich@users.noreply.github.com> Date: Sun, 25 Jan 2026 08:15:47 -0600 Subject: [PATCH] feat: Add Status Message module support (#4163) Signed-off-by: James Rich <2199651+jamesarich@users.noreply.github.com> --- .../mesh/navigation/SettingsNavigation.kt | 4 + .../mesh/repository/radio/MockInterface.kt | 40 +- .../mesh/service/MeshDataHandler.kt | 8 + .../mesh/service/MeshNodeManager.kt | 4 + .../34.json | 1016 +++++++++++++++++ .../core/database/MeshtasticDatabase.kt | 3 +- .../core/database/entity/NodeEntity.kt | 4 + .../meshtastic/core/database/model/Node.kt | 1 + .../org/meshtastic/core/model/DataPacket.kt | 9 +- .../org/meshtastic/core/navigation/Routes.kt | 2 + .../composeResources/values/strings.xml | 3 + .../node/component/NodeDetailsSection.kt | 15 + .../feature/node/component/NodeItem.kt | 11 + .../settings/navigation/ModuleRoute.kt | 10 +- .../component/StatusMessageConfigItemList.kt | 74 ++ 15 files changed, 1195 insertions(+), 9 deletions(-) create mode 100644 core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/34.json create mode 100644 feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt diff --git a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt index 29ee0dc62..aa498f009 100644 --- a/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt +++ b/app/src/main/java/com/geeksville/mesh/navigation/SettingsNavigation.kt @@ -59,6 +59,7 @@ import org.meshtastic.feature.settings.radio.component.RangeTestConfigScreen import org.meshtastic.feature.settings.radio.component.RemoteHardwareConfigScreen import org.meshtastic.feature.settings.radio.component.SecurityConfigScreen import org.meshtastic.feature.settings.radio.component.SerialConfigScreen +import org.meshtastic.feature.settings.radio.component.StatusMessageConfigScreen import org.meshtastic.feature.settings.radio.component.StoreForwardConfigScreen import org.meshtastic.feature.settings.radio.component.TelemetryConfigScreen import org.meshtastic.feature.settings.radio.component.UserConfigScreen @@ -163,6 +164,9 @@ fun NavGraphBuilder.settingsGraph(navController: NavHostController) { DetectionSensorConfigScreen(viewModel, onBack = navController::popBackStack) ModuleRoute.PAXCOUNTER -> PaxcounterConfigScreen(viewModel, onBack = navController::popBackStack) + + ModuleRoute.STATUS_MESSAGE -> + StatusMessageConfigScreen(viewModel, onBack = navController::popBackStack) } } } diff --git a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt index 3dc1da8b8..67ab2d5b9 100644 --- a/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt +++ b/app/src/main/java/com/geeksville/mesh/repository/radio/MockInterface.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package com.geeksville.mesh.repository.radio import co.touchlab.kermit.Logger @@ -32,12 +31,14 @@ import org.meshtastic.proto.ChannelProtos import org.meshtastic.proto.ConfigKt import org.meshtastic.proto.ConfigProtos import org.meshtastic.proto.MeshProtos +import org.meshtastic.proto.ModuleConfigProtos import org.meshtastic.proto.Portnums import org.meshtastic.proto.TelemetryProtos import org.meshtastic.proto.channel import org.meshtastic.proto.config import org.meshtastic.proto.deviceMetadata import org.meshtastic.proto.fromRadio +import org.meshtastic.proto.moduleConfig import org.meshtastic.proto.queueStatus import kotlin.random.Random @@ -108,6 +109,16 @@ constructor( } } + d.getModuleConfigRequest == AdminProtos.AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG -> + sendAdmin(pr.packet.to, pr.packet.from, pr.packet.id) { + getModuleConfigResponse = moduleConfig { + statusmessage = + ModuleConfigProtos.ModuleConfig.StatusMessageConfig.newBuilder() + .setNodeStatus("Going to the farm.. to grow wheat.") + .build() + } + } + else -> Logger.i { "Ignoring admin sent to mock interface $d" } } } @@ -240,6 +251,30 @@ constructor( .build() } + private fun makeNodeStatus(numIn: Int) = MeshProtos.FromRadio.newBuilder().apply { + packet = + MeshProtos.MeshPacket.newBuilder() + .apply { + id = packetIdSequence.next() + from = numIn + to = 0xffffffff.toInt() // broadcast + rxTime = (System.currentTimeMillis() / 1000).toInt() + rxSnr = 1.5f + decoded = + MeshProtos.Data.newBuilder() + .apply { + portnum = Portnums.PortNum.NODE_STATUS_APP + payload = + MeshProtos.StatusMessage.newBuilder() + .setStatus("Going to the farm.. to grow wheat.") + .build() + .toByteString() + } + .build() + } + .build() + } + private fun makeDataPacket(fromIn: Int, toIn: Int, data: MeshProtos.Data.Builder) = MeshProtos.FromRadio.newBuilder().apply { packet = @@ -356,6 +391,7 @@ constructor( makeNeighborInfo(MY_NODE + 1), makePosition(MY_NODE + 1), makeTelemetry(MY_NODE + 1), + makeNodeStatus(MY_NODE + 1), ) packets.forEach { p -> service.handleFromRadio(p.build().toByteArray()) } diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt index 944135710..e975fee09 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshDataHandler.kt @@ -95,6 +95,7 @@ constructor( Portnums.PortNum.TEXT_MESSAGE_APP_VALUE, Portnums.PortNum.ALERT_APP_VALUE, Portnums.PortNum.WAYPOINT_APP_VALUE, + Portnums.PortNum.NODE_STATUS_APP_VALUE, ) fun handleReceivedData(packet: MeshPacket, myNodeNum: Int, logUuid: String? = null, logInsertJob: Job? = null) { @@ -121,6 +122,7 @@ constructor( var shouldBroadcast = !fromUs when (packet.decoded.portnumValue) { Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> handleTextMessage(packet, dataPacket, myNodeNum) + Portnums.PortNum.NODE_STATUS_APP_VALUE -> handleNodeStatus(packet, dataPacket, myNodeNum) Portnums.PortNum.ALERT_APP_VALUE -> rememberDataPacket(dataPacket, myNodeNum) Portnums.PortNum.WAYPOINT_APP_VALUE -> handleWaypoint(packet, dataPacket, myNodeNum) Portnums.PortNum.POSITION_APP_VALUE -> handlePosition(packet, dataPacket, myNodeNum) @@ -331,6 +333,12 @@ constructor( nodeManager.handleReceivedUser(packet.from, u, packet.channel) } + private fun handleNodeStatus(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { + val s = MeshProtos.StatusMessage.parseFrom(packet.decoded.payload) + nodeManager.handleReceivedNodeStatus(packet.from, s) + rememberDataPacket(dataPacket, myNodeNum) + } + private fun handleTelemetry(packet: MeshPacket, dataPacket: DataPacket, myNodeNum: Int) { val t = TelemetryProtos.Telemetry.parseFrom(packet.decoded.payload).copy { diff --git a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt index 615f1eae3..2ca087d80 100644 --- a/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt +++ b/app/src/main/java/com/geeksville/mesh/service/MeshNodeManager.kt @@ -209,6 +209,10 @@ constructor( updateNodeInfo(fromNum) { it.paxcounter = p } } + fun handleReceivedNodeStatus(fromNum: Int, s: MeshProtos.StatusMessage) { + updateNodeInfo(fromNum) { it.nodeStatus = s.status } + } + fun installNodeInfo(info: MeshProtos.NodeInfo, withBroadcast: Boolean = true) { updateNodeInfo(info.num, withBroadcast = withBroadcast) { entity -> if (info.hasUser()) { diff --git a/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/34.json b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/34.json new file mode 100644 index 000000000..d87655c8b --- /dev/null +++ b/core/database/schemas/org.meshtastic.core.database.MeshtasticDatabase/34.json @@ -0,0 +1,1016 @@ +{ + "formatVersion": 1, + "database": { + "version": 34, + "identityHash": "34352663e54f76b7b9c13de31d9ac8e7", + "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, `paxcounter` BLOB NOT NULL, `public_key` BLOB, `notes` TEXT NOT NULL DEFAULT '', `manually_verified` INTEGER NOT NULL DEFAULT 0, `node_status` TEXT, 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": "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" + } + ], + "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`)" + } + ] + }, + { + "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)", + "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" + } + ], + "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`)" + } + ] + }, + { + "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, `retry_count` 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": "retryCount", + "columnName": "retry_count", + "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, '34352663e54f76b7b9c13de31d9ac8e7')" + ] + } +} \ No newline at end of file diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt index ee114be5a..786a50ee9 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/MeshtasticDatabase.kt @@ -91,8 +91,9 @@ import org.meshtastic.core.database.entity.TracerouteNodePositionEntity AutoMigration(from = 30, to = 31), AutoMigration(from = 31, to = 32), AutoMigration(from = 32, to = 33), + AutoMigration(from = 33, to = 34), ], - version = 33, + version = 34, exportSchema = true, ) @TypeConverters(Converters::class) diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt index 8f36af52e..9300befb2 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/entity/NodeEntity.kt @@ -62,6 +62,7 @@ data class NodeWithRelations( paxcounter = paxcounter, notes = notes, manuallyVerified = manuallyVerified, + nodeStatus = nodeStatus, ) } @@ -85,6 +86,7 @@ data class NodeWithRelations( paxcounter = paxcounter, notes = notes, manuallyVerified = manuallyVerified, + nodeStatus = nodeStatus, ) } } @@ -139,6 +141,7 @@ data class NodeEntity( @ColumnInfo(name = "notes", defaultValue = "") var notes: String = "", @ColumnInfo(name = "manually_verified", defaultValue = "0") var manuallyVerified: Boolean = false, // ONLY set true when scanned/imported manually + @ColumnInfo(name = "node_status") var nodeStatus: String? = null, ) { val deviceMetrics: TelemetryProtos.DeviceMetrics get() = deviceTelemetry.deviceMetrics @@ -194,6 +197,7 @@ data class NodeEntity( paxcounter = paxcounter, publicKey = publicKey ?: user.publicKey, notes = notes, + nodeStatus = nodeStatus, ) fun toNodeInfo() = NodeInfo( diff --git a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt index 5f3a32b05..9deb0ef81 100644 --- a/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt +++ b/core/database/src/main/kotlin/org/meshtastic/core/database/model/Node.kt @@ -55,6 +55,7 @@ data class Node( val publicKey: ByteString? = null, val notes: String = "", val manuallyVerified: Boolean = false, + val nodeStatus: String? = null, ) { val capabilities: Capabilities by lazy { Capabilities(metadata?.firmwareVersion) } diff --git a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt index 38570014b..52ed43c14 100644 --- a/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt +++ b/core/model/src/main/kotlin/org/meshtastic/core/model/DataPacket.kt @@ -90,10 +90,11 @@ data class DataPacket( /** If this is a text message, return the string, otherwise null */ val text: String? get() = - if (dataType == Portnums.PortNum.TEXT_MESSAGE_APP_VALUE) { - bytes?.decodeToString() - } else { - null + when (dataType) { + Portnums.PortNum.TEXT_MESSAGE_APP_VALUE -> bytes?.decodeToString() + // Portnums.PortNum.NODE_STATUS_APP_VALUE -> + // MeshProtos.StatusMessage.parseFrom(bytes).status + else -> null } val alert: String? diff --git a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt index b2e2d7f42..e5d7462c1 100644 --- a/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt +++ b/core/navigation/src/main/kotlin/org/meshtastic/core/navigation/Routes.kt @@ -141,6 +141,8 @@ object SettingsRoutes { @Serializable data object Paxcounter : Route + @Serializable data object StatusMessage : Route + // endregion // region advanced config routes diff --git a/core/strings/src/commonMain/composeResources/values/strings.xml b/core/strings/src/commonMain/composeResources/values/strings.xml index 21a1d408d..6f634dfbe 100644 --- a/core/strings/src/commonMain/composeResources/values/strings.xml +++ b/core/strings/src/commonMain/composeResources/values/strings.xml @@ -659,6 +659,9 @@ Subnet Paxcounter Config Paxcounter enabled + Status Message + Status Message Config + The actual status string WiFi RSSI threshold (defaults to -80) BLE RSSI threshold (defaults to -80) Position diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt index 65caeaefd..cc976a8d5 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeDetailsSection.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.SignalCellularAlt import androidx.compose.material.icons.filled.Verified import androidx.compose.material.icons.filled.Work +import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -128,6 +129,7 @@ private fun MismatchKeyWarning(modifier: Modifier = Modifier) { } } +@Suppress("LongMethod") @Composable private fun MainNodeDetails(node: Node) { Column { @@ -149,6 +151,19 @@ private fun MainNodeDetails(node: Node) { SectionDivider() PublicKeyItem(publicKey.toByteArray()) } + + if (!node.nodeStatus.isNullOrEmpty()) { + HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) + + Row(modifier = Modifier.fillMaxWidth()) { + InfoItem( + label = "Status", + value = node.nodeStatus!!, + icon = Icons.Default.CheckCircle, + modifier = Modifier.weight(1f), + ) + } + } } } diff --git a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt index 43e052b18..394a36f7c 100644 --- a/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt +++ b/feature/node/src/main/kotlin/org/meshtastic/feature/node/component/NodeItem.kt @@ -199,6 +199,17 @@ fun NodeItem( } } } + + if (!thatNode.nodeStatus.isNullOrEmpty()) { + Spacer(modifier = Modifier.height(2.dp)) + Text( + text = thatNode.nodeStatus!!, + style = MaterialTheme.typography.bodySmall, + color = contentColor, + maxLines = 2, + ) + } + Spacer(modifier = Modifier.height(2.dp)) Row( modifier = Modifier.fillMaxWidth(), diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt index 86ccda526..b65a4ca8d 100644 --- a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/navigation/ModuleRoute.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Meshtastic LLC + * Copyright (c) 2025-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 @@ -14,7 +14,6 @@ * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ - package org.meshtastic.feature.settings.navigation import androidx.compose.material.icons.Icons @@ -47,6 +46,7 @@ import org.meshtastic.core.strings.paxcounter import org.meshtastic.core.strings.range_test import org.meshtastic.core.strings.remote_hardware import org.meshtastic.core.strings.serial +import org.meshtastic.core.strings.status_message import org.meshtastic.core.strings.store_forward import org.meshtastic.core.strings.telemetry import org.meshtastic.proto.AdminProtos @@ -131,6 +131,12 @@ enum class ModuleRoute(val title: StringResource, val route: Route, val icon: Im Icons.Default.PermScanWifi, AdminProtos.AdminMessage.ModuleConfigType.PAXCOUNTER_CONFIG_VALUE, ), + STATUS_MESSAGE( + Res.string.status_message, + SettingsRoutes.StatusMessage, + Icons.AutoMirrored.Default.Message, + AdminProtos.AdminMessage.ModuleConfigType.STATUSMESSAGE_CONFIG_VALUE, + ), ; val bitfield: Int diff --git a/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt new file mode 100644 index 000000000..d8378ac61 --- /dev/null +++ b/feature/settings/src/main/kotlin/org/meshtastic/feature/settings/radio/component/StatusMessageConfigItemList.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025-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.settings.radio.component + +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import org.jetbrains.compose.resources.stringResource +import org.meshtastic.core.strings.Res +import org.meshtastic.core.strings.node_status_summary +import org.meshtastic.core.strings.status_message +import org.meshtastic.core.strings.status_message_config +import org.meshtastic.core.ui.component.EditTextPreference +import org.meshtastic.core.ui.component.TitledCard +import org.meshtastic.feature.settings.radio.RadioConfigViewModel +import org.meshtastic.proto.copy +import org.meshtastic.proto.moduleConfig + +@Composable +fun StatusMessageConfigScreen(viewModel: RadioConfigViewModel = hiltViewModel(), onBack: () -> Unit) { + val state by viewModel.radioConfigState.collectAsStateWithLifecycle() + val statusMessageConfig = state.moduleConfig.statusmessage + val formState = rememberConfigState(initialValue = statusMessageConfig) + val focusManager = LocalFocusManager.current + + RadioConfigScreenList( + title = stringResource(Res.string.status_message), + onBack = onBack, + configState = formState, + enabled = state.connected, + responseState = state.responseState, + onDismissPacketResponse = viewModel::clearPacketResponse, + onSave = { + val config = moduleConfig { statusmessage = it } + viewModel.setModuleConfig(config) + }, + ) { + item { + TitledCard(title = stringResource(Res.string.status_message_config)) { + EditTextPreference( + title = stringResource(Res.string.node_status_summary), + value = formState.value.nodeStatus, + maxSize = 80, // status_message max_size:80 + enabled = state.connected, + isError = false, + keyboardOptions = + KeyboardOptions.Default.copy(keyboardType = KeyboardType.Text, imeAction = ImeAction.Done), + keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }), + onValueChanged = { formState.value = formState.value.copy { nodeStatus = it } }, + ) + } + } + } +}