Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
46 commits
Select commit Hold shift + click to select a range
f604b50
fix(chats): rename "Private Message" placeholder to "Direct Message"
mikewren Feb 26, 2026
c470cde
feat(reactions): support meshcore-open v3 and v1 reaction formats
Avi0n Mar 4, 2026
ab2df69
test(reactions): add MeshCoreOpenReactionParser tests
Avi0n Mar 4, 2026
5940fca
ui(live-activity): remove time since disconnected
Avi0n Mar 4, 2026
6b9c521
fix(chats): order messages by receive time, reorder same-sender clusters
Avi0n Mar 4, 2026
1ec9f0a
ui(chats): tighten message bubble spacing
Avi0n Mar 4, 2026
b735ddf
refactor(messages): move same-sender reordering to MessageDTO
Avi0n Mar 4, 2026
bef238f
fix(settings): show GPS source and alert on location permission denied
Avi0n Mar 4, 2026
c7999f3
refactor(onboarding): use UIApplication.openSettingsURLString
Avi0n Mar 4, 2026
a04e61d
fix(trace-path): remove invalid "9S" from code placeholder example
Avi0n Mar 4, 2026
4ee856a
fix(chats): wait for BLE confirmation before removing channel row
Avi0n Mar 4, 2026
9ff7465
i18n(chats): add channel deletion failed alert title
Avi0n Mar 4, 2026
1726ba2
refactor(chats): extract navigation cleanup helper, fix silent guard …
Avi0n Mar 4, 2026
bec80e2
fix(chats): use environment injection for message sheet detent on iPad
Avi0n Mar 5, 2026
13c5687
fix(repeater): use round-trip timeout for binary requests and CLI com…
Avi0n Mar 5, 2026
6932328
ui(repeater): align list row separators to leading edge
Avi0n Mar 5, 2026
78bd347
fix(settings): correct advert location policy mapping
Avi0n Mar 5, 2026
eef9ca0
fix(timeouts): centralize timeout policy and add cancellation
Avi0n Mar 5, 2026
db545bb
Bump version to 0.10.1
Avi0n Mar 5, 2026
5cef785
fix(location): persist manual location over gps
Avi0n Mar 6, 2026
83035f4
refactor(location): use advertLocationPolicyMode, skip redundant GPS …
Avi0n Mar 6, 2026
b744ac1
l10n(location): add device GPS translations and update footer text
Avi0n Mar 6, 2026
555b681
perf(chat): eliminate redundant URL detection and extract channel vie…
Avi0n Mar 6, 2026
1870385
perf(chats): reduce actor hops, timeline views, and row rebuilds
Avi0n Mar 6, 2026
607e16e
feat(protocol): introduce AutoAddConfig struct with maxHops
Avi0n Mar 6, 2026
5bb7cbb
feat(nodes): add auto-add max hops setting
Avi0n Mar 6, 2026
ce41175
fix(nodes): persist auto-add config to SwiftData after update
Avi0n Mar 6, 2026
d1c36da
l10n(nodes): translate maxHops strings for all languages
Avi0n Mar 6, 2026
71ed8b1
feat(cli): add MeshCore v1.14.0 commands to autocomplete
Avi0n Mar 6, 2026
54f5ab0
perf(chats): pre-decode link preview images off main thread
Avi0n Mar 6, 2026
d5e8329
perf(chats): cache MessageText formatting to eliminate per-render cost
Avi0n Mar 6, 2026
bb498ab
perf(chats): hoist showIncomingPath @AppStorage out of message cells
Avi0n Mar 6, 2026
94f0856
perf(chats): batch preview image mutations and fix legacy decode guard
Avi0n Mar 6, 2026
51e9fff
refactor(services): add isEncryptedChannel to Channel and ChannelDTO
Avi0n Mar 6, 2026
31a885a
feat(chats): add encryption indicator padlock to input bar
Avi0n Mar 6, 2026
e5121a3
fix(onboarding): check for other-app connection before pairing
Avi0n Mar 6, 2026
c321160
fix(onboarding): add retry UI and diagnostics for other-app pairing
Avi0n Mar 7, 2026
0379591
fix(pairing): poll for other-app reconnection after ASK disruption
Avi0n Mar 7, 2026
85a6815
fix(pairing): use single ASK picker display item to prevent duplicate…
Avi0n Mar 7, 2026
5aaf420
feat(connection): allow switching devices during reconnection
Avi0n Mar 8, 2026
b32638c
Merge pull request #234 from mikewren/fix/direct-message-placeholder
Avi0n Mar 8, 2026
adb15bd
fix(l10n): update non-English translations for "Direct Message" place…
Avi0n Mar 8, 2026
810dea4
fix(connection): guard rebuildSession against superseded reconnect cy…
Avi0n Mar 8, 2026
c08cf86
fix(connection): set connectingDeviceID in simulatorConnect
Avi0n Mar 8, 2026
224d38a
ui(settings): add description text under Max Hop Distance picker
Avi0n Mar 9, 2026
c275683
fix(parser): correct off-by-one in reaction string length check
Avi0n Mar 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion MeshCore/Sources/MeshCore/Events/MeshEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public enum MeshEvent: Sendable {
/// Indicates that auto-add configuration was received.
///
/// Emitted in response to ``MeshCoreSession/getAutoAddConfig()``.
case autoAddConfig(UInt8)
case autoAddConfig(AutoAddConfig)

/// Indicates that allowed repeat frequency ranges were received.
///
Expand Down Expand Up @@ -1053,6 +1053,21 @@ public struct DiscoverResponse: Sendable, Equatable {
}
}

/// Auto-add configuration received from the device.
///
/// Bundles the bitmask (which node types to auto-add) with the max hops filter.
public struct AutoAddConfig: Sendable, Equatable {
/// Bitmask controlling auto-add behavior (0x01=overwrite, 0x02=contacts, 0x04=repeaters, 0x08=rooms).
public let bitmask: UInt8
/// Maximum hops for auto-add filtering. 0 = no limit, 1 = direct only, N = up to N-1 hops (max 64).
public let maxHops: UInt8

public init(bitmask: UInt8, maxHops: UInt8 = 0) {
self.bitmask = bitmask
self.maxHops = maxHops
}
}

/// Represents an allowed frequency range for client repeat mode.
public struct FrequencyRange: Sendable, Equatable {
/// The lower bound of the frequency range in kHz.
Expand Down
6 changes: 3 additions & 3 deletions MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -643,9 +643,9 @@ public enum PacketBuilder: Sendable {
}

/// Builds a packet to set the auto-add configuration.
/// - Parameter config: The bitmask (0x01=overwrite, 0x02=contacts, 0x04=repeaters, 0x08=rooms)
public static func setAutoAddConfig(_ config: UInt8) -> Data {
Data([CommandCode.setAutoAddConfig.rawValue, config])
/// - Parameter config: The auto-add configuration (bitmask + max hops).
public static func setAutoAddConfig(_ config: AutoAddConfig) -> Data {
Data([CommandCode.setAutoAddConfig.rawValue, config.bitmask, config.maxHops])
}

/// Builds a getSelfTelemetry command to request current sensor data from the device.
Expand Down
4 changes: 2 additions & 2 deletions MeshCore/Sources/MeshCore/Protocol/PacketParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,14 +144,14 @@ extension PacketParser {
return Parsers.TuningParamsResponse.parse(payload)

case .autoAddConfig:
// Single byte bitmask
guard payload.count >= 1 else {
return .parseFailure(
data: payload,
reason: "AutoAddConfig response too short: \(payload.count) < 1"
)
}
return .autoAddConfig(payload[0])
let maxHops: UInt8 = payload.count >= 2 ? payload[1] : 0
return .autoAddConfig(AutoAddConfig(bitmask: payload[0], maxHops: maxHops))

case .allowedRepeatFreq:
return Parsers.AllowedRepeatFreq.parse(payload)
Expand Down
59 changes: 44 additions & 15 deletions MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -849,6 +849,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
private func performStatusRequest(from publicKey: Data) 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()
let startTime = ContinuousClock.now

logger.info("Status request to \(prefixHex): sending")

// Subscribe BEFORE sending to avoid race condition where binaryResponse
// arrives before we can register the pending request
Expand All @@ -861,7 +865,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
return try await withThrowingTaskGroup(of: StatusResponse?.self) { group in
let (timeoutStream, timeoutContinuation) = AsyncStream<TimeInterval>.makeStream()

group.addTask {
group.addTask { [logger] in
var expectedAck: Data?

for await event in events {
Expand All @@ -872,7 +876,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
// Capture the expectedAck from firmware's MSG_SENT response
expectedAck = info.expectedAck
// Signal dynamic timeout to timeout task
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0
logger.info("Status request to \(prefixHex): messageSent received, suggestedTimeoutMs=\(info.suggestedTimeoutMs), effective timeout=\(String(format: "%.1f", timeout))s")
timeoutContinuation.yield(timeout)
timeoutContinuation.finish()

Expand All @@ -889,10 +894,14 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
) else {
return nil
}
let elapsed = ContinuousClock.now - startTime
logger.info("Status request to \(prefixHex): response received in \(elapsed)")
return response

case .statusResponse(let response):
// Handle already-routed response (if routing happens elsewhere)
let elapsed = ContinuousClock.now - startTime
logger.info("Status request to \(prefixHex): routed response received in \(elapsed)")
return response

default:
Expand All @@ -903,14 +912,19 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
return nil
}

group.addTask { [clock = self.clock, defaultTimeout = configuration.defaultTimeout] in
group.addTask { [logger, clock = self.clock, defaultTimeout = configuration.defaultTimeout] in
// Wait for dynamic timeout from event task, or use default
var timeout = defaultTimeout
var usedFirmwareTimeout = false
for await t in timeoutStream {
timeout = t
usedFirmwareTimeout = true
break
}
logger.info("Status request to \(prefixHex): timeout task sleeping for \(String(format: "%.1f", timeout))s (\(usedFirmwareTimeout ? "firmware" : "default"))")
try await clock.sleep(for: .seconds(timeout))
let elapsed = ContinuousClock.now - startTime
logger.warning("Status request to \(prefixHex): timed out after \(elapsed)")
return nil
}

Expand Down Expand Up @@ -1162,10 +1176,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {

/// Gets the current auto-add configuration from the device.
///
/// - Returns: The auto-add config bitmask.
/// - Returns: The auto-add configuration (bitmask + max hops).
/// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond.
/// ``MeshCoreError/deviceError(code:)`` if the device returns an error.
public func getAutoAddConfig() async throws -> UInt8 {
public func getAutoAddConfig() async throws -> AutoAddConfig {
try await sendAndWaitWithError(PacketBuilder.getAutoAddConfig()) { event in
if case .autoAddConfig(let config) = event { return config }
return nil
Expand All @@ -1174,10 +1188,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {

/// Sets the auto-add configuration on the device.
///
/// - Parameter config: The bitmask (0x01=overwrite, 0x02=contacts, 0x04=repeaters, 0x08=rooms).
/// - Parameter config: The auto-add configuration (bitmask + max hops).
/// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond.
/// ``MeshCoreError/deviceError(code:)`` if the device returns an error.
public func setAutoAddConfig(_ config: UInt8) async throws {
public func setAutoAddConfig(_ config: AutoAddConfig) async throws {
try await sendAndWaitWithError(PacketBuilder.setAutoAddConfig(config)) { event in
if case .ok = event { return () }
return nil
Expand Down Expand Up @@ -1516,11 +1530,12 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// until `.noMoreMessages` is returned to drain the queue.
/// Use ``startAutoMessageFetching()`` to automate this process.
///
/// - Parameter timeout: Optional timeout override in seconds. Uses `configuration.defaultTimeout` when `nil`.
/// - Returns: A ``MessageResult`` containing either a contact message, channel message,
/// or indication that no more messages are waiting.
/// - Throws: ``MeshCoreError`` if the fetch fails.
public func getMessage() async throws -> MessageResult {
let timeoutSeconds = configuration.defaultTimeout
public func getMessage(timeout: TimeInterval? = nil) async throws -> MessageResult {
let timeoutSeconds = timeout ?? configuration.defaultTimeout

let stream = await dispatcher.subscribe { event in
switch event {
Expand Down Expand Up @@ -1800,6 +1815,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
let telemetryPayload = Data([0x00, 0x00, 0x00, 0x00])
let data = PacketBuilder.binaryRequest(to: publicKey, type: .telemetry, payload: telemetryPayload)
let publicKeyPrefix = Data(publicKey.prefix(6))
let prefixHex = publicKeyPrefix.map { String(format: "%02x", $0) }.joined()
let startTime = ContinuousClock.now

logger.info("Telemetry request to \(prefixHex): sending")

// Subscribe BEFORE sending to avoid race condition where binaryResponse
// arrives before we can register the pending request
Expand All @@ -1812,7 +1831,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
return try await withThrowingTaskGroup(of: TelemetryResponse?.self) { group in
let (timeoutStream, timeoutContinuation) = AsyncStream<TimeInterval>.makeStream()

group.addTask {
group.addTask { [logger] in
var expectedAck: Data?

for await event in events {
Expand All @@ -1823,7 +1842,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
// Capture the expectedAck from firmware's MSG_SENT response
expectedAck = info.expectedAck
// Signal dynamic timeout to timeout task
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0
logger.info("Telemetry request to \(prefixHex): messageSent received, suggestedTimeoutMs=\(info.suggestedTimeoutMs), effective timeout=\(String(format: "%.1f", timeout))s")
timeoutContinuation.yield(timeout)
timeoutContinuation.finish()

Expand All @@ -1838,10 +1858,14 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
responseData,
publicKeyPrefix: publicKeyPrefix
)
let elapsed = ContinuousClock.now - startTime
logger.info("Telemetry request to \(prefixHex): response received in \(elapsed)")
return response

case .telemetryResponse(let response):
// Handle already-routed response (if routing happens elsewhere)
let elapsed = ContinuousClock.now - startTime
logger.info("Telemetry request to \(prefixHex): routed response received in \(elapsed)")
return response

default:
Expand All @@ -1852,14 +1876,19 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
return nil
}

group.addTask { [clock = self.clock, defaultTimeout = configuration.defaultTimeout] in
group.addTask { [logger, clock = self.clock, defaultTimeout = configuration.defaultTimeout] in
// Wait for dynamic timeout from event task, or use default
var timeout = defaultTimeout
var usedFirmwareTimeout = false
for await t in timeoutStream {
timeout = t
usedFirmwareTimeout = true
break
}
logger.info("Telemetry request to \(prefixHex): timeout task sleeping for \(String(format: "%.1f", timeout))s (\(usedFirmwareTimeout ? "firmware" : "default"))")
try await clock.sleep(for: .seconds(timeout))
let elapsed = ContinuousClock.now - startTime
logger.warning("Telemetry request to \(prefixHex): timed out after \(elapsed)")
return nil
}

Expand Down Expand Up @@ -1928,7 +1957,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
case .messageSent(let info):
expectedAck = info.expectedAck
// Signal dynamic timeout to timeout task
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0
timeoutContinuation.yield(timeout)
timeoutContinuation.finish()

Expand Down Expand Up @@ -2004,7 +2033,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
case .messageSent(let info):
expectedAck = info.expectedAck
// Signal dynamic timeout to timeout task
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0
timeoutContinuation.yield(timeout)
timeoutContinuation.finish()

Expand Down Expand Up @@ -2112,7 +2141,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
case .messageSent(let info):
expectedAck = info.expectedAck
// Signal dynamic timeout to timeout task
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 1.2
let timeout = TimeInterval(info.suggestedTimeoutMs) / 1000.0 * 2.0
timeoutContinuation.yield(timeout)
timeoutContinuation.finish()

Expand Down
47 changes: 35 additions & 12 deletions MeshCore/Tests/MeshCoreTests/Protocol/V112ProtocolTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -194,23 +194,30 @@ struct V112ProtocolTests {

@Test("setAutoAddConfig packet builder")
func setAutoAddConfigPacketBuilder() {
let packet = PacketBuilder.setAutoAddConfig(0x0F)
let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0x0F))

#expect(packet == Data([0x3A, 0x0F]))
#expect(packet == Data([0x3A, 0x0F, 0x00]))
}

@Test("setAutoAddConfig all bits set")
func setAutoAddConfigAllBitsSet() {
let packet = PacketBuilder.setAutoAddConfig(0xFF)
let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0xFF))

#expect(packet == Data([0x3A, 0xFF]))
#expect(packet == Data([0x3A, 0xFF, 0x00]))
}

@Test("setAutoAddConfig zero bits")
func setAutoAddConfigZeroBits() {
let packet = PacketBuilder.setAutoAddConfig(0x00)
let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0x00))

#expect(packet == Data([0x3A, 0x00]))
#expect(packet == Data([0x3A, 0x00, 0x00]))
}

@Test("setAutoAddConfig with maxHops")
func setAutoAddConfigWithMaxHops() {
let packet = PacketBuilder.setAutoAddConfig(AutoAddConfig(bitmask: 0x0F, maxHops: 3))

#expect(packet == Data([0x3A, 0x0F, 0x03]))
}

// MARK: - Auto-Add Config Response Parser Tests
Expand All @@ -227,14 +234,29 @@ struct V112ProtocolTests {
#expect(ResponseCode.autoAddConfig.category == .device)
}

@Test("autoAddConfig parses valid payload")
func autoAddConfigParsesValidPayload() {
@Test("autoAddConfig parses single-byte payload with default maxHops")
func autoAddConfigParsesSingleBytePayload() {
let packet = Data([0x19, 0x0F])

let event = PacketParser.parse(packet)

if case .autoAddConfig(let config) = event {
#expect(config == 0x0F)
#expect(config.bitmask == 0x0F)
#expect(config.maxHops == 0)
} else {
Issue.record("Expected .autoAddConfig event, got \(event)")
}
}

@Test("autoAddConfig parses two-byte payload with maxHops")
func autoAddConfigParsesTwoBytePayload() {
let packet = Data([0x19, 0x0F, 0x05])

let event = PacketParser.parse(packet)

if case .autoAddConfig(let config) = event {
#expect(config.bitmask == 0x0F)
#expect(config.maxHops == 5)
} else {
Issue.record("Expected .autoAddConfig event, got \(event)")
}
Expand All @@ -253,14 +275,15 @@ struct V112ProtocolTests {
}
}

@Test("autoAddConfig ignores extra bytes")
@Test("autoAddConfig ignores extra bytes beyond maxHops")
func autoAddConfigIgnoresExtraBytes() {
let packet = Data([0x19, 0x0E, 0xFF, 0xFF])
let packet = Data([0x19, 0x0E, 0x03, 0xFF, 0xFF])

let event = PacketParser.parse(packet)

if case .autoAddConfig(let config) = event {
#expect(config == 0x0E)
#expect(config.bitmask == 0x0E)
#expect(config.maxHops == 3)
} else {
Issue.record("Expected .autoAddConfig event, got \(event)")
}
Expand Down
36 changes: 36 additions & 0 deletions MeshCore/Tests/MeshCoreTests/Session/GetMessageTimeoutTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,4 +15,40 @@ struct GetMessageTimeoutTests {
_ = try await session.getMessage()
}
}

@Test("getMessage timeout override can be shorter than the session default")
func getMessageRespectsShortTimeoutOverride() async {
let transport = MockTransport()
try? await transport.connect()

let configuration = SessionConfiguration(defaultTimeout: 0.2, clientIdentifier: "MeshCore-Tests")
let session = MeshCoreSession(transport: transport, configuration: configuration)
let clock = ContinuousClock()
let start = clock.now

await #expect(throws: MeshCoreError.self) {
_ = try await session.getMessage(timeout: 0.02)
}

let elapsed = start.duration(to: clock.now)
#expect(elapsed < .milliseconds(100))
}

@Test("getMessage timeout override can extend beyond the session default")
func getMessageRespectsLongTimeoutOverride() async {
let transport = MockTransport()
try? await transport.connect()

let configuration = SessionConfiguration(defaultTimeout: 0.02, clientIdentifier: "MeshCore-Tests")
let session = MeshCoreSession(transport: transport, configuration: configuration)
let clock = ContinuousClock()
let start = clock.now

await #expect(throws: MeshCoreError.self) {
_ = try await session.getMessage(timeout: 0.12)
}

let elapsed = start.duration(to: clock.now)
#expect(elapsed >= .milliseconds(80))
}
}
Loading
Loading