From a976a777e6ce2266f9bd79667fdfa99b7abfcf7d Mon Sep 17 00:00:00 2001 From: Robert Ekl Date: Wed, 25 Mar 2026 11:21:20 -0500 Subject: [PATCH] fix(meshcore): decode room server status layout --- .../Services/BinaryProtocolService.swift | 18 +++- .../Services/RemoteNodeService.swift | 6 +- MeshCore/README.md | 3 + .../Sources/MeshCore/Events/MeshEvent.swift | 22 ++++- .../MeshCore.docc/Articles/BinaryProtocol.md | 5 +- .../Sources/MeshCore/Protocol/Parsers.swift | 93 +++++++++++++------ .../MeshCore/Session/MeshCoreSession.swift | 54 +++++++++-- ...shCoreSessionCommandCorrelationTests.swift | 70 ++++++++++++++ .../Validation/ProtocolBugFixTests.swift | 49 ++++++++++ 9 files changed, 282 insertions(+), 38 deletions(-) diff --git a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift index 6f33037f4..3b11d9507 100644 --- a/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift +++ b/MC1Services/Sources/MC1Services/Services/BinaryProtocolService.swift @@ -119,7 +119,7 @@ public actor BinaryProtocolService { // MARK: - Status Request /// Request status from a remote node (blocking, waits for response) - /// - Parameter publicKey: The remote node's public key (full or prefix) + /// - Parameter publicKey: The remote node's full 32-byte public key /// - Returns: StatusResponse with device stats public func requestStatus(from publicKey: Data) async throws -> StatusResponse { do { @@ -129,6 +129,22 @@ public actor BinaryProtocolService { } } + /// Request status from a remote node (blocking, waits for response) + /// - Parameters: + /// - publicKey: The remote node's full 32-byte public key + /// - type: The target node type used to select the correct firmware status layout + /// - Returns: StatusResponse with device stats + public func requestStatus( + from publicKey: Data, + type: ContactType + ) async throws -> StatusResponse { + do { + return try await session.requestStatus(from: publicKey, type: type) + } catch let error as MeshCoreError { + throw BinaryProtocolError.sessionError(error) + } + } + // MARK: - Telemetry Request /// Request telemetry from a remote node (blocking, waits for response) diff --git a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift index 87e5b70ee..e9148c7e5 100644 --- a/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift +++ b/MC1Services/Sources/MC1Services/Services/RemoteNodeService.swift @@ -727,7 +727,8 @@ public actor RemoteNodeService { // Request status (which triggers sync) do { - _ = try await session.requestStatus(from: remoteSession.publicKey) + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + _ = try await session.requestStatus(from: remoteSession.publicKey, type: contactType) } catch let error as MeshCoreError { throw RemoteNodeError.sessionError(error) } @@ -781,7 +782,8 @@ public actor RemoteNodeService { do { let effectiveTimeout = timeout ?? RemoteOperationTimeoutPolicy.binaryMaximum return try await withTimeout(effectiveTimeout, operationName: "remoteStatus") { - try await self.session.requestStatus(from: remoteSession.publicKey) + let contactType: ContactType = remoteSession.isRoom ? .room : .repeater + return try await self.session.requestStatus(from: remoteSession.publicKey, type: contactType) } } catch is TimeoutError { throw RemoteNodeError.timeout diff --git a/MeshCore/README.md b/MeshCore/README.md index 73eab7b2f..04c75b1e8 100644 --- a/MeshCore/README.md +++ b/MeshCore/README.md @@ -172,6 +172,9 @@ for await state in await session.connectionState { let status = try await session.requestStatus(from: publicKey) print("Remote battery: \(status.battery) mV, uptime: \(status.uptime)s") +// For room servers, use a typed request so the correct status layout is decoded. +let roomStatus = try await session.requestStatus(from: roomContact) + // Request telemetry let telemetry = try await session.requestTelemetry(from: publicKey) diff --git a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift index 9515d04cf..6dd285e00 100644 --- a/MeshCore/Sources/MeshCore/Events/MeshEvent.swift +++ b/MeshCore/Sources/MeshCore/Events/MeshEvent.swift @@ -508,6 +508,16 @@ public struct ChannelInfo: Sendable, Equatable { /// - Push notification responses: offset=8, pubkey_prefix at bytes 2-8, fields follow /// The parser must handle both cases based on whether this is a solicited vs unsolicited response public struct StatusResponse: Sendable, Equatable { + /// Describes which firmware status layout was used to decode the payload. + public enum Layout: Sendable, Equatable { + /// Standard repeater / legacy status layout. + case repeater + /// Room server layout used by room-server firmware. + case roomServer + } + + /// The decoded status layout. + public let layout: Layout /// The public key prefix of the responding node. public let publicKeyPrefix: Data /// The battery level in millivolts. @@ -546,9 +556,14 @@ public struct StatusResponse: Sendable, Equatable { public let rxAirtime: UInt32 /// Total receive errors (v1.12+, 0 for older firmware). public let receiveErrors: UInt32 + /// Total messages posted to the room server. + public let roomServerPostedCount: UInt16? + /// Total room-server post push attempts. + public let roomServerPostPushCount: UInt16? /// Initializes a new status response object. public init( + layout: Layout = .repeater, publicKeyPrefix: Data, battery: Int, txQueueLength: Int, @@ -567,8 +582,11 @@ public struct StatusResponse: Sendable, Equatable { directDuplicates: Int, floodDuplicates: Int, rxAirtime: UInt32, - receiveErrors: UInt32 = 0 + receiveErrors: UInt32 = 0, + roomServerPostedCount: UInt16? = nil, + roomServerPostPushCount: UInt16? = nil ) { + self.layout = layout self.publicKeyPrefix = publicKeyPrefix self.battery = battery self.txQueueLength = txQueueLength @@ -588,6 +606,8 @@ public struct StatusResponse: Sendable, Equatable { self.floodDuplicates = floodDuplicates self.rxAirtime = rxAirtime self.receiveErrors = receiveErrors + self.roomServerPostedCount = roomServerPostedCount + self.roomServerPostPushCount = roomServerPostPushCount } } diff --git a/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md b/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md index 545e913e1..77c263d7b 100644 --- a/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md +++ b/MeshCore/Sources/MeshCore/MeshCore.docc/Articles/BinaryProtocol.md @@ -11,7 +11,7 @@ The binary protocol provides efficient data transfer for complex requests like s Query a remote node's status: ```swift -let status = try await session.requestStatus(from: contact.publicKey) +let status = try await session.requestStatus(from: contact) print("Battery: \(status.battery)mV") print("Uptime: \(status.uptime)s") @@ -124,3 +124,6 @@ do { print("Unexpected response: expected \(expected), got \(got)") } ``` + +When querying a room server, use ``requestStatus(from: MeshContact)`` or +``requestStatus(from:type:)`` so the session can select the room-server status layout. diff --git a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift index 65b5f6ff5..25cecdd7a 100644 --- a/MeshCore/Sources/MeshCore/Protocol/Parsers.swift +++ b/MeshCore/Sources/MeshCore/Protocol/Parsers.swift @@ -712,7 +712,11 @@ public enum Parsers { /// - data: Raw binary response payload (without the 4-byte tag). /// - publicKeyPrefix: The 6-byte public key prefix from the pending request context. /// - Returns: A `StatusResponse` if parsing succeeds, `nil` otherwise. - static func parseFromBinaryResponse(_ data: Data, publicKeyPrefix: Data) -> MeshCore.StatusResponse? { + static func parseFromBinaryResponse( + _ data: Data, + publicKeyPrefix: Data, + layout: MeshCore.StatusResponse.Layout = .repeater + ) -> MeshCore.StatusResponse? { // Accept exactly 48 (no rxAirtime), 52 (with rxAirtime), or 56+ (with receiveErrors). // Reject malformed payloads with incomplete fields (49-51, 53-55). guard data.count == PacketSize.binaryResponseStatusBase || @@ -736,33 +740,68 @@ public enum Parsers { let lastSNR = Double(data.readInt16LE(at: offset)) / 4.0; offset += 2 let directDups = Int(data.readUInt16LE(at: offset)); offset += 2 let floodDups = Int(data.readUInt16LE(at: offset)); offset += 2 - let rxAirtime: UInt32 = data.count >= PacketSize.binaryResponseStatusWithRxAirtime - ? data.readUInt32LE(at: offset) : 0 - offset += 4 - let receiveErrors: UInt32 = data.count >= PacketSize.binaryResponseStatusWithReceiveErrors - ? data.readUInt32LE(at: offset) : 0 + switch layout { + case .repeater: + let rxAirtime: UInt32 = data.count >= PacketSize.binaryResponseStatusWithRxAirtime + ? data.readUInt32LE(at: offset) : 0 + offset += 4 + let receiveErrors: UInt32 = data.count >= PacketSize.binaryResponseStatusWithReceiveErrors + ? data.readUInt32LE(at: offset) : 0 + + return MeshCore.StatusResponse( + layout: .repeater, + publicKeyPrefix: publicKeyPrefix, + battery: battery, + txQueueLength: txQueueLen, + noiseFloor: noiseFloor, + lastRSSI: lastRSSI, + packetsReceived: packetsRecv, + packetsSent: packetsSent, + airtime: airtime, + uptime: uptime, + sentFlood: sentFlood, + sentDirect: sentDirect, + receivedFlood: recvFlood, + receivedDirect: recvDirect, + fullEvents: fullEvents, + lastSNR: lastSNR, + directDuplicates: directDups, + floodDuplicates: floodDups, + rxAirtime: rxAirtime, + receiveErrors: receiveErrors + ) - return MeshCore.StatusResponse( - publicKeyPrefix: publicKeyPrefix, - battery: battery, - txQueueLength: txQueueLen, - noiseFloor: noiseFloor, - lastRSSI: lastRSSI, - packetsReceived: packetsRecv, - packetsSent: packetsSent, - airtime: airtime, - uptime: uptime, - sentFlood: sentFlood, - sentDirect: sentDirect, - receivedFlood: recvFlood, - receivedDirect: recvDirect, - fullEvents: fullEvents, - lastSNR: lastSNR, - directDuplicates: directDups, - floodDuplicates: floodDups, - rxAirtime: rxAirtime, - receiveErrors: receiveErrors - ) + case .roomServer: + let postedCount: UInt16? = data.count >= PacketSize.binaryResponseStatusWithRxAirtime + ? data.readUInt16LE(at: offset) : nil + let postPushCount: UInt16? = data.count >= PacketSize.binaryResponseStatusWithRxAirtime + ? data.readUInt16LE(at: offset + 2) : nil + + return MeshCore.StatusResponse( + layout: .roomServer, + publicKeyPrefix: publicKeyPrefix, + battery: battery, + txQueueLength: txQueueLen, + noiseFloor: noiseFloor, + lastRSSI: lastRSSI, + packetsReceived: packetsRecv, + packetsSent: packetsSent, + airtime: airtime, + uptime: uptime, + sentFlood: sentFlood, + sentDirect: sentDirect, + receivedFlood: recvFlood, + receivedDirect: recvDirect, + fullEvents: fullEvents, + lastSNR: lastSNR, + directDuplicates: directDups, + floodDuplicates: floodDups, + rxAirtime: 0, + receiveErrors: 0, + roomServerPostedCount: postedCount, + roomServerPostPushCount: postPushCount + ) + } } } diff --git a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift index 7d142abd3..7eaed6576 100644 --- a/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift +++ b/MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift @@ -883,6 +883,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// Requests status information from a remote node using the binary protocol. /// + /// Raw public-key status requests use the repeater status layout. + /// For room servers, prefer ``requestStatus(from: MeshContact)`` or + /// ``requestStatus(from:type:)`` so the correct status layout is selected. + /// /// - Parameter publicKey: The full 32-byte public key of the remote node. /// - Returns: A status response containing battery, uptime, and other metrics. /// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period. @@ -890,14 +894,46 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// ``MeshCoreError/invalidResponse`` if an unexpected response is received. public func requestStatus(from publicKey: Data) async throws -> StatusResponse { try requireFullPublicKey(publicKey, operation: "requestStatus") - // Serialize binary requests to prevent messageSent race conditions return try await binaryRequestSerializer.withSerialization { [self] in - try await performStatusRequest(from: publicKey) + try await performStatusRequest(from: publicKey, layout: .repeater) } } + /// Requests status information from a remote node using the binary protocol. + /// + /// - Parameters: + /// - publicKey: The full 32-byte public key of the remote node. + /// - type: The target node type used to choose the correct firmware status layout. + /// - Returns: A status response containing battery, uptime, and other metrics. + /// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period. + /// ``MeshCoreError/deviceError(code:)`` if the device rejects the request. + /// ``MeshCoreError/invalidResponse`` if an unexpected response is received. + public func requestStatus( + from publicKey: Data, + type: ContactType + ) async throws -> StatusResponse { + try requireFullPublicKey(publicKey, operation: "requestStatus") + let layout: StatusResponse.Layout = type == .room ? .roomServer : .repeater + return try await binaryRequestSerializer.withSerialization { [self] in + try await performStatusRequest(from: publicKey, layout: layout) + } + } + + /// Requests status information from a remote contact using its contact type to + /// select the correct firmware status layout. + /// + /// - Parameter contact: The remote contact to query. + /// - Returns: A status response containing battery, uptime, and other metrics. + /// - Throws: ``MeshCoreError`` if the request fails. + public func requestStatus(from contact: MeshContact) async throws -> StatusResponse { + try await requestStatus(from: contact.publicKey, type: contact.type) + } + /// Internal implementation of status request, called within serialization. - private func performStatusRequest(from publicKey: Data) async throws -> StatusResponse { + private func performStatusRequest( + from publicKey: Data, + layout: StatusResponse.Layout + ) async throws -> StatusResponse { let data = PacketBuilder.binaryRequest(to: publicKey, type: .status) let publicKeyPrefix = Data(publicKey.prefix(6)) let prefixHex = publicKeyPrefix.map { String(format: "%02x", $0) }.joined() @@ -942,7 +978,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { guard let response = Parsers.StatusResponse.parseFromBinaryResponse( responseData, - publicKeyPrefix: publicKeyPrefix + publicKeyPrefix: publicKeyPrefix, + layout: layout ) else { return nil } @@ -996,8 +1033,13 @@ public actor MeshCoreSession: MeshCoreSessionProtocol { /// - Returns: Status response from the remote node. /// - Throws: ``MeshCoreError`` on failure. public func requestStatus(from destination: Destination) async throws -> StatusResponse { - let publicKey = try destination.fullPublicKey() - return try await requestStatus(from: publicKey) + switch destination { + case .contact(let contact): + return try await requestStatus(from: contact) + case .data, .hexString: + let publicKey = try destination.fullPublicKey() + return try await requestStatus(from: publicKey) + } } // MARK: - Keep-Alive diff --git a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift index 61aebe77d..31711793a 100644 --- a/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Session/MeshCoreSessionCommandCorrelationTests.swift @@ -320,6 +320,45 @@ struct MeshCoreSessionCommandCorrelationTests { await session.stop() } + @Test("requestStatus uses room layout for typed room targets") + func requestStatusUsesRoomLayoutForTypedRoomTargets() async throws { + let transport = MockTransport() + let session = MeshCoreSession( + transport: transport, + configuration: SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MCTst") + ) + + try await startSession(session, transport: transport) + + let target = Data(repeating: 0x31, count: 32) + let expectedAck = Data([0xAA, 0xBB, 0xCC, 0xDD]) + + let statusTask = Task { + try await session.requestStatus(from: target, type: .room) + } + + try await waitUntil("requestStatus should be sent") { + await transport.sentData.count == 2 + } + + await transport.simulateReceive(makeMessageSentPacket(expectedAck: expectedAck)) + await transport.simulateReceive( + makeBinaryStatusResponsePacket( + tag: expectedAck, + battery: 1000, + roomServerPostedCount: 17, + roomServerPostPushCount: 9 + ) + ) + + let status = try await statusTask.value + #expect(status.battery == 1000) + #expect(status.roomServerPostedCount == 17) + #expect(status.roomServerPostPushCount == 9) + #expect(status.rxAirtime == 0) + await session.stop() + } + @Test("requestTelemetry fails fast on device error before messageSent") func requestTelemetryFailsFastOnDeviceErrorBeforeMessageSent() async throws { let transport = MockTransport() @@ -456,6 +495,18 @@ private func makeBatteryPacket(level: UInt16) -> Data { return packet } +private func makeMessageSentPacket( + type: UInt8 = 0, + expectedAck: Data, + timeoutMs: UInt32 = 5000 +) -> Data { + var packet = Data([ResponseCode.messageSent.rawValue]) + packet.append(type) + packet.append(expectedAck) + packet.append(contentsOf: withUnsafeBytes(of: timeoutMs.littleEndian) { Array($0) }) + return packet +} + private func makeTelemetryPacket(publicKeyPrefix: Data, lppPayload: Data) -> Data { var packet = Data([ResponseCode.telemetryResponse.rawValue]) packet.append(0x00) @@ -487,6 +538,25 @@ private func makeStatusResponsePacket(publicKeyPrefix: Data, battery: UInt16) -> return packet } +private func makeBinaryStatusResponsePacket( + tag: Data, + battery: UInt16, + roomServerPostedCount: UInt16, + roomServerPostPushCount: UInt16 +) -> Data { + var packet = Data([ResponseCode.binaryResponse.rawValue]) + packet.append(0x00) + packet.append(tag) + + var payload = Data(repeating: 0, count: 52) + payload.replaceSubrange(0..<2, with: withUnsafeBytes(of: battery.littleEndian) { Array($0) }) + payload.replaceSubrange(48..<50, with: withUnsafeBytes(of: roomServerPostedCount.littleEndian) { Array($0) }) + payload.replaceSubrange(50..<52, with: withUnsafeBytes(of: roomServerPostPushCount.littleEndian) { Array($0) }) + + packet.append(payload) + return packet +} + private func makeChannelInfoPacket(index: UInt8, name: String, secret: Data) -> Data { var packet = Data([ResponseCode.channelInfo.rawValue, index]) let nameBytes = Array(name.utf8.prefix(31)) diff --git a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift index 79cf1b453..e21a99ab0 100644 --- a/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift +++ b/MeshCore/Tests/MeshCoreTests/Validation/ProtocolBugFixTests.swift @@ -254,6 +254,55 @@ struct ProtocolBugFixTests { #expect(status.receiveErrors == 0, "receiveErrors should default to 0 for 52-byte payload") } + @Test("statusResponse parseFromBinaryResponse room server 52 bytes parses room counters") + func statusResponseParseFromBinaryResponseRoomServer52BytesParsesRoomCounters() { + var payload = Data(repeating: 0, count: 52) + payload[0] = 0x3C + payload[1] = 0x0F // Battery: 3900mV + payload[2] = 0x02 + payload[3] = 0x00 // txQueueLength: 2 + payload[4] = 0x8D + payload[5] = 0xFF // noiseFloor: -115 + payload[6] = 0xA9 + payload[7] = 0xFF // lastRSSI: -87 + payload[8] = 0x78 + payload[12] = 0x2D + payload[16] = 0x10 + payload[17] = 0x0E // airtime: 3600 + payload[20] = 0x20 + payload[21] = 0x1C // uptime: 7200 + payload[24] = 0x0C + payload[28] = 0x08 + payload[32] = 0x0E + payload[36] = 0x0A + payload[40] = 0x03 + payload[42] = 0x18 + payload[44] = 0x01 + payload[46] = 0x02 + payload[48] = 0x11 + payload[49] = 0x00 // roomServerPostedCount: 17 + payload[50] = 0x09 + payload[51] = 0x00 // roomServerPostPushCount: 9 + + let pubkeyPrefix = Data([0x11, 0x22, 0x33, 0x44, 0x55, 0x66]) + let status = Parsers.StatusResponse.parseFromBinaryResponse( + payload, + publicKeyPrefix: pubkeyPrefix, + layout: .roomServer + ) + + guard let status = status else { + Issue.record("Should parse 52-byte room server payload") + return + } + + #expect(status.layout == .roomServer) + #expect(status.roomServerPostedCount == 17) + #expect(status.roomServerPostPushCount == 9) + #expect(status.rxAirtime == 0) + #expect(status.receiveErrors == 0) + } + @Test("statusResponse parseFromBinaryResponse 56 bytes parses receiveErrors") func statusResponseParseFromBinaryResponse56BytesParsesReceiveErrors() { // 56 bytes: has rxAirtime and receiveErrors (v1.12+)