diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 62726645bb..a1469bb2db 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -98129,6 +98129,12 @@ } } }, + "Default (812.5 kHz)": { + "comment": "Label for the protobuf-default bandwidth in the 2.4 GHz LoRa region." + }, + "This bandwidth is not supported by the connected radio in the selected region. Choose a supported value before saving.": { + "comment": "Validation message shown when the configured LoRa bandwidth is incompatible with the selected region or radio." + }, "This connected device does not support packet signature verification.": { "comment": "Summary shown when the connected radio lacks XEdDSA packet signature verification." }, @@ -113613,4 +113619,4 @@ "your node's private key & admin keys": {} }, "version": "1.1" -} \ No newline at end of file +} diff --git a/Meshtastic/Enums/LoraConfigEnums.swift b/Meshtastic/Enums/LoraConfigEnums.swift index 9b21f71880..fd1f016b81 100644 --- a/Meshtastic/Enums/LoraConfigEnums.swift +++ b/Meshtastic/Enums/LoraConfigEnums.swift @@ -771,14 +771,46 @@ enum CodingRates { } enum Bandwidths: Int, CaseIterable, Identifiable { + enum ValidationIssue: Equatable { + case unsupported + } case thirtyOne = 31 case sixtyTwo = 62 case oneHundredTwentyFive = 125 + case twoHundred = 200 case twoHundredFifty = 250 + case fourHundred = 400 case fiveHundred = 500 + case eightHundred = 800 + case sixteenHundred = 1600 + + private static let subGHzCases: [Bandwidths] = [ + .thirtyOne, .sixtyTwo, .oneHundredTwentyFive, .twoHundredFifty, .fiveHundred + ] + + private static let highBandCases: [Bandwidths] = [ + .twoHundred, .fourHundred, .eightHundred + ] + + /// Targets whose firmware definitions select SX128x exclusively. Targets that can be built + /// with more than one radio, plus unknown future targets, use the conservative high-band set. + private static let sx128xTargets: Set = [ + "betafpv_2400_tx_micro", + "makerpython_nrf52840_sx1280_eink", + "makerpython_nrf52840_sx1280_oled", + "my-esp32s3-diy-eink", + "my-esp32s3-diy-oled", + "tlora-v2-1-1_8" + ] var id: Int { self.rawValue } + + /// The protobuf uses zero for the firmware-selected regional default. + var pickerValue: Int { + self == .twoHundredFifty ? 0 : rawValue + } + var description: String { switch self { case .thirtyOne: @@ -787,11 +819,55 @@ enum Bandwidths: Int, CaseIterable, Identifiable { return "62 kHz" case .oneHundredTwentyFive: return "125 kHz" + case .twoHundred: + return "203.125 kHz" case .twoHundredFifty: return "250 kHz" + case .fourHundred: + return "406.25 kHz" case .fiveHundred: return "500 kHz" + case .eightHundred: + return "812.5 kHz" + case .sixteenHundred: + return "1625 kHz" + } + } + + static func selectable(region: RegionCodes?, pioEnv: String?) -> [Bandwidths] { + guard region == .lora24 else { return subGHzCases } + + var options = highBandCases + if let pioEnv, sx128xTargets.contains(pioEnv.lowercased()) { + options.append(.sixteenHundred) + } + return options + } + + static func isValid(_ value: Int, region: RegionCodes?, pioEnv: String?) -> Bool { + validationIssue(for: value, region: region, pioEnv: pioEnv) == nil + } + + static func validationIssue(for value: Int, region: RegionCodes?, pioEnv: String?) -> ValidationIssue? { + if value == 0 { return nil } + let normalizedValue = pickerValue(forStoredValue: value, region: region) + return selectable(region: region, pioEnv: pioEnv).contains { $0.pickerValue == normalizedValue } + ? nil + : .unsupported + } + + static func pickerValue(forStoredValue value: Int, region: RegionCodes?) -> Int { + if region != .lora24, value == Bandwidths.twoHundredFifty.rawValue { + return Bandwidths.twoHundredFifty.pickerValue + } + return value + } + + static func description(forPickerValue value: Int, region: RegionCodes?) -> String { + if value == 0 { + return region == .lora24 ? "Default (812.5 kHz)" : Bandwidths.twoHundredFifty.description } + return Bandwidths(rawValue: value)?.description ?? "\(value) kHz" } } diff --git a/Meshtastic/Resources/docs/index.json b/Meshtastic/Resources/docs/index.json index 6ccb0c68ae..1f7de741ff 100644 --- a/Meshtastic/Resources/docs/index.json +++ b/Meshtastic/Resources/docs/index.json @@ -403,18 +403,18 @@ "from", "firmware", "position", + "connected", "traffic", + "region", "mesh", - "connected", "tag", "nodes", "nfc", - "region", "configuration", "only", "language", - "messages", "default", + "messages", "contact", "channels", "channel", @@ -426,7 +426,7 @@ "packets", "packet" ], - "charCount": 15276 + "charCount": 15667 }, { "id": "signal-meter", diff --git a/Meshtastic/Resources/docs/markdown/user/settings.md b/Meshtastic/Resources/docs/markdown/user/settings.md index f188c5f766..22b06ec794 100644 --- a/Meshtastic/Resources/docs/markdown/user/settings.md +++ b/Meshtastic/Resources/docs/markdown/user/settings.md @@ -34,6 +34,7 @@ LoRa settings control how your radio communicates on the mesh: |---------|-------------| | Region | Your geographical region. **Must be set correctly** — using the wrong region is illegal and prevents communication with local nodes. The standard regions are always available; the amateur (ham) 2m / 70cm / 1.25m bands and the EU 866 / narrow bands require firmware **2.8.0 or later** and only appear when your connected radio supports them. | | Modem Preset | Speed/range trade-off. Most users should use Long Fast or Long Slow. On firmware 2.8+, the preset list is filtered to those that are legal for the selected region (see below). | +| Bandwidth | Available under **Advanced** when **Use Preset** is off. The protobuf default value of 0 is shown as its effective firmware value: 250 kHz in sub-GHz regions and 812.5 kHz in the 2.4 GHz region. Choices are filtered for the selected region and connected radio. If a stored nonzero bandwidth is unsupported, the app shows a warning and prevents saving until you select a supported value. | | Hop Limit | The number of times a message is repeated by other nodes. Higher values increase range but also mesh traffic. | | Frequency Slot | Fine-tune the exact frequency within your region. | diff --git a/Meshtastic/Resources/docs/user/settings.html b/Meshtastic/Resources/docs/user/settings.html index c922384c43..f7259f994c 100644 --- a/Meshtastic/Resources/docs/user/settings.html +++ b/Meshtastic/Resources/docs/user/settings.html @@ -40,6 +40,10 @@

LoRa

Speed/range trade-off. Most users should use Long Fast or Long Slow. On firmware 2.8+, the preset list is filtered to those that are legal for the selected region (see below). +Bandwidth +Available under Advanced when Use Preset is off. The protobuf default value of 0 is shown as its effective firmware value: 250 kHz in sub-GHz regions and 812.5 kHz in the 2.4 GHz region. Choices are filtered for the selected region and connected radio. If a stored nonzero bandwidth is unsupported, the app shows a warning and prevents saving until you select a supported value. + + Hop Limit The number of times a message is repeated by other nodes. Higher values increase range but also mesh traffic. diff --git a/Meshtastic/Views/Settings/Config/LoRaConfig.swift b/Meshtastic/Views/Settings/Config/LoRaConfig.swift index e6fe413741..4035bf81f8 100644 --- a/Meshtastic/Views/Settings/Config/LoRaConfig.swift +++ b/Meshtastic/Views/Settings/Config/LoRaConfig.swift @@ -143,6 +143,34 @@ struct LoRaConfig: View { return presets } + private var availableBandwidths: [Bandwidths] { + Bandwidths.selectable( + region: RegionCodes(rawValue: region), + pioEnv: node?.myInfo?.pioEnv + ) + } + + private var customBandwidthValidationIssue: Bandwidths.ValidationIssue? { + guard !usePreset else { return nil } + return Bandwidths.validationIssue( + for: bandwidth, + region: RegionCodes(rawValue: region), + pioEnv: node?.myInfo?.pioEnv + ) + } + + private var bandwidthSelection: Binding { + Binding( + get: { + Bandwidths.pickerValue( + forStoredValue: bandwidth, + region: RegionCodes(rawValue: region) + ) + }, + set: { bandwidth = $0 } + ) + } + var body: some View { Form { ConfigHeader(title: "LoRa", config: \.loRaConfig, node: node, onAppear: setLoRaValues) @@ -217,23 +245,21 @@ struct LoRaConfig: View { Label("Transmit Enabled", systemImage: "waveform.path") } - if !usePreset { - HStack { - Picker("Bandwidth", selection: $bandwidth) { - ForEach(Bandwidths.allCases) { bw in - Text(bw.description) - .tag(bw.rawValue == 250 ? 0 : bw.rawValue) - } - } - } - HStack { - Picker("Spread Factor", selection: $spreadFactor) { - ForEach(7..<13) { - Text("\($0)") - .tag($0 == 12 ? 0 : $0) - } - } - } + if !usePreset { + CustomBandwidthPicker( + selection: bandwidthSelection, + options: availableBandwidths, + region: RegionCodes(rawValue: region), + validationIssue: customBandwidthValidationIssue + ) + HStack { + Picker("Spread Factor", selection: $spreadFactor) { + ForEach(7..<13) { + Text("\($0)") + .tag($0 == 12 ? 0 : $0) + } + } + } } VStack(alignment: .leading, spacing: 8) { @@ -339,37 +365,39 @@ struct LoRaConfig: View { .disabled(!accessoryManager.isConnected || node?.loRaConfig == nil) .safeAreaInset(edge: .bottom, alignment: .center) { HStack(spacing: 0) { - SaveConfigButton(node: node, hasChanges: $hasChanges) { - performConfigSave( - node: node, - context: context, - accessoryManager: accessoryManager, - hasChanges: $hasChanges, - dismiss: goBack - ) { fromUser, toUser in - var lc = Config.LoRaConfig() - lc.hopLimit = UInt32(hopLimit) - lc.region = RegionCodes(rawValue: region)!.protoEnumValue() - lc.modemPreset = ModemPresets(rawValue: modemPreset)!.protoEnumValue() - lc.usePreset = usePreset - lc.txEnabled = txEnabled - lc.txPower = Int32(txPower) - lc.channelNum = UInt32(channelNum) - lc.bandwidth = UInt32(bandwidth) - lc.codingRate = UInt32(normalizedCodingRate) - lc.spreadFactor = UInt32(spreadFactor) - lc.sx126XRxBoostedGain = rxBoostedGain - lc.overrideFrequency = overrideFrequency - lc.ignoreMqtt = ignoreMqtt - lc.configOkToMqtt = okToMqtt - if let deviceNum = accessoryManager.activeDeviceNum, - let connectedNode = getNodeInfo(id: deviceNum, context: context), - connectedNode.num == node?.user?.num ?? 0 { - UserDefaults.modemPreset = modemPreset + SaveConfigButton(node: node, hasChanges: $hasChanges) { + guard customBandwidthValidationIssue == nil else { return } + performConfigSave( + node: node, + context: context, + accessoryManager: accessoryManager, + hasChanges: $hasChanges, + dismiss: goBack + ) { fromUser, toUser in + var lc = Config.LoRaConfig() + lc.hopLimit = UInt32(hopLimit) + lc.region = RegionCodes(rawValue: region)!.protoEnumValue() + lc.modemPreset = ModemPresets(rawValue: modemPreset)!.protoEnumValue() + lc.usePreset = usePreset + lc.txEnabled = txEnabled + lc.txPower = Int32(txPower) + lc.channelNum = UInt32(channelNum) + lc.bandwidth = UInt32(bandwidth) + lc.codingRate = UInt32(normalizedCodingRate) + lc.spreadFactor = UInt32(spreadFactor) + lc.sx126XRxBoostedGain = rxBoostedGain + lc.overrideFrequency = overrideFrequency + lc.ignoreMqtt = ignoreMqtt + lc.configOkToMqtt = okToMqtt + if let deviceNum = accessoryManager.activeDeviceNum, + let connectedNode = getNodeInfo(id: deviceNum, context: context), + connectedNode.num == node?.user?.num ?? 0 { + UserDefaults.modemPreset = modemPreset + } + _ = try await accessoryManager.saveLoRaConfig(config: lc, fromUser: fromUser, toUser: toUser) } - _ = try await accessoryManager.saveLoRaConfig(config: lc, fromUser: fromUser, toUser: toUser) } - } + .disabled(customBandwidthValidationIssue != nil) } } .navigationTitle("LoRa Config") @@ -487,6 +515,43 @@ struct LoRaConfig: View { } } +private struct CustomBandwidthPicker: View { + @Binding var selection: Int + let options: [Bandwidths] + let region: RegionCodes? + let validationIssue: Bandwidths.ValidationIssue? + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Picker("Bandwidth", selection: $selection) { + if validationIssue == .unsupported { + Text("Unsupported (\(Bandwidths.description(forPickerValue: selection, region: region)))") + .tag(selection) + } + if region == .lora24 { + Text(Bandwidths.description(forPickerValue: 0, region: region).localized) + .tag(0) + } + ForEach(options) { bandwidth in + Text(bandwidth.description) + .tag(bandwidth.pickerValue) + } + } + if let validationIssue { + Label { + if validationIssue == .unsupported { + Text("This bandwidth is not supported by the connected radio in the selected region. Choose a supported value before saving.".localized) + } + } icon: { + Image(systemName: "exclamationmark.triangle.fill") + } + .foregroundStyle(.orange) + .font(.callout) + } + } + } +} + #Preview { LoRaConfig(node: nil) .environmentObject(AccessoryManager.shared) diff --git a/MeshtasticTests/LoraDeviceEnumTests.swift b/MeshtasticTests/LoraDeviceEnumTests.swift index a5b20b5d9f..6521da5cc6 100644 --- a/MeshtasticTests/LoraDeviceEnumTests.swift +++ b/MeshtasticTests/LoraDeviceEnumTests.swift @@ -173,7 +173,64 @@ struct BandwidthsTests { } @Test func totalCaseCount() { - #expect(Bandwidths.allCases.count == 5) + #expect(Bandwidths.allCases.count == 9) + } + + @Test func subGHzOptions_preserveExistingWireValues() { + let options = Bandwidths.selectable(region: .us, pioEnv: nil) + + #expect(options.map(\.pickerValue) == [31, 62, 125, 0, 500]) + } + + @Test func unknownRegion_fallsBackToSubGHzOptions() { + let options = Bandwidths.selectable(region: nil, pioEnv: nil) + + #expect(options.map(\.pickerValue) == [31, 62, 125, 0, 500]) + } + + @Test func sx128xOptions_includeAllCanonicalHighBandValues() { + let options = Bandwidths.selectable(region: .lora24, pioEnv: "tlora-v2-1-1_8") + + #expect(options.map(\.pickerValue) == [200, 400, 800, 1600]) + #expect(options.map(\.description) == ["203.125 kHz", "406.25 kHz", "812.5 kHz", "1625 kHz"]) + } + + @Test(arguments: ["my-esp32s3-diy-oled", "my-esp32s3-diy-eink"]) + func diySX128xOptions_include1625kHzBandwidth(pioEnv: String) { + let options = Bandwidths.selectable(region: .lora24, pioEnv: pioEnv) + + #expect(options.map(\.pickerValue) == [200, 400, 800, 1600]) + } + + @Test func lr1121Options_excludeUnsupported1625kHzBandwidth() { + let options = Bandwidths.selectable(region: .lora24, pioEnv: "muzi-base") + + #expect(options.map(\.pickerValue) == [200, 400, 800]) + } + + @Test func mixedSX128xLR1121Target_usesConservativeOptions() { + let options = Bandwidths.selectable(region: .lora24, pioEnv: "tlora-t3s3-v1") + + #expect(options.map(\.pickerValue) == [200, 400, 800]) + } + + @Test func invalidStoredBandwidth_mustBeReplacedBeforeSaving() { + #expect(!Bandwidths.isValid(125, region: .lora24, pioEnv: "tlora-v2-1-1_8")) + #expect(!Bandwidths.isValid(1600, region: .lora24, pioEnv: "muzi-base")) + #expect(Bandwidths.isValid(800, region: .lora24, pioEnv: "muzi-base")) + #expect(Bandwidths.isValid(0, region: .us, pioEnv: nil)) + } + + @Test func zeroBandwidthInLora24_usesFirmwareDefault() { + #expect(Bandwidths.validationIssue(for: 0, region: .lora24, pioEnv: "tlora-t3s3-v1") == nil) + #expect(Bandwidths.isValid(0, region: .lora24, pioEnv: "tlora-t3s3-v1")) + #expect(Bandwidths.description(forPickerValue: 0, region: .lora24) == "Default (812.5 kHz)") + } + + @Test func explicit250kHzStoredValue_remainsValidAndDisplaysAsDefaultOption() { + #expect(Bandwidths.isValid(250, region: .us, pioEnv: nil)) + #expect(Bandwidths.pickerValue(forStoredValue: 250, region: .us) == 0) + #expect(Bandwidths.pickerValue(forStoredValue: 250, region: .lora24) == 250) } } diff --git a/MeshtasticTests/MeshPacketsAndTelemetryTests.swift b/MeshtasticTests/MeshPacketsAndTelemetryTests.swift index 9dbf1f7d60..391d4ba488 100644 --- a/MeshtasticTests/MeshPacketsAndTelemetryTests.swift +++ b/MeshtasticTests/MeshPacketsAndTelemetryTests.swift @@ -69,6 +69,44 @@ struct GenerateMessageMarkdownTests { } } +// MARK: - MyInfo ingestion + +@Suite("MyInfo ingestion", .serialized) +@MainActor +struct MyInfoIngestionTests { + @Test func sameNodeReflash_updatesPioEnvAndBandwidthCapability() async throws { + let container = try ModelContainer( + for: Schema(MeshtasticSchema.allModels), + configurations: ModelConfiguration(isStoredInMemoryOnly: true) + ) + let mesh = MeshPackets(modelContainer: container) + let nodeNum: UInt32 = 0x1234_5678 + + var beforeReflash = MyNodeInfo() + beforeReflash.myNodeNum = nodeNum + beforeReflash.pioEnv = "muzi-base" + let originalID = await mesh.myInfoPacket(myInfo: beforeReflash, peripheralId: "test-peripheral") + + var afterReflash = MyNodeInfo() + afterReflash.myNodeNum = nodeNum + afterReflash.pioEnv = "my-esp32s3-diy-oled" + let updatedID = await mesh.myInfoPacket(myInfo: afterReflash, peripheralId: "test-peripheral") + + let context = ModelContext(container) + let persistedNodeNum = Int64(nodeNum) + let rows = try context.fetch( + FetchDescriptor(predicate: #Predicate { $0.myNodeNum == persistedNodeNum }) + ) + let myInfo = try #require(rows.first) + + #expect(rows.count == 1) + #expect(originalID == updatedID) + #expect(myInfo.myNodeNum == persistedNodeNum) + #expect(myInfo.pioEnv == "my-esp32s3-diy-oled") + #expect(Bandwidths.selectable(region: .lora24, pioEnv: myInfo.pioEnv).contains(.sixteenHundred)) + } +} + // MARK: - Local Message Notification Cleanup @Suite("Local message notification cleanup") diff --git a/docs/user/settings.md b/docs/user/settings.md index f188c5f766..22b06ec794 100644 --- a/docs/user/settings.md +++ b/docs/user/settings.md @@ -34,6 +34,7 @@ LoRa settings control how your radio communicates on the mesh: |---------|-------------| | Region | Your geographical region. **Must be set correctly** — using the wrong region is illegal and prevents communication with local nodes. The standard regions are always available; the amateur (ham) 2m / 70cm / 1.25m bands and the EU 866 / narrow bands require firmware **2.8.0 or later** and only appear when your connected radio supports them. | | Modem Preset | Speed/range trade-off. Most users should use Long Fast or Long Slow. On firmware 2.8+, the preset list is filtered to those that are legal for the selected region (see below). | +| Bandwidth | Available under **Advanced** when **Use Preset** is off. The protobuf default value of 0 is shown as its effective firmware value: 250 kHz in sub-GHz regions and 812.5 kHz in the 2.4 GHz region. Choices are filtered for the selected region and connected radio. If a stored nonzero bandwidth is unsupported, the app shows a warning and prevents saving until you select a supported value. | | Hop Limit | The number of times a message is repeated by other nodes. Higher values increase range but also mesh traffic. | | Frequency Slot | Fine-tune the exact frequency within your region. |