Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
4 changes: 4 additions & 0 deletions Meshtastic.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,7 @@
AA003DOC0000002000000003 /* DocPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA003DOC0000001000000003 /* DocPageView.swift */; };
AA003DOC0000002000000004 /* AIDocAssistantView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA003DOC0000001000000004 /* AIDocAssistantView.swift */; };
AA003DOC0000002000000005 /* DocBundleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA003DOC0000001000000005 /* DocBundleTests.swift */; };
DDF1A0022F12345600BEEF01 /* FindNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDF1A0012F12345600BEEF01 /* FindNodeButton.swift */; };
C54AC9F4DA920A4F718CE21A /* DiscoveryScanEngineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAD14FA280F0A130730DEB93 /* DiscoveryScanEngineTests.swift */; };
C79BFC99EDCC89DD45082753 /* DiscoveryScanView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5CA8BAC814CF82E127EEABF /* DiscoveryScanView.swift */; };
D02589957BD040969FCB8663 /* PositionEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = A97241903A924144A3EEB679 /* PositionEntity.swift */; };
Expand Down Expand Up @@ -1051,6 +1052,7 @@
DDDB26412AABF655003AFCB7 /* NodeListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeListItem.swift; sourceTree = "<group>"; };
DDDB26432AAC0206003AFCB7 /* NodeDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeDetail.swift; sourceTree = "<group>"; };
DDDB26452AACC0B7003AFCB7 /* NodeInfoItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoItem.swift; sourceTree = "<group>"; };
DDF1A0012F12345600BEEF01 /* FindNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FindNodeButton.swift; sourceTree = "<group>"; };
DDDB26492AAD743E003AFCB7 /* MeshtasticDataModelV18.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = MeshtasticDataModelV18.xcdatamodel; sourceTree = "<group>"; };
DDDB443F29F79AB000EE2349 /* UserDefaults.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserDefaults.swift; sourceTree = "<group>"; };
DDDB444129F8A88700EE2349 /* Double.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Double.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1492,6 +1494,7 @@
children = (
DD4074682F1233F400BCC22F /* ExchangeUserInfoButton.swift */,
DDDFE73E2D0D48FF0044463C /* IgnoreNodeButton.swift */,
DDF1A0012F12345600BEEF01 /* FindNodeButton.swift */,
BCCA0BAC2F1C5C60007648E5 /* RequestLocalStatsButton.swift */,
251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */,
251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */,
Expand Down Expand Up @@ -2746,6 +2749,7 @@
DDDB444C29F8AAA600EE2349 /* Color.swift in Sources */,
AA000001303050030000B001 /* Color+Brand.swift in Sources */,
DDDFE73F2D0D48FF0044463C /* IgnoreNodeButton.swift in Sources */,
DDF1A0022F12345600BEEF01 /* FindNodeButton.swift in Sources */,
BCCA0BAD2F1C5C60007648E5 /* RequestLocalStatsButton.swift in Sources */,
3D3417D42E2DC293006A988B /* MapDataFiles.swift in Sources */,
DDB8F4122A9EE5DD00230ECE /* UserList.swift in Sources */,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -174,15 +174,12 @@ extension AccessoryManager {
throw AccessoryError.connectionFailed("Firmware version not available")
}

let lastDotIndex = firmwareVersion.lastIndex(of: ".")
if lastDotIndex == nil {
guard let version = DeviceMetadataEntity.displayFirmwareVersion(from: firmwareVersion), version.contains(".") else {
throw AccessoryError.versionMismatch("🚨" + "Update Your Firmware".localized)
}

let version = firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: firmwareVersion))].dropLast()

// TODO: do we really need to store the firmware version in the UserDefaults?
UserDefaults.firmwareVersion = String(version)
UserDefaults.firmwareVersion = version

let supportedVersion = self.checkIsVersionSupported(forVersion: self.minimumVersion)
if !supportedVersion {
Expand Down Expand Up @@ -228,14 +225,7 @@ extension AccessoryManager {
}

if let device = self.activeConnection?.device {
var version: String?
if let firmwareVersion = device.firmwareVersion {
if let lastDotIndex = firmwareVersion.lastIndex(of: ".") {
version = String(firmwareVersion[...(lastDotIndex)].dropLast())
} else {
version = firmwareVersion
}
}
let version = device.firmwareVersion.flatMap(DeviceMetadataEntity.displayFirmwareVersion)

let connectionWasRestored = (withConnection != nil)
Logger.datadog.action(.connect(firmwareVersion: version,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -212,14 +212,10 @@ extension AccessoryManager {
// Local config parses out the variants. Should we do that here maybe?
await MeshPackets.shared.localConfig(config: config, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName)

// Handle Timezone
if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) {
var dc = config.device
if dc.tzdef.isEmpty {
dc.tzdef = TimeZone.current.posixDescription
Task {
try? await saveTimeZone(config: dc, user: deviceNum)
}
let deviceConfig = config.device
if deviceConfig.tzdef.isEmpty {
Logger.admin.info("Skipping automatic timezone config write during connect sync for node \(deviceNum.toHex(), privacy: .public)")
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,28 +86,6 @@ extension AccessoryManager {
}
}

public func saveTimeZone(config: Config.DeviceConfig, user: Int64) async throws -> Int64 {
var adminPacket = AdminMessage()
adminPacket.setConfig.device = config
var meshPacket: MeshPacket = MeshPacket()
meshPacket.to = UInt32(user)
meshPacket.from = UInt32(user)
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.priority = MeshPacket.Priority.reliable
meshPacket.wantAck = true
var dataMessage = DataMessage()
guard let adminData: Data = try? adminPacket.serializedData() else {
throw AccessoryError.ioFailed("saveTimeZone: Unable to serialize Admin packet")
}
dataMessage.payload = adminData
dataMessage.portnum = PortNum.adminApp
meshPacket.decoded = dataMessage

let messageDescription = "⌚ Device Config timezone was empty set timezone to \(config.tzdef)"
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
return Int64(meshPacket.id)
}

// Send an admin message to a radio, save a message to core data for logging
private func sendAdminMessageToRadio(meshPacket: MeshPacket, adminDescription: String?) async throws {

Expand Down Expand Up @@ -270,6 +248,39 @@ extension AccessoryManager {
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
}

public func sendFindNodeRequest(fromUser: UserEntity, toUser: UserEntity, durationSeconds: UInt32 = 0, stop: Bool = false) async throws -> Int64 {
var request = AdminMessage.FindNodeRequest()
request.durationSeconds = min(durationSeconds, 300)
request.stop = stop

var adminPacket = AdminMessage()
adminPacket.findNodeRequest = request
if fromUser != toUser {
adminPacket.sessionPasskey = toUser.userNode?.sessionPasskey ?? Data()
}

var meshPacket = MeshPacket()
meshPacket.to = UInt32(toUser.num)
meshPacket.from = UInt32(fromUser.num)
meshPacket.id = UInt32.random(in: UInt32(UInt8.max)..<UInt32.max)
meshPacket.priority = MeshPacket.Priority.reliable
meshPacket.wantAck = true

var dataMessage = DataMessage()
guard let serializedData = try? adminPacket.serializedData() else {
throw AccessoryError.ioFailed("sendFindNodeRequest: Unable to serialize Admin packet")
}
dataMessage.payload = serializedData
dataMessage.portnum = PortNum.adminApp
dataMessage.wantResponse = true
meshPacket.decoded = dataMessage

let action = stop ? "Stop Find Node" : "Find Node"
let messageDescription = "🔊 Sent \(action) Admin Message to: \(toUser.longName ?? "Unknown".localized) from: \(fromUser.longName ?? "Unknown".localized)"
try await sendAdminMessageToRadio(meshPacket: meshPacket, adminDescription: messageDescription)
return Int64(meshPacket.id)
}

public func sendMessage(message: String, toUserNum: Int64, channel: Int32, isEmoji: Bool, replyID: Int64) async throws {
guard let fromUserNum = self.activeConnection?.device.num else {
Logger.services.error("Error while sending CannedMessageModule request. No active device.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,46 @@ actor BLETransport: Transport {
cleanupTask = nil
}

private func waitForPoweredOnCentral() async throws {
guard centralManager != nil else {
throw AccessoryError.connectionFailed("Bluetooth not initialized")
}

if centralManager.state == .poweredOn {
return
}

Logger.transport.info("🛜 [BLE] Waiting for poweredOn before connecting. Current state: \(cbManagerStateDescription(self.centralManager.state), privacy: .public)")
await setupCompleteGate.reset()

// Avoid missing a racing state transition between the state check and gate reset.
if centralManager.state == .poweredOn {
await setupCompleteGate.open()
return
}

try await setupCompleteGate.wait()

guard centralManager.state == .poweredOn else {
throw AccessoryError.connectionFailed("Bluetooth is not ready: \(cbManagerStateDescription(centralManager.state))")
}
}

private func peripheral(for device: Device) throws -> CBPeripheral {
guard let id = UUID(uuidString: device.identifier) else {
throw AccessoryError.connectionFailed("Invalid BLE peripheral identifier")
}
if let discovered = discoveredPeripherals[id]?.peripheral {
return discovered
}
if let retrieved = centralManager.retrievePeripherals(withIdentifiers: [id]).first {
Logger.transport.info("🛜 [BLE] Retrieved cached peripheral for reconnect: \(retrieved.name ?? device.name, privacy: .public) (\(id, privacy: .public))")
discoveredPeripherals[id] = (peripheral: retrieved, lastSeen: Date())
return retrieved
}
throw AccessoryError.connectionFailed("Peripheral not found")
}

func handleCentralState(_ state: CBManagerState, central: CBCentralManager) {
Logger.transport.error("🛜 [BLE] State has transitioned to: \(cbManagerStateDescription(state), privacy: .public)")
switch state {
Expand Down Expand Up @@ -189,11 +229,11 @@ actor BLETransport: Transport {

case .resetting:
status = .error("Bluetooth is resetting")
// Perhaps don't finish, wait for next state
Task { await setupCompleteGate.reset() }

case .unknown:
status = .error("Bluetooth state is unknown")
// Perhaps wait
Task { await setupCompleteGate.reset() }
@unknown default:
status = .error("Unknown Bluetooth state")
Task { await self.setupCompleteGate.throwAll(AccessoryError.connectionFailed("Unknown Bluetooth State"))}
Expand Down Expand Up @@ -231,10 +271,9 @@ actor BLETransport: Transport {
}

func connect(to device: Device) async throws -> any Connection {
guard let peripheral = discoveredPeripherals[UUID(uuidString: device.identifier)!] else {
throw AccessoryError.connectionFailed("Peripheral not found")
}

try await waitForPoweredOnCentral()
let peripheral = try peripheral(for: device)

do {
if await self.activeConnection?.peripheral.state == .disconnected {
Logger.transport.error("🛜 [BLE] Connect request while an active (but disconnected)")
Expand All @@ -248,24 +287,32 @@ actor BLETransport: Transport {
return
}
self.connectContinuation = cont
self.connectingPeripheral = peripheral.peripheral
self.connectingPeripheral = peripheral
guard centralManager != nil else {
self.connectContinuation = nil
self.connectingPeripheral = nil
cont.resume(throwing: AccessoryError.connectionFailed("Bluetooth not initialized"))
return
}
centralManager.connect(peripheral.peripheral)
guard centralManager.state == .poweredOn else {
self.connectContinuation = nil
self.connectingPeripheral = nil
cont.resume(throwing: AccessoryError.connectionFailed("Bluetooth is not ready: \(cbManagerStateDescription(centralManager.state))"))
return
}
centralManager.connect(peripheral)
}
self.activeConnection = newConnection
return newConnection
} onCancel: {
Task {
await self.cancelConnectContinuation(for: peripheral.peripheral)
await self.cancelConnectContinuation(for: peripheral)
}
}
Logger.transport.debug("🛜 [BLE] Connect complete.")
return returnConnection
} catch {
connectionDidDisconnect(fromPeripheral: peripheral.peripheral)
connectionDidDisconnect(fromPeripheral: peripheral)
throw error
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,10 @@ extension DeviceMetadataEntity {
self.canShutdown = metadata.canShutdown
self.hasWifi = metadata.hasWifi_p
self.hasBluetooth = metadata.hasBluetooth_p
self.hasBuzzer = metadata.hasBuzzer_p
self.hasEthernet = metadata.hasEthernet_p
self.role = Int32(metadata.role.rawValue)
self.positionFlags = Int32(metadata.positionFlags)
// Swift does strings weird, this does work to get the version without the github hash
let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".")
var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))]
version = version.dropLast()
self.firmwareVersion = String(version)
self.firmwareVersion = Self.displayFirmwareVersion(from: metadata.firmwareVersion)
}
}
45 changes: 40 additions & 5 deletions Meshtastic/Helpers/MeshPackets.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,35 @@ import OSLog
import ActivityKit
#endif

struct FindNodeResponseEvent: Sendable {
enum Result: Sendable {
case started
case stopped
case noBuzzer
case buzzerDisabled
case unrecognized(Int)

init(_ protoResult: AdminMessage.FindNodeResponse.Result) {
switch protoResult {
case .started:
self = .started
case .stopped:
self = .stopped
case .noBuzzer:
self = .noBuzzer
case .buzzerDisabled:
self = .buzzerDisabled
case .UNRECOGNIZED(let value):
self = .unrecognized(value)
}
}
}

let nodeNum: Int64
let result: Result
let durationSeconds: UInt32
}

// Simple extension to concisely pass values through a has_XXX boolean check
fileprivate extension Bool {
func then<T>(_ value: T) -> T? {
Expand Down Expand Up @@ -372,15 +401,12 @@ actor MeshPackets {
newMetadata.canShutdown = metadata.canShutdown
newMetadata.hasWifi = metadata.hasWifi_p
newMetadata.hasBluetooth = metadata.hasBluetooth_p
newMetadata.hasBuzzer = metadata.hasBuzzer_p
newMetadata.hasEthernet = metadata.hasEthernet_p
newMetadata.role = Int32(metadata.role.rawValue)
newMetadata.positionFlags = Int32(metadata.positionFlags)
newMetadata.excludedModules = Int32(metadata.excludedModules)
// Swift does strings weird, this does work to get the version without the github hash
let lastDotIndex = metadata.firmwareVersion.lastIndex(of: ".")
var version = metadata.firmwareVersion[...(lastDotIndex ?? String.Index(utf16Offset: 6, in: metadata.firmwareVersion))]
version = version.dropLast()
newMetadata.firmwareVersion = String(version)
newMetadata.firmwareVersion = DeviceMetadataEntity.displayFirmwareVersion(from: metadata.firmwareVersion)
if fetchedNode.count > 0 {
fetchedNode[0].metadata = newMetadata
if sessionPasskey?.count != 0 {
Expand Down Expand Up @@ -727,6 +753,15 @@ actor MeshPackets {
if let rt = try? RTTTLConfig(serializedBytes: packet.decoded.payload) {
self.upsertRtttlConfigPacket(ringtone: rt.ringtone, nodeNum: Int64(packet.from))
}
} else if adminMessage.payloadVariant == AdminMessage.OneOf_PayloadVariant.findNodeResponse(adminMessage.findNodeResponse) {
let response = adminMessage.findNodeResponse
let event = FindNodeResponseEvent(
nodeNum: Int64(packet.from),
result: FindNodeResponseEvent.Result(response.result),
durationSeconds: response.durationSeconds
)
NotificationCenter.default.post(name: .findNodeResponseDidChange, object: event)
Logger.admin.info("🔊 Find Node response from \(packet.from.toHex(), privacy: .public): \(String(describing: response.result), privacy: .public)")
} else {
Logger.admin.error("🕸️ MESH PACKET received Admin App UNHANDLED \((try? packet.decoded.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
}
Expand Down
15 changes: 15 additions & 0 deletions Meshtastic/Model/DeviceMetadataEntity.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class DeviceMetadataEntity {
var excludedModules: Int32 = 0
var firmwareVersion: String?
var hasBluetooth: Bool = false
var hasBuzzer: Bool = false
var hasEthernet: Bool = false
var hasWifi: Bool = false
var hwModel: String?
Expand All @@ -25,4 +26,18 @@ final class DeviceMetadataEntity {
var metadataNode: NodeInfoEntity?

init() {}

static func displayFirmwareVersion(from rawVersion: String) -> String? {
let version = rawVersion.trimmingCharacters(in: .whitespacesAndNewlines)
guard !version.isEmpty else {
return nil
}

let components = version.split(separator: ".", omittingEmptySubsequences: false)
guard components.count > 3 else {
return version
}

return components.dropLast().joined(separator: ".")
}
}
1 change: 1 addition & 0 deletions Meshtastic/Persistence/NodeBackupManager+Import.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ extension NodeBackupManager {
dst.excludedModules = src.excludedModules
dst.firmwareVersion = src.firmwareVersion
dst.hasBluetooth = src.hasBluetooth
dst.hasBuzzer = src.hasBuzzer
dst.hasEthernet = src.hasEthernet
dst.hasWifi = src.hasWifi
dst.hwModel = src.hwModel
Expand Down
1 change: 1 addition & 0 deletions Meshtastic/Persistence/PerformanceSeedData.swift
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ enum PerformanceSeedData {
metadata.hwModel = user.hwModel
metadata.firmwareVersion = "2.7.\(index % 10)"
metadata.hasBluetooth = true
metadata.hasBuzzer = index.isMultiple(of: 11)
metadata.hasWifi = index.isMultiple(of: 5)
metadata.role = user.role
metadata.time = node.lastHeard
Expand Down
Loading