mirror of
https://github.com/meshtastic/Meshtastic-Apple.git
synced 2026-04-20 22:13:56 +00:00
Transports Interface to Support TCP for all Platforms and Serial on Mac (#1341)
* Initial implementation of transports * Initial LogRadio implementation * Fixes for Settings view (caused by debug commenting) * Refinement of the object and actor model * Connect view text and tab updates * Fix mac catalyst and tests * Warning and logging clean-up * In progress commit * Serial Transport and Reconnect draft work * Serial transport and reconnection draft work * Quick fix for BLE - still more work to do * interim commit * More in progress changes * Minor improvements * Pretty good initial implementation * Bump version beyond the app store * Fix for disconnection swipeAction * Tweaks to TCPConnection implementation * Retry for NONCE_ONLY_DB * Revert json string change * Simplified some of the API + "Anti-discovery" * Tweaks for devices leaving the discovery process * Bump version * iOS26 Tweaks * Tweaks and bug fixes * Add link with slash sf symbol * update symbol image on connect view * BLE disconnect handling * Log privacy attributes * Onboarding and minor fixes. * change database to nodes, add emoji to tcp logs * Error handling improvements * More logging emojis * Suppressed unnecessary errors on disconnect * Heartbeat emoji * Add bluetooth symbol * add privacy attributes to [TCP] logs, add custom bluetooth logo * Improve routing logs * Emoji for connect logs * Heartbeat emoji * Add CBCentralManagerScanOptionAllowDuplicatesKey options to central for bluetooth * fix nav errors by switching from observableobject to state * Update connection indicator icon * fix for BLE disconnects * Connection process fixes * More fixes/tweaks to connection process * Strict concurrency * Fix some warnings, remove wifi warning * delete stale keys * interim commit * Update privacy for log, fix wrong space * fix a couple of linting items * Switch to targeted * interim commit * BLE Signal strenth on connect view * Remove BLE RSSI from long press menu * Modem lights * minor spacing tweak * Additional BLE logging and a scanning fix. * Discovery and BLE RSSI improvements * Background suspension * Update isConnected to enable UI during db load * update protobufs * Replace config if statements with switches, Fix unknown module config logging, make dark mode modem circle stroke color white so they are visible * Additional logging cleanup * hast * Set unmessagable to true if the longname has the unmessagable emoji * Connect error handling improvements * Admin popup list icon and activity lights updates * Revert use of .toolbar back to .navigationBarItems * More public logging * Better BLE error handling * Node DB progress meter * minor tweak to activity light interaction timing * Fix comment linting, remove stale keys * Remove stale keys * Easy linting fixes * Two more simple linting fixes * clean up meshtasticapp * More public logging * Replay config * Logging * Fix for unselected node on Settings * Tweak to progress meter based on device idiom * Update protos * Session replay redaction of messages * Serial fix for old devices, and a let statement * Mask text too * Fix typo * BLE poweredOff is now an auto-reconnectable error * Update logging * Fix for peerRemovedPairingInformation * Logging for BLE peripheral:didUpdateValueFor errors. * Fix for inconsistent swipe disconnect behavior * periperal:didUpdateValueFor error handling * Fix for BLEConnection continuation guarding * BLEConnection actor deadlock on disconnect * Heartbeat nonce * Fix for swipe disconnect and task cancellation * Fix for swipe actions not honoring .disabled() * Tell BLETransport when BLEConnection is cancelled * Update navigation logging * Logging updates * Bump version to 2.7.0 * Organize into folders and heartbeat stuff * Minor improvements to manual TCP connection * Auto-connect toggle * Possible BLE bug, still waiting to see in logs * Concurrency tweaks * Concurrency improvements * requestDeviceMetadata fix. fixes remote admin * Minor typo fixes * "All" button for log filters: category and level * More robust continuation handling for BLE * @FetchRequest based ChannelMessageList * Update info.plist and device hardware file * Move auto connect toggle to app settings and debug mode, tint properly with the accent color * Add label to auto connect toggle * Update log for node info received from ourselves over the mesh * Remove unused scrollViewProxy * Update Meshtastic/Views/Onboarding/DeviceOnboarding.swift Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update target for connect view * Properly Set datadog environment * Comment out ble manager * Adjust cyclomatic complexity thresholds in .swiftlint.yml * Linting fixes, delete ble manager * Make session replay debug only --------- Co-authored-by: jake-b <jake-b@users.noreply.github.com> Co-authored-by: jake <jake@jakes-Mac-mini.local> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
parent
85c6c1f58a
commit
026bb80fba
138 changed files with 9463 additions and 6381 deletions
60
.swiftlint-precommit.yml
Normal file
60
.swiftlint-precommit.yml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
# Exclude automatically generated Swift files
|
||||
excluded:
|
||||
- MeshtasticProtobufs
|
||||
|
||||
line_length: 400
|
||||
|
||||
type_name:
|
||||
min_length: 1
|
||||
max_length:
|
||||
warning: 60
|
||||
error: 70
|
||||
excluded: iPhone # excluded via string
|
||||
allowed_symbols: ["_"] # these are allowed in type names
|
||||
identifier_name:
|
||||
min_length: 1
|
||||
max_length:
|
||||
warning: 60
|
||||
allowed_symbols: ["_"] # these are allowed in type names
|
||||
|
||||
# TODO: should review
|
||||
force_try:
|
||||
severity: warning # explicitly
|
||||
|
||||
# TODO: should review
|
||||
file_length:
|
||||
warning: 3500
|
||||
error: 4000
|
||||
|
||||
# TODO: should review
|
||||
cyclomatic_complexity:
|
||||
warning: 70
|
||||
error: 80
|
||||
ignores_case_statements: true
|
||||
|
||||
# TODO: should review
|
||||
function_body_length:
|
||||
warning: 200
|
||||
|
||||
# TODO: should review
|
||||
type_body_length:
|
||||
warning: 400
|
||||
|
||||
# TODO: should review
|
||||
disabled_rules: # rule identifiers to exclude from running
|
||||
- operator_whitespace
|
||||
- multiple_closures_with_trailing_closure
|
||||
- todo
|
||||
|
||||
# TODO: should review
|
||||
nesting:
|
||||
type_level:
|
||||
warning: 3
|
||||
|
||||
custom_rules:
|
||||
disable_print:
|
||||
included: ".*\\.swift"
|
||||
name: "Disable `print()`"
|
||||
regex: "((\\bprint)|(Swift\\.print))\\s*\\("
|
||||
message: "Consider using a dedicated log message or the Xcode debugger instead of using `print`. ex. logger.debug(...)"
|
||||
severity: warning
|
||||
|
|
@ -28,8 +28,8 @@ file_length:
|
|||
|
||||
# TODO: should review
|
||||
cyclomatic_complexity:
|
||||
warning: 70
|
||||
error: 80
|
||||
warning: 60
|
||||
error: 70
|
||||
ignores_case_statements: true
|
||||
|
||||
# TODO: should review
|
||||
|
|
@ -45,6 +45,7 @@ disabled_rules: # rule identifiers to exclude from running
|
|||
- operator_whitespace
|
||||
- multiple_closures_with_trailing_closure
|
||||
- todo
|
||||
- trailing_whitespace
|
||||
|
||||
# TODO: should review
|
||||
nesting:
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -15,10 +15,14 @@
|
|||
108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */; };
|
||||
10D109F22E2047D600536CE6 /* DatadogSessionReplay in Frameworks */ = {isa = PBXBuildFile; productRef = 10D109F12E2047D600536CE6 /* DatadogSessionReplay */; };
|
||||
10D109F42E2047D600536CE6 /* DatadogTrace in Frameworks */ = {isa = PBXBuildFile; productRef = 10D109F32E2047D600536CE6 /* DatadogTrace */; };
|
||||
230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */; };
|
||||
231251382E3BC96400E6ED07 /* BLEAuthorizationHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */; };
|
||||
231B3F212D087A4C0069A07D /* MetricTableColumn.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F202D087A4C0069A07D /* MetricTableColumn.swift */; };
|
||||
231B3F222D087A4C0069A07D /* MetricsColumnList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */; };
|
||||
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */; };
|
||||
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B3F262D0885240069A07D /* MetricsColumnDetail.swift */; };
|
||||
232ED4C32E2C5E89009DA392 /* TCPTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 232ED4C22E2C5E89009DA392 /* TCPTransport.swift */; };
|
||||
232ED4C52E2C5EDD009DA392 /* TCPConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */; };
|
||||
233E99B62D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99B52D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift */; };
|
||||
233E99B82D849C6500CC3A77 /* HumidityCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99B72D849C6500CC3A77 /* HumidityCompactWidget.swift */; };
|
||||
233E99BA2D849C7000CC3A77 /* PressureCompactWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233E99B92D849C7000CC3A77 /* PressureCompactWidget.swift */; };
|
||||
|
|
@ -33,10 +37,28 @@
|
|||
2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */; };
|
||||
2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */; };
|
||||
2344A2B12D68DFF800170A77 /* Constants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 25C49D8F2C471AEA0024FBD1 /* Constants.swift */; };
|
||||
2346A7192E2FB9A300CB9239 /* SerialConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A7182E2FB9A300CB9239 /* SerialConnection.swift */; };
|
||||
2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */; };
|
||||
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */; };
|
||||
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE142D0A24930086C749 /* MetricsSeriesList.swift */; };
|
||||
2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */; };
|
||||
23769D882E39521400E3601C /* View+iOS26Modifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23769D872E39521400E3601C /* View+iOS26Modifier.swift */; };
|
||||
237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB8E2E1FE456003B7CE3 /* Transport.swift */; };
|
||||
237AEB912E1FE46D003B7CE3 /* AccessoryManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB902E1FE46D003B7CE3 /* AccessoryManager.swift */; };
|
||||
237AEB932E1FE4BA003B7CE3 /* Connection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB922E1FE4BA003B7CE3 /* Connection.swift */; };
|
||||
237AEB952E1FE516003B7CE3 /* Device.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB942E1FE516003B7CE3 /* Device.swift */; };
|
||||
237AEB972E1FE627003B7CE3 /* BLETransport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB962E1FE627003B7CE3 /* BLETransport.swift */; };
|
||||
237AEB992E20098B003B7CE3 /* BLEConnection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237AEB982E20098B003B7CE3 /* BLEConnection.swift */; };
|
||||
237B46962DC8F1C100B22D99 /* RateLimitedButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */; };
|
||||
23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */; };
|
||||
23AD54692E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */; };
|
||||
23AD546B2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */; };
|
||||
23AD546D2E2AE9630046E9AB /* AccessoryManager+MQTT.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */; };
|
||||
23D316932E5618D2002FA4FB /* AsyncGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23D316922E5618D2002FA4FB /* AsyncGate.swift */; };
|
||||
23D9D9392E50DA97005D1C18 /* ResettableTimer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */; };
|
||||
23E23F922E392C2B00919073 /* LogRecord+StringRepresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */; };
|
||||
23F488122E32980B002C776F /* AccessoryManager+Position.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F488112E32980B002C776F /* AccessoryManager+Position.swift */; };
|
||||
23FF00B62E323C75001DF095 /* AccessoryManager+Connect.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23FF00B52E323C75001DF095 /* AccessoryManager+Connect.swift */; };
|
||||
251926852C3BA97800249DF5 /* FavoriteNodeButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */; };
|
||||
251926872C3BAE2200249DF5 /* NodeAlertsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */; };
|
||||
2519268A2C3BB1B200249DF5 /* ExchangePositionsButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */; };
|
||||
|
|
@ -185,7 +207,6 @@
|
|||
DDAB580D2B0DAA9E00147258 /* Routes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAB580C2B0DAA9E00147258 /* Routes.swift */; };
|
||||
DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAB580E2B0DAFBC00147258 /* LocationEntityExtension.swift */; };
|
||||
DDAD49ED2AFB39DC00B4425D /* MeshMap.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAD49EC2AFB39DC00B4425D /* MeshMap.swift */; };
|
||||
DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDAF8C5226EB1DF10058C060 /* BLEManager.swift */; };
|
||||
DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6ABD528AE742000384BA1 /* BluetoothConfig.swift */; };
|
||||
DDB6ABD928B0A4BA00384BA1 /* BluetoothModes.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6ABD828B0A4BA00384BA1 /* BluetoothModes.swift */; };
|
||||
DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDB6ABDA28B0AC6000384BA1 /* DistanceText.swift */; };
|
||||
|
|
@ -295,10 +316,14 @@
|
|||
/* Begin PBXFileReference section */
|
||||
108FFECA2DD3F43C00BFAA81 /* ShareContactQRDialog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareContactQRDialog.swift; sourceTree = "<group>"; };
|
||||
108FFECC2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeInfoEntityToNodeInfo.swift; sourceTree = "<group>"; };
|
||||
230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Discovery.swift"; sourceTree = "<group>"; };
|
||||
231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEAuthorizationHelper.swift; sourceTree = "<group>"; };
|
||||
231B3F1F2D087A4C0069A07D /* MetricsColumnList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnList.swift; sourceTree = "<group>"; };
|
||||
231B3F202D087A4C0069A07D /* MetricTableColumn.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricTableColumn.swift; sourceTree = "<group>"; };
|
||||
231B3F242D087C3C0069A07D /* EnvironmentDefaultColumns.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultColumns.swift; sourceTree = "<group>"; };
|
||||
231B3F262D0885240069A07D /* MetricsColumnDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsColumnDetail.swift; sourceTree = "<group>"; };
|
||||
232ED4C22E2C5E89009DA392 /* TCPTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPTransport.swift; sourceTree = "<group>"; };
|
||||
232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TCPConnection.swift; sourceTree = "<group>"; };
|
||||
233E99B32D84969500CC3A77 /* MeshtasticDataModelV 50.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 50.xcdatamodel"; sourceTree = "<group>"; };
|
||||
233E99B52D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeatherConditionsCompactWidget.swift; sourceTree = "<group>"; };
|
||||
233E99B72D849C6500CC3A77 /* HumidityCompactWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HumidityCompactWidget.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -313,10 +338,28 @@
|
|||
2344A2AA2D66973D00170A77 /* ManagedAttributePropertyWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManagedAttributePropertyWrapper.swift; sourceTree = "<group>"; };
|
||||
2344A2AD2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataClass.swift"; sourceTree = "<group>"; };
|
||||
2344A2AE2D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "TelemetryEntity+CoreDataProperties.swift"; sourceTree = "<group>"; };
|
||||
2346A7182E2FB9A300CB9239 /* SerialConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialConnection.swift; sourceTree = "<group>"; };
|
||||
2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SerialTransport.swift; sourceTree = "<group>"; };
|
||||
2373AE122D0A216C0086C749 /* MetricsChartSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsChartSeries.swift; sourceTree = "<group>"; };
|
||||
2373AE142D0A24930086C749 /* MetricsSeriesList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetricsSeriesList.swift; sourceTree = "<group>"; };
|
||||
2373AE162D0A26620086C749 /* EnvironmentDefaultSeries.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentDefaultSeries.swift; sourceTree = "<group>"; };
|
||||
23769D872E39521400E3601C /* View+iOS26Modifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+iOS26Modifier.swift"; sourceTree = "<group>"; };
|
||||
237AEB8E2E1FE456003B7CE3 /* Transport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Transport.swift; sourceTree = "<group>"; };
|
||||
237AEB902E1FE46D003B7CE3 /* AccessoryManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessoryManager.swift; sourceTree = "<group>"; };
|
||||
237AEB922E1FE4BA003B7CE3 /* Connection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connection.swift; sourceTree = "<group>"; };
|
||||
237AEB942E1FE516003B7CE3 /* Device.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Device.swift; sourceTree = "<group>"; };
|
||||
237AEB962E1FE627003B7CE3 /* BLETransport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLETransport.swift; sourceTree = "<group>"; };
|
||||
237AEB982E20098B003B7CE3 /* BLEConnection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEConnection.swift; sourceTree = "<group>"; };
|
||||
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RateLimitedButton.swift; sourceTree = "<group>"; };
|
||||
23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RXTXIndicatorView.swift; sourceTree = "<group>"; };
|
||||
23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+FromRadio.swift"; sourceTree = "<group>"; };
|
||||
23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+ToRadio.swift"; sourceTree = "<group>"; };
|
||||
23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+MQTT.swift"; sourceTree = "<group>"; };
|
||||
23D316922E5618D2002FA4FB /* AsyncGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AsyncGate.swift; sourceTree = "<group>"; };
|
||||
23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResettableTimer.swift; sourceTree = "<group>"; };
|
||||
23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LogRecord+StringRepresentation.swift"; sourceTree = "<group>"; };
|
||||
23F488112E32980B002C776F /* AccessoryManager+Position.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Position.swift"; sourceTree = "<group>"; };
|
||||
23FF00B52E323C75001DF095 /* AccessoryManager+Connect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AccessoryManager+Connect.swift"; sourceTree = "<group>"; };
|
||||
251926842C3BA97800249DF5 /* FavoriteNodeButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FavoriteNodeButton.swift; sourceTree = "<group>"; };
|
||||
251926862C3BAE2200249DF5 /* NodeAlertsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NodeAlertsButton.swift; sourceTree = "<group>"; };
|
||||
251926892C3BB1B200249DF5 /* ExchangePositionsButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExchangePositionsButton.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -497,7 +540,6 @@
|
|||
DDAB580C2B0DAA9E00147258 /* Routes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Routes.swift; sourceTree = "<group>"; };
|
||||
DDAB580E2B0DAFBC00147258 /* LocationEntityExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocationEntityExtension.swift; sourceTree = "<group>"; };
|
||||
DDAD49EC2AFB39DC00B4425D /* MeshMap.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MeshMap.swift; sourceTree = "<group>"; };
|
||||
DDAF8C5226EB1DF10058C060 /* BLEManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BLEManager.swift; sourceTree = "<group>"; };
|
||||
DDB234392B5CA9B000DA6FB1 /* MeshtasticDataModelV 24.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = "MeshtasticDataModelV 24.xcdatamodel"; sourceTree = "<group>"; };
|
||||
DDB6ABD528AE742000384BA1 /* BluetoothConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothConfig.swift; sourceTree = "<group>"; };
|
||||
DDB6ABD828B0A4BA00384BA1 /* BluetoothModes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BluetoothModes.swift; sourceTree = "<group>"; };
|
||||
|
|
@ -679,6 +721,89 @@
|
|||
path = CoreData;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
237AEB8D2E1FE120003B7CE3 /* Accessory */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23D9D9312E50DA0E005D1C18 /* Protocols */,
|
||||
23D9D9322E50DA1F005D1C18 /* Accessory Manager */,
|
||||
23D9D9332E50DA33005D1C18 /* Transports */,
|
||||
23D9D9372E50DA81005D1C18 /* Helpers */,
|
||||
);
|
||||
path = Accessory;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23D9D9312E50DA0E005D1C18 /* Protocols */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
237AEB8E2E1FE456003B7CE3 /* Transport.swift */,
|
||||
237AEB922E1FE4BA003B7CE3 /* Connection.swift */,
|
||||
237AEB942E1FE516003B7CE3 /* Device.swift */,
|
||||
);
|
||||
path = Protocols;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23D9D9322E50DA1F005D1C18 /* Accessory Manager */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
237AEB902E1FE46D003B7CE3 /* AccessoryManager.swift */,
|
||||
23F488112E32980B002C776F /* AccessoryManager+Position.swift */,
|
||||
23FF00B52E323C75001DF095 /* AccessoryManager+Connect.swift */,
|
||||
230BC3962E31071E0046BF2A /* AccessoryManager+Discovery.swift */,
|
||||
23AD54682E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift */,
|
||||
23AD546A2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift */,
|
||||
23AD546C2E2AE9630046E9AB /* AccessoryManager+MQTT.swift */,
|
||||
);
|
||||
path = "Accessory Manager";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23D9D9332E50DA33005D1C18 /* Transports */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23D9D9342E50DA40005D1C18 /* Bluetooth Low Energy */,
|
||||
23D9D9352E50DA4D005D1C18 /* TCP */,
|
||||
23D9D9362E50DA5A005D1C18 /* Serial */,
|
||||
);
|
||||
path = Transports;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23D9D9342E50DA40005D1C18 /* Bluetooth Low Energy */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
237AEB962E1FE627003B7CE3 /* BLETransport.swift */,
|
||||
237AEB982E20098B003B7CE3 /* BLEConnection.swift */,
|
||||
231251372E3BC96400E6ED07 /* BLEAuthorizationHelper.swift */,
|
||||
);
|
||||
path = "Bluetooth Low Energy";
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23D9D9352E50DA4D005D1C18 /* TCP */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
232ED4C22E2C5E89009DA392 /* TCPTransport.swift */,
|
||||
232ED4C42E2C5EDD009DA392 /* TCPConnection.swift */,
|
||||
);
|
||||
path = TCP;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23D9D9362E50DA5A005D1C18 /* Serial */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2346A71C2E2FB9C500CB9239 /* SerialTransport.swift */,
|
||||
2346A7182E2FB9A300CB9239 /* SerialConnection.swift */,
|
||||
);
|
||||
path = Serial;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
23D9D9372E50DA81005D1C18 /* Helpers */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
23D316922E5618D2002FA4FB /* AsyncGate.swift */,
|
||||
23E23F912E392C2B00919073 /* LogRecord+StringRepresentation.swift */,
|
||||
23D9D9382E50DA97005D1C18 /* ResettableTimer.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
251926882C3BAF2E00249DF5 /* Actions */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
|
|
@ -784,13 +909,13 @@
|
|||
path = Nodes;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DD47E3D726F2F21A00029299 /* Bluetooth */ = {
|
||||
DD47E3D726F2F21A00029299 /* Connect */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DD836AE626F6B38600ABCC23 /* Connect.swift */,
|
||||
DD86D409287F04F100BAEB7A /* InvalidVersion.swift */,
|
||||
);
|
||||
path = Bluetooth;
|
||||
path = Connect;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
DD4A911C2708C57100501B7E /* Settings */ = {
|
||||
|
|
@ -999,6 +1124,7 @@
|
|||
DDC2E15626CE248E0042C5E4 /* Meshtastic */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
237AEB8D2E1FE120003B7CE3 /* Accessory */,
|
||||
BCB6137F2C6728E700485544 /* AppIntents */,
|
||||
DD1BD0EC2C603C5B008C0C70 /* Measurement */,
|
||||
25F5D5BC2C3F6D7B008036E3 /* Router */,
|
||||
|
|
@ -1032,7 +1158,7 @@
|
|||
DDC2E18726CE24E40042C5E4 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
DD47E3D726F2F21A00029299 /* Bluetooth */,
|
||||
DD47E3D726F2F21A00029299 /* Connect */,
|
||||
DDC2E18D26CE25CB0042C5E4 /* Helpers */,
|
||||
DD6D5A312CA1176A00ED3032 /* Layouts */,
|
||||
DDC2E18B26CE25A70042C5E4 /* Messages */,
|
||||
|
|
@ -1102,6 +1228,8 @@
|
|||
DD6F65712C6AB8EC0053C113 /* SecureInput.swift */,
|
||||
8D3F8A3E2D44BB02009EAAA4 /* PowerMetrics.swift */,
|
||||
237B46952DC8F1C100B22D99 /* RateLimitedButton.swift */,
|
||||
23769D872E39521400E3601C /* View+iOS26Modifier.swift */,
|
||||
23A1AFB62E42BD2500E46C96 /* RXTXIndicatorView.swift */,
|
||||
);
|
||||
path = Helpers;
|
||||
sourceTree = "<group>";
|
||||
|
|
@ -1113,7 +1241,6 @@
|
|||
3D3417C72E29D38A006A988B /* GeoJSONOverlayConfig.swift */,
|
||||
BCD7448C2E0F2FA300F265A2 /* ContactURLHandler.swift */,
|
||||
DDD43FE12A78C86B0083A3E9 /* Mqtt */,
|
||||
DDAF8C5226EB1DF10058C060 /* BLEManager.swift */,
|
||||
DD1BEF492E0292220090CE24 /* KeychainHelper.swift */,
|
||||
DD913638270DFF4C00D7ACF3 /* LocalNotificationManager.swift */,
|
||||
DDA6B2E828419CF2003E8C16 /* MeshPackets.swift */,
|
||||
|
|
@ -1424,6 +1551,7 @@
|
|||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
230BC3972E31071E0046BF2A /* AccessoryManager+Discovery.swift in Sources */,
|
||||
25F26B1F2C2F611300C9CD9D /* AppData.swift in Sources */,
|
||||
25F26B1E2C2F610D00C9CD9D /* Logger.swift in Sources */,
|
||||
259792252C2F114500AD1659 /* ChannelEntityExtension.swift in Sources */,
|
||||
|
|
@ -1445,6 +1573,8 @@
|
|||
DD5D0A9C2931B9F200F7EA61 /* EthernetModes.swift in Sources */,
|
||||
6DEDA55A2A957B8E00321D2E /* DetectionSensorLog.swift in Sources */,
|
||||
DD798B072915928D005217CD /* ChannelMessageList.swift in Sources */,
|
||||
23769D882E39521400E3601C /* View+iOS26Modifier.swift in Sources */,
|
||||
237AEB992E20098B003B7CE3 /* BLEConnection.swift in Sources */,
|
||||
231B3F272D0885240069A07D /* MetricsColumnDetail.swift in Sources */,
|
||||
DD77093D2AA1AFA3007A8BF0 /* ChannelTips.swift in Sources */,
|
||||
6D825E622C34786C008DBEE4 /* CommonRegex.swift in Sources */,
|
||||
|
|
@ -1461,14 +1591,15 @@
|
|||
DD41A61529AB0035003C5A37 /* NodeWeatherForecast.swift in Sources */,
|
||||
DDB6ABD628AE742000384BA1 /* BluetoothConfig.swift in Sources */,
|
||||
251926902C3CB44900249DF5 /* ClientHistoryButton.swift in Sources */,
|
||||
237AEB932E1FE4BA003B7CE3 /* Connection.swift in Sources */,
|
||||
DDD5BB102C285FB3007E03CA /* AppLogFilter.swift in Sources */,
|
||||
237AEB952E1FE516003B7CE3 /* Device.swift in Sources */,
|
||||
2373AE172D0A26620086C749 /* EnvironmentDefaultSeries.swift in Sources */,
|
||||
233E99B82D849C6500CC3A77 /* HumidityCompactWidget.swift in Sources */,
|
||||
DD4640202AFF10F4002A5ECB /* WaypointForm.swift in Sources */,
|
||||
233E99C12D849D6000CC3A77 /* DistanceCompactWidget.swift in Sources */,
|
||||
DD769E0328D18BF1001A3F05 /* DeviceMetricsLog.swift in Sources */,
|
||||
108FFECD2DD4005600BFAA81 /* NodeInfoEntityToNodeInfo.swift in Sources */,
|
||||
DDAF8C5326EB1DF10058C060 /* BLEManager.swift in Sources */,
|
||||
DD15E4F32B8BA56E00654F61 /* PaxCounterConfig.swift in Sources */,
|
||||
DDDB445229F8ACF900EE2349 /* Date.swift in Sources */,
|
||||
2373AE132D0A216C0086C749 /* MetricsChartSeries.swift in Sources */,
|
||||
|
|
@ -1483,6 +1614,7 @@
|
|||
BCD93CBA2D9E11A2006C9214 /* DisconnectNodeIntent.swift in Sources */,
|
||||
DDC94FC129CE063B0082EA6E /* BatteryLevel.swift in Sources */,
|
||||
231B3F252D087C3C0069A07D /* EnvironmentDefaultColumns.swift in Sources */,
|
||||
23AD546D2E2AE9630046E9AB /* AccessoryManager+MQTT.swift in Sources */,
|
||||
25F5D5BE2C3F6D87008036E3 /* NavigationState.swift in Sources */,
|
||||
2373AE152D0A24930086C749 /* MetricsSeriesList.swift in Sources */,
|
||||
DD354FD92BD96A0B0061A25F /* IAQScale.swift in Sources */,
|
||||
|
|
@ -1498,6 +1630,7 @@
|
|||
DDD5BB0B2C285E45007E03CA /* LogDetail.swift in Sources */,
|
||||
DDA0B6B2294CDC55001356EC /* Channels.swift in Sources */,
|
||||
DD74ED0D2DC6A0C90059AC10 /* DeviceOnboarding.swift in Sources */,
|
||||
237AEB972E1FE627003B7CE3 /* BLETransport.swift in Sources */,
|
||||
DDE9659C2B1C3B6A00531070 /* RouteRecorder.swift in Sources */,
|
||||
B399E8A42B6F486400E4488E /* RetryButton.swift in Sources */,
|
||||
DDB8F4102A9EE5B400230ECE /* Messages.swift in Sources */,
|
||||
|
|
@ -1513,13 +1646,16 @@
|
|||
DDF45C342BC1A48E005ED5F2 /* MQTTIcon.swift in Sources */,
|
||||
DDA9515A2BC6624100CEA535 /* TelemetryWeather.swift in Sources */,
|
||||
DD1BD0EB2C601795008C0C70 /* CLLocation.swift in Sources */,
|
||||
231251382E3BC96400E6ED07 /* BLEAuthorizationHelper.swift in Sources */,
|
||||
DDF924CA26FBB953009FE055 /* ConnectedDevice.swift in Sources */,
|
||||
DD3CC6BE28E4CD9800FA9159 /* BatteryGauge.swift in Sources */,
|
||||
237AEB912E1FE46D003B7CE3 /* AccessoryManager.swift in Sources */,
|
||||
DD6193772862F90F00E59241 /* CannedMessagesConfig.swift in Sources */,
|
||||
DD3619152B1EF9F900C41C8C /* LocationsHandler.swift in Sources */,
|
||||
DD6F65792C6EADE60053C113 /* DirectMessagesHelp.swift in Sources */,
|
||||
233E99B62D849C3D00CC3A77 /* WeatherConditionsCompactWidget.swift in Sources */,
|
||||
25F5D5C02C3F6DA6008036E3 /* Router.swift in Sources */,
|
||||
2346A7192E2FB9A300CB9239 /* SerialConnection.swift in Sources */,
|
||||
DDDB444A29F8AA3A00EE2349 /* CLLocationCoordinate2D.swift in Sources */,
|
||||
25C49D902C471AEA0024FBD1 /* Constants.swift in Sources */,
|
||||
DD41582628582E9B009B0E59 /* DeviceConfig.swift in Sources */,
|
||||
|
|
@ -1527,10 +1663,12 @@
|
|||
DD007BAE2AA4E91200F5FA12 /* MyInfoEntityExtension.swift in Sources */,
|
||||
DD33DB622B3D27C7003E1EA0 /* FirmwareApi.swift in Sources */,
|
||||
DD3CC6B528E33FD100FA9159 /* ShareChannels.swift in Sources */,
|
||||
23D9D9392E50DA97005D1C18 /* ResettableTimer.swift in Sources */,
|
||||
DD1BF2F92776FE2E008C8D2F /* UserMessageList.swift in Sources */,
|
||||
DD3CC6C228EB9D4900FA9159 /* UpdateCoreData.swift in Sources */,
|
||||
DDE0F7C5295F77B700B8AAB3 /* AppSettingsEnums.swift in Sources */,
|
||||
DDB6ABE628B1406100384BA1 /* LoraConfigEnums.swift in Sources */,
|
||||
232ED4C52E2C5EDD009DA392 /* TCPConnection.swift in Sources */,
|
||||
DDB8F4142A9EE5F000230ECE /* ChannelList.swift in Sources */,
|
||||
DDD43FE32A78C8900083A3E9 /* MqttClientProxyManager.swift in Sources */,
|
||||
BCD7448D2E0F2FAA00F265A2 /* ContactURLHandler.swift in Sources */,
|
||||
|
|
@ -1542,6 +1680,8 @@
|
|||
DDB6ABDB28B0AC6000384BA1 /* DistanceText.swift in Sources */,
|
||||
DD94B7402ACCE3BE00DCD1D1 /* MapSettingsForm.swift in Sources */,
|
||||
DD964FC2297272AE007C176F /* WaypointEntityExtension.swift in Sources */,
|
||||
23AD546B2E2AA5A80046E9AB /* AccessoryManager+ToRadio.swift in Sources */,
|
||||
23F488122E32980B002C776F /* AccessoryManager+Position.swift in Sources */,
|
||||
6DA39D8E2A92DC52007E311C /* MeshtasticAppDelegate.swift in Sources */,
|
||||
D93068DB2B81C85E0066FBC8 /* PowerConfig.swift in Sources */,
|
||||
D93068D32B8129510066FBC8 /* MessageContextMenuItems.swift in Sources */,
|
||||
|
|
@ -1558,6 +1698,7 @@
|
|||
DD2553592855B52700E55709 /* PositionConfig.swift in Sources */,
|
||||
DD97E96828EFE9A00056DDA4 /* About.swift in Sources */,
|
||||
DDDB444029F79AB000EE2349 /* UserDefaults.swift in Sources */,
|
||||
23AD54692E2A6EAA0046E9AB /* AccessoryManager+FromRadio.swift in Sources */,
|
||||
233E99BA2D849C7000CC3A77 /* PressureCompactWidget.swift in Sources */,
|
||||
DDB6ABE028B13AC700384BA1 /* DeviceEnums.swift in Sources */,
|
||||
DD86D40C287F401000BAEB7A /* SaveChannelQRCode.swift in Sources */,
|
||||
|
|
@ -1587,6 +1728,7 @@
|
|||
DD6F65762C6EA5490053C113 /* AckErrors.swift in Sources */,
|
||||
DDDB445029F8AC9C00EE2349 /* UIImage.swift in Sources */,
|
||||
DD86D40F2881BE4C00BAEB7A /* CsvDocument.swift in Sources */,
|
||||
23A1AFB72E42BD2500E46C96 /* RXTXIndicatorView.swift in Sources */,
|
||||
DDB75A1E2A0B0CD0006ED576 /* LoRaSignalStrengthIndicator.swift in Sources */,
|
||||
DDA6B2E928419CF2003E8C16 /* MeshPackets.swift in Sources */,
|
||||
DDCE4E2C2869F92900BE9F8F /* UserConfig.swift in Sources */,
|
||||
|
|
@ -1607,6 +1749,8 @@
|
|||
DDD94A502845C8F5004A87A0 /* DateTimeText.swift in Sources */,
|
||||
DDB6ABE228B13FB500384BA1 /* PositionConfigEnums.swift in Sources */,
|
||||
DD994B69295F88B60013760A /* IntervalEnums.swift in Sources */,
|
||||
23D316932E5618D2002FA4FB /* AsyncGate.swift in Sources */,
|
||||
23FF00B62E323C75001DF095 /* AccessoryManager+Connect.swift in Sources */,
|
||||
DDDCD5702BB26F5C00BE6B60 /* NodeListFilter.swift in Sources */,
|
||||
DD6F65742C6CB80A0053C113 /* View.swift in Sources */,
|
||||
DD1933762B0835D500771CD5 /* PositionAltitudeChart.swift in Sources */,
|
||||
|
|
@ -1617,6 +1761,7 @@
|
|||
DD73FD1128750779000852D6 /* PositionLog.swift in Sources */,
|
||||
DD15E4F52B8BFC8E00654F61 /* PaxCounterLog.swift in Sources */,
|
||||
BCE2D3C32C7ADF42008E6199 /* ShutDownNodeIntent.swift in Sources */,
|
||||
23E23F922E392C2B00919073 /* LogRecord+StringRepresentation.swift in Sources */,
|
||||
25F5D5C22C3F6E4B008036E3 /* AppState.swift in Sources */,
|
||||
DD3CC6C028E7A60700FA9159 /* MessagingEnums.swift in Sources */,
|
||||
DD6F657B2C6EC2900053C113 /* LockLegend.swift in Sources */,
|
||||
|
|
@ -1628,12 +1773,14 @@
|
|||
BC10380F2DD4334400B00BFA /* AddContactIntent.swift in Sources */,
|
||||
DDA9515E2BC6F56F00CEA535 /* IndoorAirQuality.swift in Sources */,
|
||||
DDDB444E29F8AB0E00EE2349 /* Int.swift in Sources */,
|
||||
2346A71D2E2FB9C500CB9239 /* SerialTransport.swift in Sources */,
|
||||
DD3CC6BC28E366DF00FA9159 /* Meshtastic.xcdatamodeld in Sources */,
|
||||
BCDDFA9A2DBB180D0065189C /* ScrollToBottomButton.swift in Sources */,
|
||||
DDC4C9FF2A8D982900CE201C /* DetectionSensorConfig.swift in Sources */,
|
||||
2344A2AF2D6697A700170A77 /* TelemetryEntity+CoreDataClass.swift in Sources */,
|
||||
2344A2B02D6697A700170A77 /* TelemetryEntity+CoreDataProperties.swift in Sources */,
|
||||
D9C983A22B79D1A600BDBE6A /* RequestPositionButton.swift in Sources */,
|
||||
237AEB8F2E1FE457003B7CE3 /* Transport.swift in Sources */,
|
||||
DDDB26442AAC0206003AFCB7 /* NodeDetail.swift in Sources */,
|
||||
DD77093F2AA1B146007A8BF0 /* UIColor.swift in Sources */,
|
||||
DDF6B2482A9AEBF500BA6931 /* StoreForwardConfig.swift in Sources */,
|
||||
|
|
@ -1647,6 +1794,7 @@
|
|||
DDAB580F2B0DAFBC00147258 /* LocationEntityExtension.swift in Sources */,
|
||||
233E99BC2D849C8C00CC3A77 /* WindCompactWidget.swift in Sources */,
|
||||
B3E905B12B71F7F300654D07 /* TextMessageField.swift in Sources */,
|
||||
232ED4C32E2C5E89009DA392 /* TCPTransport.swift in Sources */,
|
||||
BC6B45FF2CB2F98900723CEB /* SaveChannelSettingsIntent.swift in Sources */,
|
||||
D93068D72B8146690066FBC8 /* MessageText.swift in Sources */,
|
||||
DDC2E15826CE248E0042C5E4 /* MeshtasticApp.swift in Sources */,
|
||||
|
|
@ -1693,7 +1841,7 @@
|
|||
ENABLE_USER_SCRIPT_SANDBOXING = YES;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1715,7 +1863,7 @@
|
|||
CURRENT_PROJECT_VERSION = 1;
|
||||
GCC_C_LANGUAGE_STANDARD = gnu17;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.0;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 17.5;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.MeshtasticTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
|
|
@ -1789,6 +1937,8 @@
|
|||
SDKROOT = iphoneos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_STRICT_CONCURRENCY = targeted;
|
||||
SWIFT_VERSION = 6.0;
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
|
|
@ -1849,6 +1999,8 @@
|
|||
SDKROOT = iphoneos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_STRICT_CONCURRENCY = targeted;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2,6";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
|
|
@ -1875,7 +2027,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.17;
|
||||
MARKETING_VERSION = 2.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1908,7 +2060,7 @@
|
|||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.17;
|
||||
MARKETING_VERSION = 2.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SUPPORTS_MACCATALYST = YES;
|
||||
|
|
@ -1939,7 +2091,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.17;
|
||||
MARKETING_VERSION = 2.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -1971,7 +2123,7 @@
|
|||
"@executable_path/Frameworks",
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 2.6.17;
|
||||
MARKETING_VERSION = 2.7.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = gvh.MeshtasticClient.Widgets;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
|
|
@ -2037,7 +2189,7 @@
|
|||
repositoryURL = "https://github.com/DataDog/dd-sdk-ios.git";
|
||||
requirement = {
|
||||
kind = upToNextMajorVersion;
|
||||
minimumVersion = 2.29.0;
|
||||
minimumVersion = 2.30.0;
|
||||
};
|
||||
};
|
||||
259792242C2F10B600AD1659 /* XCRemoteSwiftPackageReference "swift-protobuf" */ = {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
{
|
||||
"originHash" : "fd71b247ba909b0eb360db5530e1068363839c5e169dea6f6a9974b2d98276f4",
|
||||
"originHash" : "ec45e53bfccc0a9f0df47733b15acfccda455638f3114d1407bb14e89aa23639",
|
||||
"pins" : [
|
||||
{
|
||||
"identity" : "cocoamqtt",
|
||||
|
|
@ -15,8 +15,8 @@
|
|||
"kind" : "remoteSourceControl",
|
||||
"location" : "https://github.com/DataDog/dd-sdk-ios.git",
|
||||
"state" : {
|
||||
"revision" : "d0a42d8067665cb6ee86af51251ccc071f62bd54",
|
||||
"version" : "2.29.0"
|
||||
"revision" : "ba59a958b9a4894b0d281d9220ed1bb28fc1fae1",
|
||||
"version" : "2.30.0"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -0,0 +1,334 @@
|
|||
//
|
||||
// AccessoryManager+Connect.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/24/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
import CoreBluetooth
|
||||
|
||||
private let maxRetries = 10
|
||||
private let retryDelay: Duration = .seconds(1)
|
||||
|
||||
extension AccessoryManager {
|
||||
func connect(to device: Device) async throws {
|
||||
|
||||
// Prevent new connection if one is active
|
||||
if activeConnection != nil {
|
||||
throw AccessoryError.connectionFailed("Already connected to a device")
|
||||
}
|
||||
|
||||
guard let transport = transportForType(device.transportType) else {
|
||||
throw AccessoryError.connectionFailed("No transport for type")
|
||||
}
|
||||
|
||||
// Clear any errors from last time
|
||||
lastConnectionError = nil
|
||||
packetsSent = 0
|
||||
packetsReceived = 0
|
||||
expectedNodeDBSize = nil
|
||||
|
||||
// Prepare to connect
|
||||
self.connectionStepper = SequentialSteps(maxRetries: maxRetries, retryDelay: retryDelay) {
|
||||
|
||||
// Step 0
|
||||
Step { @MainActor retryAttempt in
|
||||
Logger.transport.info("🔗👟 [Connect] Starting connection to \(device.id)")
|
||||
if retryAttempt > 0 {
|
||||
try await self.closeConnection() // clean-up before retries.
|
||||
self.updateState(.retrying(attempt: retryAttempt + 1))
|
||||
self.allowDisconnect = true
|
||||
} else {
|
||||
self.updateState(.connecting)
|
||||
}
|
||||
self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connecting)
|
||||
}
|
||||
|
||||
// Step 1: Setup the connection
|
||||
Step(timeout: .seconds(2)) { @MainActor _ in
|
||||
Logger.transport.info("🔗👟[Connect] Step 1: connection to \(device.id)")
|
||||
do {
|
||||
let connection = try await transport.connect(to: device)
|
||||
let eventStream = try await connection.connect()
|
||||
self.updateState(.communicating)
|
||||
self.connectionEventTask = Task {
|
||||
for await event in eventStream {
|
||||
self.didReceive(event)
|
||||
}
|
||||
Logger.transport.info("[Accessory] Event stream closed")
|
||||
}
|
||||
self.activeConnection = (device: device, connection: connection)
|
||||
|
||||
if UserDefaults.preferredPeripheralId.count < 1 {
|
||||
UserDefaults.preferredPeripheralId = device.id.uuidString
|
||||
}
|
||||
} catch let error as CBError where error.code == .peerRemovedPairingInformation {
|
||||
await self.connectionStepper?.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: true)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Send Heartbeat before wantConfig (config)
|
||||
Step { @MainActor _ in
|
||||
Logger.transport.info("💓👟 [Connect] Step 2: Send heartbeat")
|
||||
try await self.sendHeartbeat()
|
||||
}
|
||||
|
||||
// Step 3: Send WantConfig (config)
|
||||
Step(timeout: .seconds(30)) { @MainActor _ in
|
||||
Logger.transport.info("🔗👟 [Connect] Step 3: Send wantConfig (config)")
|
||||
try await self.sendWantConfig()
|
||||
}
|
||||
|
||||
// Step 4: Send Heartbeat before wantConfig (database)
|
||||
Step { @MainActor _ in
|
||||
Logger.transport.info("💓 [Connect] Step 4: Send heartbeat")
|
||||
try await self.sendHeartbeat()
|
||||
}
|
||||
|
||||
// Step 5: Send WantConfig (database)
|
||||
Step(timeout: .seconds(3.0), onFailure: .retryStep(attempts: 3)) { @MainActor _ in
|
||||
Logger.transport.info("🔗👟 [Connect] Step 5: Send wantConfig (database)")
|
||||
self.updateState(.retrievingDatabase(nodeCount: 0))
|
||||
self.allowDisconnect = true
|
||||
try await self.sendWantDatabase()
|
||||
}
|
||||
|
||||
// Step 5a: Wait for end of WantConfig (database)
|
||||
Step { @MainActor _ in
|
||||
Logger.transport.info("🔗👟 [Connect] Step 5a: Wait for the final database")
|
||||
try await self.waitForWantDatabaseResponse()
|
||||
}
|
||||
|
||||
// Step 6: Version check
|
||||
Step { @MainActor _ in
|
||||
Logger.transport.info("🔗👟 [Connect] Step 6: Version check")
|
||||
|
||||
guard let firmwareVersion = self.activeConnection?.device.firmwareVersion else {
|
||||
Logger.transport.error("🔗 [Connect] Firmware version not available for device \(device.name, privacy: .public)")
|
||||
throw AccessoryError.connectionFailed("Firmware version not available")
|
||||
}
|
||||
|
||||
let lastDotIndex = firmwareVersion.lastIndex(of: ".")
|
||||
if lastDotIndex == nil {
|
||||
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)
|
||||
|
||||
let supportedVersion = self.checkIsVersionSupported(forVersion: self.minimumVersion)
|
||||
if !supportedVersion {
|
||||
throw AccessoryError.connectionFailed("🚨" + "Update Your Firmware".localized)
|
||||
}
|
||||
}
|
||||
|
||||
// Step 7: Update UI and status to connected
|
||||
Step { @MainActor _ in
|
||||
Logger.transport.info("🔗👟 [Connect] Step 7: Update UI and status")
|
||||
|
||||
// We have an active connection
|
||||
self.updateDevice(deviceId: device.id, key: \.connectionState, value: .connected)
|
||||
self.updateState(.subscribed)
|
||||
}
|
||||
|
||||
// Step 8: Update UI and status to connected
|
||||
Step { @MainActor _ in
|
||||
Logger.transport.debug("🔗👟 [Connect] Step 8: Initialize MQTT and Location Provider")
|
||||
self.stopDiscovery()
|
||||
await self.initializeMqtt()
|
||||
self.initializeLocationProvider()
|
||||
if transport.requiresPeriodicHeartbeat {
|
||||
await self.setupPeriodicHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run the connection process
|
||||
do {
|
||||
try await connectionStepper?.run()
|
||||
Logger.transport.debug("🔗 [Connect] ConnectionStepper completed.")
|
||||
} catch {
|
||||
Logger.transport.error("🔗 [Connect] Error returned by connectionStepper: \(error)")
|
||||
try await self.closeConnection()
|
||||
updateState(.discovering)
|
||||
self.lastConnectionError = error
|
||||
}
|
||||
|
||||
// All done, one way or another, clean up
|
||||
self.connectionStepper = nil
|
||||
}
|
||||
}
|
||||
|
||||
// Sequentially stepped tasks
|
||||
typealias Step = SequentialSteps.Step
|
||||
actor SequentialSteps {
|
||||
|
||||
typealias StepClosure = @Sendable (_ retryAttempt: Int) async throws -> Void
|
||||
|
||||
enum FailureBehavior {
|
||||
case fail
|
||||
case retryStep(attempts: Int)
|
||||
case retryAll
|
||||
}
|
||||
|
||||
struct Step {
|
||||
let timeout: Duration?
|
||||
let failureBehavior: FailureBehavior
|
||||
let operation: StepClosure
|
||||
|
||||
init(timeout: Duration? = nil, onFailure: FailureBehavior = .retryAll, operation: @escaping StepClosure) {
|
||||
self.timeout = timeout
|
||||
self.failureBehavior = onFailure
|
||||
self.operation = operation
|
||||
}
|
||||
}
|
||||
|
||||
private enum SequentialStepError: Error, LocalizedError {
|
||||
case timeout(stepNumber: Int, afterWaiting: Duration)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .timeout(let stepNumber, let afterWaiting):
|
||||
return "Timeout after \(afterWaiting) waiting for step \(stepNumber)."
|
||||
}
|
||||
}
|
||||
}
|
||||
let steps: [Step]
|
||||
var currentlyExecutingStep: Task<Void, any Error>?
|
||||
var cancelled = false
|
||||
var maxRetries: Int
|
||||
var retryDelay: Duration
|
||||
var isRunning: Bool = false
|
||||
var externalError: Error?
|
||||
|
||||
init(maxRetries: Int = 1, retryDelay: Duration = .seconds(3), @StepsBuilder _ builder: () -> [Step]) {
|
||||
self.maxRetries = maxRetries
|
||||
self.retryDelay = retryDelay
|
||||
self.steps = builder()
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
self.isRunning = true
|
||||
retryLoop: for attempt in 0..<maxRetries {
|
||||
for stepNumber in 0..<steps.count {
|
||||
if cancelled {
|
||||
throw externalError ?? CancellationError()
|
||||
}
|
||||
let currentStep = steps[stepNumber]
|
||||
let isRetry = (attempt > 0)
|
||||
if isRetry {
|
||||
try await Task.sleep(for: retryDelay)
|
||||
}
|
||||
do {
|
||||
let stepRetries = if case let .retryStep(attempts) = currentStep.failureBehavior, attempts > 0 { attempts } else { 1 }
|
||||
stepRetryLoop: for stepRetryAttempt in 0..<stepRetries {
|
||||
if stepRetryAttempt > 0 {
|
||||
Logger.transport.info("[Retry Step Loop] Retrying step \(stepNumber + 1) for the \(stepRetryAttempt + 1) time.")
|
||||
try await Task.sleep(for: retryDelay)
|
||||
}
|
||||
do {
|
||||
// Starting a new attempt for this step.
|
||||
if let duration = currentStep.timeout {
|
||||
// Execute this task with a timeout
|
||||
self.currentlyExecutingStep = executeWithTimeout(stepNumber: stepNumber, timeout: duration) {
|
||||
try await currentStep.operation(attempt)
|
||||
}
|
||||
try await self.currentlyExecutingStep!.value
|
||||
} else {
|
||||
// Execute this task without a timeout
|
||||
self.currentlyExecutingStep = Task {
|
||||
try await currentStep.operation(attempt)
|
||||
}
|
||||
try await self.currentlyExecutingStep!.value
|
||||
}
|
||||
break stepRetryLoop // Exit retry loop if successful
|
||||
} catch {
|
||||
if stepRetryAttempt == stepRetries - 1 {
|
||||
// If this is the last retry attempt, we throw the error to the outer loop
|
||||
throw error
|
||||
} else {
|
||||
switch error {
|
||||
case let SequentialStepError.timeout(stepNumber, afterWaiting):
|
||||
Logger.transport.info("[Inner Retry Step Loop] Sequential process timed out on step \(stepNumber) of \(stepRetries) after waiting \(afterWaiting)")
|
||||
case is CancellationError:
|
||||
if let externalError {
|
||||
// Something from the outside had an error which caused the cancellation of this step
|
||||
let errorToThrow = externalError
|
||||
self.externalError = nil
|
||||
throw errorToThrow
|
||||
}
|
||||
break stepRetryLoop
|
||||
default:
|
||||
Logger.transport.error("[Inner Retry Step Loop] Sequential process failed on step \(stepNumber) with error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
switch error {
|
||||
case let SequentialStepError.timeout(stepNumber, afterWaiting):
|
||||
Logger.transport.info("[Outer Step Retry Loop] Sequential process timed out on step \(stepNumber) after waiting \(afterWaiting)")
|
||||
default:
|
||||
Logger.transport.error("[Outer Step Retry Loop] Sequential process failed on step \(stepNumber) with error: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
switch currentStep.failureBehavior {
|
||||
case .retryAll, .retryStep:
|
||||
// TODO: we could have a .retryStepAndFail and a .retryStepAndContinue instead of just .retryStep to clarify the behavior here
|
||||
continue retryLoop
|
||||
case .fail:
|
||||
isRunning = false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
// We have finished all steps
|
||||
isRunning = false
|
||||
return
|
||||
}
|
||||
isRunning = false
|
||||
throw AccessoryError.tooManyRetries
|
||||
}
|
||||
|
||||
func cancel() {
|
||||
cancelled = true
|
||||
self.currentlyExecutingStep?.cancel()
|
||||
}
|
||||
|
||||
func cancelCurrentlyExecutingStep(withError: Error?, cancelFullProcess: Bool = false) {
|
||||
self.externalError = withError
|
||||
if cancelFullProcess {
|
||||
cancel()
|
||||
} else {
|
||||
self.currentlyExecutingStep?.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
func executeWithTimeout<ReturnType>(stepNumber: Int, timeout: Duration, operation: @escaping @Sendable () async throws -> ReturnType) -> Task<ReturnType, Error> {
|
||||
return Task {
|
||||
try await withThrowingTaskGroup(of: ReturnType.self) { group -> ReturnType in
|
||||
group.addTask(operation: operation)
|
||||
group.addTask {
|
||||
try await _Concurrency.Task.sleep(for: timeout)
|
||||
throw SequentialStepError.timeout(stepNumber: stepNumber, afterWaiting: timeout)
|
||||
}
|
||||
guard let success = try await group.next() else {
|
||||
throw SequentialStepError.timeout(stepNumber: stepNumber, afterWaiting: timeout)
|
||||
}
|
||||
group.cancelAll()
|
||||
return success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@resultBuilder
|
||||
struct StepsBuilder {
|
||||
static func buildBlock(_ components: Step...) -> [Step] {
|
||||
return components
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
//
|
||||
// AccessoryManager+Discovery.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/23/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
|
||||
extension AccessoryManager {
|
||||
|
||||
private func discoverAllDevices() -> AsyncStream<DiscoveryEvent> {
|
||||
AsyncStream { continuation in
|
||||
let tasks = transports.map { transport in
|
||||
Task {
|
||||
Logger.transport.info("🔎 [Discovery] Discovery stream started for transport \(String(describing: transport.type))")
|
||||
for await event in transport.discoverDevices() {
|
||||
continuation.yield(event)
|
||||
}
|
||||
Logger.transport.info("🔎 [Discovery] Discovery stream closed for transport \(String(describing: transport.type))")
|
||||
}
|
||||
}
|
||||
continuation.onTermination = { _ in
|
||||
Logger.transport.info("🔎 [Discovery] Cancelling discovery for all transports.")
|
||||
tasks.forEach { $0.cancel() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func startDiscovery() {
|
||||
if discoveryTask != nil {
|
||||
Logger.transport.debug("🔎 [Discovery] Existing discovery task is active.")
|
||||
return
|
||||
}
|
||||
updateState(.discovering)
|
||||
|
||||
discoveryTask = Task { @MainActor in
|
||||
for await event in self.discoverAllDevices() {
|
||||
do {
|
||||
try Task.checkCancellation()
|
||||
switch event {
|
||||
case .deviceFound(let newDevice), .deviceUpdated(let newDevice):
|
||||
// Update existing device or add new
|
||||
if let index = self.devices.firstIndex(where: { $0.id == newDevice.id }) {
|
||||
// This device already exists.
|
||||
var existing = self.devices[index]
|
||||
existing.name = newDevice.name
|
||||
existing.transportType = newDevice.transportType
|
||||
existing.identifier = newDevice.identifier
|
||||
existing.connectionState = newDevice.connectionState
|
||||
existing.rssi = newDevice.rssi
|
||||
self.devices[index] = existing
|
||||
} else {
|
||||
// This is a new device, add it to our list
|
||||
self.devices.append(newDevice)
|
||||
}
|
||||
|
||||
if self.shouldAutomaticallyConnectToPreferredPeripheral,
|
||||
UserDefaults.autoconnectOnDiscovery, UserDefaults.preferredPeripheralId == newDevice.id.uuidString {
|
||||
Logger.transport.debug("🔎 [Discovery] Found preferred peripheral \(newDevice.name)")
|
||||
self.connectToPreferredDevice()
|
||||
}
|
||||
|
||||
// Update the list of discovered devices on the main thread for presentation
|
||||
// in the user interface
|
||||
self.devices = devices.sorted { $0.name < $1.name }
|
||||
|
||||
case .deviceLost(let deviceId):
|
||||
devices.removeAll { $0.id == deviceId }
|
||||
|
||||
case .deviceReportedRssi(let deviceId, let newRssi):
|
||||
updateDevice(deviceId: deviceId, key: \.rssi, value: newRssi)
|
||||
}
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopDiscovery() {
|
||||
devices.removeAll()
|
||||
discoveryTask?.cancel()
|
||||
discoveryTask?.cancel()
|
||||
discoveryTask = nil
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,483 @@
|
|||
//
|
||||
// AccessoryManager+FromRadio.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MeshtasticProtobufs
|
||||
import CocoaMQTT
|
||||
import OSLog
|
||||
|
||||
extension AccessoryManager {
|
||||
|
||||
func handleMqttClientProxyMessage(_ mqttClientProxyMessage: MqttClientProxyMessage) {
|
||||
Logger.services.info("handleMqttClientProxyMessage: \(mqttClientProxyMessage.debugDescription)")
|
||||
let message = CocoaMQTTMessage(topic: mqttClientProxyMessage.topic,
|
||||
payload: [UInt8](mqttClientProxyMessage.data),
|
||||
retained: mqttClientProxyMessage.retained)
|
||||
MqttClientProxyManager.shared.mqttClientProxy?.publish(message)
|
||||
}
|
||||
|
||||
func handleClientNotification(_ clientNotification: ClientNotification) {
|
||||
Logger.services.info("handleClientNotification: \(clientNotification.debugDescription)")
|
||||
var path = "meshtastic:///settings/debugLogs"
|
||||
if clientNotification.hasReplyID {
|
||||
/// Set Sent bool on TraceRouteEntity to false if we got rate limited
|
||||
if clientNotification.message.starts(with: "TraceRoute") {
|
||||
// CoreData operation happens on the Main Actor
|
||||
|
||||
let traceRoute = getTraceRoute(id: Int64(clientNotification.replyID), context: context)
|
||||
traceRoute?.sent = false
|
||||
do {
|
||||
try context.save()
|
||||
Logger.data.info("💾 [TraceRouteEntity] Trace Route Rate Limited")
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
Logger.data.error("💥 [TraceRouteEntity] Error Updating Core Data: \(nsError, privacy: .public)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
switch clientNotification.payloadVariant {
|
||||
case .lowEntropyKey, .duplicatedPublicKey:
|
||||
path = "meshtastic:///settings/security"
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: Look at this to see if LocationManager should be singleton
|
||||
let manager = LocalNotificationManager()
|
||||
manager.notifications = [
|
||||
Notification(
|
||||
id: UUID().uuidString,
|
||||
title: "Firmware Notification".localized,
|
||||
subtitle: "\(clientNotification.level)".capitalized,
|
||||
content: clientNotification.message,
|
||||
target: "settings",
|
||||
path: path
|
||||
)
|
||||
]
|
||||
manager.schedule()
|
||||
Logger.services.error("⚠️ Client Notification: \(clientNotification.message, privacy: .public)")
|
||||
}
|
||||
|
||||
func handleMyInfo(_ myNodeInfo: MyNodeInfo) {
|
||||
// TODO: this works for connections like BLE that have a uniqueId, but what about ones like serial?
|
||||
guard let connectedDeviceId = activeConnection?.device.id.uuidString else {
|
||||
Logger.services.error("⚠️ Failed to decode MyInfo, no connected device ID")
|
||||
return
|
||||
}
|
||||
Logger.services.info("handleMyInfo: \(myNodeInfo.debugDescription)")
|
||||
|
||||
updateDevice(key: \.num, value: Int64(myNodeInfo.myNodeNum))
|
||||
|
||||
if let myInfo = myInfoPacket(myInfo: myNodeInfo, peripheralId: connectedDeviceId, context: context) {
|
||||
if let bleName = myInfo.bleName {
|
||||
updateDevice(key: \.name, value: bleName)
|
||||
updateDevice(key: \.longName, value: bleName)
|
||||
}
|
||||
|
||||
if myNodeInfo.nodedbCount > 0 {
|
||||
expectedNodeDBSize = Int(myNodeInfo.nodedbCount)
|
||||
}
|
||||
|
||||
UserDefaults.preferredPeripheralNum = Int(myInfo.myNodeNum)
|
||||
let newConnection = Int64(UserDefaults.preferredPeripheralNum) != Int64(myInfo.myNodeNum)
|
||||
if newConnection {
|
||||
// Onboard a new device connection here
|
||||
}
|
||||
}
|
||||
tryClearExistingChannels()
|
||||
|
||||
}
|
||||
|
||||
func handleNodeInfo(_ nodeInfo: NodeInfo) {
|
||||
if let continuation = self.firstDatabaseNodeInfoContinuation {
|
||||
continuation.resume()
|
||||
self.firstDatabaseNodeInfoContinuation = nil
|
||||
}
|
||||
|
||||
guard nodeInfo.num > 0 else {
|
||||
Logger.services.error("NodeInfo packet with a zero nodeNum")
|
||||
return
|
||||
}
|
||||
|
||||
// TODO: nodeInfoPacket's channel: parameter is not used
|
||||
if let nodeInfo = nodeInfoPacket(nodeInfo: nodeInfo, channel: 0, context: context) {
|
||||
if let activeDevice = activeConnection?.device, activeDevice.num == nodeInfo.num {
|
||||
if let user = nodeInfo.user {
|
||||
updateDevice(deviceId: activeDevice.id, key: \.shortName, value: user.shortName ?? "?")
|
||||
updateDevice(deviceId: activeDevice.id, key: \.longName, value: user.longName ?? "Unknown".localized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bump the nodeCount
|
||||
if case let .retrievingDatabase(nodeCount: nodeCount) = self.state {
|
||||
updateState(.retrievingDatabase(nodeCount: nodeCount+1))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func handleChannel(_ channel: Channel) {
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.data.error("Attempt to process channel information when no connected device.")
|
||||
return
|
||||
}
|
||||
|
||||
channelPacket(channel: channel, fromNum: Int64(truncatingIfNeeded: deviceNum), context: context)
|
||||
|
||||
}
|
||||
|
||||
func handleConfig(_ config: Config) {
|
||||
guard let device = activeConnection?.device, let deviceNum = device.num, let longName = device.longName else {
|
||||
Logger.data.error("Attempt to process channel information when no connected device.")
|
||||
return
|
||||
}
|
||||
|
||||
// Local config parses out the variants. Should we do that here maybe?
|
||||
localConfig(config: config, context: context, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleModuleConfig(_ moduleConfigPacket: ModuleConfig) {
|
||||
guard let device = activeConnection?.device, let deviceNum = device.num, let longName = device.longName else {
|
||||
Logger.services.error("Attempt to process channel information when no connected device.")
|
||||
return
|
||||
}
|
||||
moduleConfig(config: moduleConfigPacket, context: context, nodeNum: Int64(truncatingIfNeeded: deviceNum), nodeLongName: longName)
|
||||
// Get Canned Message Message List if the Module is Canned Messages
|
||||
if moduleConfigPacket.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(moduleConfigPacket.cannedMessage) {
|
||||
try? getCannedMessageModuleMessages(destNum: deviceNum, wantResponse: true)
|
||||
}
|
||||
}
|
||||
|
||||
func handleDeviceMetadata(_ metadata: DeviceMetadata) {
|
||||
// Note: moved firmware version check to be inline with connection process
|
||||
guard let device = activeConnection?.device, let deviceNum = device.num else {
|
||||
Logger.services.error("Attempt to process device metadata information when no connected device.")
|
||||
return
|
||||
}
|
||||
|
||||
Logger.transport.debug("[Version] handleDeviceMetadata returned version: \(metadata.firmwareVersion)")
|
||||
|
||||
updateDevice(key: \.firmwareVersion, value: metadata.firmwareVersion)
|
||||
|
||||
deviceMetadataPacket(metadata: metadata, fromNum: deviceNum, context: context)
|
||||
}
|
||||
|
||||
internal func tryClearExistingChannels() {
|
||||
guard let device = activeConnection?.device, let deviceNum = device.num else {
|
||||
Logger.services.error("Attempt to clear existing channels when no connected device.")
|
||||
return
|
||||
}
|
||||
|
||||
// Before we get started delete the existing channels from the myNodeInfo
|
||||
let fetchMyInfoRequest = MyInfoEntity.fetchRequest()
|
||||
fetchMyInfoRequest.predicate = NSPredicate(format: "myNodeNum == %lld", Int64(deviceNum))
|
||||
|
||||
do {
|
||||
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
|
||||
if fetchedMyInfo.count == 1 {
|
||||
let mutableChannels = fetchedMyInfo[0].channels?.mutableCopy() as? NSMutableOrderedSet
|
||||
mutableChannels?.removeAllObjects()
|
||||
fetchedMyInfo[0].channels = mutableChannels
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
Logger.data.error("Failed to clear existing channels from local app database: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.data.error("Failed to find a node MyInfo to save these channels to: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func handleTextMessageAppPacket(_ packet: MeshPacket) {
|
||||
guard let device = activeConnection?.device, let deviceNum = device.num else {
|
||||
Logger.services.error("Attempt to handle text message when no connected device.")
|
||||
return
|
||||
}
|
||||
|
||||
textMessageAppPacket(
|
||||
packet: packet,
|
||||
wantRangeTestPackets: wantRangeTestPackets,
|
||||
connectedNode: deviceNum,
|
||||
context: context,
|
||||
appState: appState
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
func storeAndForwardPacket(packet: MeshPacket, connectedNodeNum: Int64) {
|
||||
if let storeAndForwardMessage = try? StoreAndForward(serializedBytes: packet.decoded.payload) {
|
||||
// Handle each of the store and forward request / response messages
|
||||
switch storeAndForwardMessage.rr {
|
||||
case .unset:
|
||||
Logger.mesh.info("\("📮 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerError:
|
||||
Logger.mesh.info("\("☠️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerHeartbeat:
|
||||
/// When we get a router heartbeat we know there is a store and forward node on the network
|
||||
/// Check if it is the primary S&F Router and save the timestamp of the last heartbeat so that we can show the request message history menu item on node long press if the router has been seen recently
|
||||
if storeAndForwardMessage.heartbeat.secondary == 0 {
|
||||
|
||||
guard let routerNode = getNodeInfo(id: Int64(packet.from), context: context) else {
|
||||
return
|
||||
}
|
||||
if routerNode.storeForwardConfig != nil {
|
||||
routerNode.storeForwardConfig?.enabled = true
|
||||
routerNode.storeForwardConfig?.isRouter = storeAndForwardMessage.heartbeat.secondary == 0
|
||||
routerNode.storeForwardConfig?.lastHeartbeat = Date()
|
||||
} else {
|
||||
let newConfig = StoreForwardConfigEntity(context: context)
|
||||
newConfig.enabled = true
|
||||
newConfig.isRouter = storeAndForwardMessage.heartbeat.secondary == 0
|
||||
newConfig.lastHeartbeat = Date()
|
||||
routerNode.storeForwardConfig = newConfig
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("Save Store and Forward Router Error")
|
||||
}
|
||||
}
|
||||
Logger.mesh.info("\("💓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerPing:
|
||||
Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerPong:
|
||||
Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerBusy:
|
||||
Logger.mesh.info("\("🐝 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerHistory:
|
||||
/// Set the Router History Last Request Value
|
||||
guard let routerNode = getNodeInfo(id: Int64(packet.from), context: context) else {
|
||||
return
|
||||
}
|
||||
if routerNode.storeForwardConfig != nil {
|
||||
routerNode.storeForwardConfig?.lastRequest = Int32(storeAndForwardMessage.history.lastRequest)
|
||||
} else {
|
||||
let newConfig = StoreForwardConfigEntity(context: context)
|
||||
newConfig.lastRequest = Int32(storeAndForwardMessage.history.lastRequest)
|
||||
routerNode.storeForwardConfig = newConfig
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("Save Store and Forward Router Error")
|
||||
}
|
||||
Logger.mesh.info("\("📜 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerStats:
|
||||
Logger.mesh.info("\("📊 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .clientError:
|
||||
Logger.mesh.info("\("☠️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .clientHistory:
|
||||
Logger.mesh.info("\("📜 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .clientStats:
|
||||
Logger.mesh.info("\("📊 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .clientPing:
|
||||
Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .clientPong:
|
||||
Logger.mesh.info("\("🏓 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .clientAbort:
|
||||
Logger.mesh.info("\("🛑 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .UNRECOGNIZED:
|
||||
Logger.mesh.info("\("📮 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
case .routerTextDirect:
|
||||
Logger.mesh.info("\("💬 Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
textMessageAppPacket(
|
||||
packet: packet,
|
||||
wantRangeTestPackets: false,
|
||||
connectedNode: connectedNodeNum,
|
||||
storeForward: true,
|
||||
context: context,
|
||||
appState: appState
|
||||
)
|
||||
case .routerTextBroadcast:
|
||||
Logger.mesh.info("\("✉️ Store and Forward \(storeAndForwardMessage.rr) message received from \(packet.from.toHex())")")
|
||||
textMessageAppPacket(
|
||||
packet: packet,
|
||||
wantRangeTestPackets: false,
|
||||
connectedNode: connectedNodeNum,
|
||||
storeForward: true,
|
||||
context: context,
|
||||
appState: appState
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handleTraceRouteApp(_ packet: MeshPacket) {
|
||||
guard let device = activeConnection?.device, let deviceNum = device.num else {
|
||||
Logger.services.error("Attempt to handle text message when no connected device.")
|
||||
return
|
||||
}
|
||||
|
||||
if let routingMessage = try? RouteDiscovery(serializedBytes: packet.decoded.payload) {
|
||||
let traceRoute = getTraceRoute(id: Int64(packet.decoded.requestID), context: context)
|
||||
traceRoute?.response = true
|
||||
guard let connectedNode = getNodeInfo(id: Int64(deviceNum), context: context) else {
|
||||
return
|
||||
}
|
||||
var hopNodes: [TraceRouteHopEntity] = []
|
||||
let connectedHop = TraceRouteHopEntity(context: context)
|
||||
connectedHop.time = Date()
|
||||
connectedHop.num = deviceNum
|
||||
connectedHop.name = connectedNode.user?.longName ?? "???"
|
||||
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
|
||||
connectedHop.snr = Float(routingMessage.snrBack.last ?? -128) / 4
|
||||
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
|
||||
connectedHop.altitude = mostRecent.altitude
|
||||
connectedHop.latitudeI = mostRecent.latitudeI
|
||||
connectedHop.longitudeI = mostRecent.longitudeI
|
||||
traceRoute?.hasPositions = true
|
||||
}
|
||||
var routeString = "\(connectedNode.user?.longName ?? "???") --> "
|
||||
hopNodes.append(connectedHop)
|
||||
traceRoute?.hopsTowards = Int32(routingMessage.route.count)
|
||||
for (index, node) in routingMessage.route.enumerated() {
|
||||
var hopNode = getNodeInfo(id: Int64(node), context: context)
|
||||
if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 {
|
||||
hopNode = createNodeInfo(num: Int64(node), context: context)
|
||||
}
|
||||
let traceRouteHop = TraceRouteHopEntity(context: context)
|
||||
traceRouteHop.time = Date()
|
||||
if routingMessage.snrTowards.count >= index + 1 {
|
||||
traceRouteHop.snr = Float(routingMessage.snrTowards[index]) / 4
|
||||
} else {
|
||||
// If no snr in route, set unknown
|
||||
traceRouteHop.snr = -32
|
||||
}
|
||||
if let hn = hopNode, hn.hasPositions {
|
||||
if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
|
||||
traceRouteHop.altitude = mostRecent.altitude
|
||||
traceRouteHop.latitudeI = mostRecent.latitudeI
|
||||
traceRouteHop.longitudeI = mostRecent.longitudeI
|
||||
traceRoute?.hasPositions = true
|
||||
}
|
||||
}
|
||||
traceRouteHop.num = hopNode?.num ?? 0
|
||||
if hopNode != nil {
|
||||
if packet.rxTime > 0 {
|
||||
hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime)))
|
||||
}
|
||||
}
|
||||
hopNodes.append(traceRouteHop)
|
||||
|
||||
let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "Unknown".localized))
|
||||
let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : ""
|
||||
let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized
|
||||
routeString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> "
|
||||
}
|
||||
let destinationHop = TraceRouteHopEntity(context: context)
|
||||
destinationHop.name = traceRoute?.node?.user?.longName ?? "Unknown".localized
|
||||
destinationHop.time = Date()
|
||||
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
|
||||
destinationHop.snr = Float(routingMessage.snrTowards.last ?? -128) / 4
|
||||
destinationHop.num = traceRoute?.node?.num ?? 0
|
||||
if let mostRecent = traceRoute?.node?.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
|
||||
destinationHop.altitude = mostRecent.altitude
|
||||
destinationHop.latitudeI = mostRecent.latitudeI
|
||||
destinationHop.longitudeI = mostRecent.longitudeI
|
||||
traceRoute?.hasPositions = true
|
||||
}
|
||||
hopNodes.append(destinationHop)
|
||||
/// Add the destination node to the end of the route towards string and the beginning of the route back string
|
||||
routeString += "\(traceRoute?.node?.user?.longName ?? "Unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) (\(destinationHop.snr != -32 ? String(destinationHop.snr) : "unknown ".localized)dB)"
|
||||
traceRoute?.routeText = routeString
|
||||
// Default to -1 only fill in if routeBack is valid below
|
||||
traceRoute?.hopsBack = -1
|
||||
// Only if hopStart is set and there is an SNR entry
|
||||
if packet.hopStart > 0 && routingMessage.snrBack.count > 0 {
|
||||
traceRoute?.hopsBack = Int32(routingMessage.routeBack.count)
|
||||
var routeBackString = "\(traceRoute?.node?.user?.longName ?? "Unknown".localized) \((traceRoute?.node?.num ?? 0).toHex()) --> "
|
||||
for (index, node) in routingMessage.routeBack.enumerated() {
|
||||
var hopNode = getNodeInfo(id: Int64(node), context: context)
|
||||
if hopNode == nil && hopNode?.num ?? 0 > 0 && node != 4294967295 {
|
||||
hopNode = createNodeInfo(num: Int64(node), context: context)
|
||||
}
|
||||
let traceRouteHop = TraceRouteHopEntity(context: context)
|
||||
traceRouteHop.time = Date()
|
||||
traceRouteHop.back = true
|
||||
if routingMessage.snrBack.count >= index + 1 {
|
||||
traceRouteHop.snr = Float(routingMessage.snrBack[index]) / 4
|
||||
} else {
|
||||
// If no snr in route, set to unknown
|
||||
traceRouteHop.snr = -32
|
||||
}
|
||||
if let hn = hopNode, hn.hasPositions {
|
||||
if let mostRecent = hn.positions?.lastObject as? PositionEntity, mostRecent.time! >= Calendar.current.date(byAdding: .hour, value: -24, to: Date())! {
|
||||
traceRouteHop.altitude = mostRecent.altitude
|
||||
traceRouteHop.latitudeI = mostRecent.latitudeI
|
||||
traceRouteHop.longitudeI = mostRecent.longitudeI
|
||||
traceRoute?.hasPositions = true
|
||||
}
|
||||
}
|
||||
traceRouteHop.num = hopNode?.num ?? 0
|
||||
if hopNode != nil {
|
||||
if packet.rxTime > 0 {
|
||||
hopNode?.lastHeard = Date(timeIntervalSince1970: TimeInterval(Int64(packet.rxTime)))
|
||||
}
|
||||
}
|
||||
hopNodes.append(traceRouteHop)
|
||||
|
||||
let hopName = hopNode?.user?.longName ?? (node == 4294967295 ? "Repeater" : String(hopNode?.num.toHex() ?? "Unknown".localized))
|
||||
let mqttLabel = hopNode?.viaMqtt ?? false ? "MQTT " : ""
|
||||
let snrLabel = (traceRouteHop.snr != -32) ? String(traceRouteHop.snr) : "unknown ".localized
|
||||
routeBackString += "\(hopName) \(mqttLabel)(\(snrLabel)dB) --> "
|
||||
}
|
||||
// If nil, set to unknown, INT8_MIN (-128) then divide by 4
|
||||
let snrBackLast = Float(routingMessage.snrBack.last ?? -128) / 4
|
||||
routeBackString += "\(connectedNode.user?.longName ?? String(connectedNode.num.toHex())) (\(snrBackLast != -32 ? String(snrBackLast) : "unknown ".localized)dB)"
|
||||
traceRoute?.routeBackText = routeBackString
|
||||
}
|
||||
traceRoute?.hops = NSOrderedSet(array: hopNodes)
|
||||
traceRoute?.time = Date()
|
||||
|
||||
if let tr = traceRoute {
|
||||
let manager = LocalNotificationManager()
|
||||
manager.notifications = [
|
||||
Notification(
|
||||
id: (UUID().uuidString),
|
||||
title: "Traceroute Complete",
|
||||
subtitle: "TR received back from \(destinationHop.name ?? "unknown")",
|
||||
content: "Hops from: \(tr.hopsTowards), Hops back: \(tr.hopsBack)\n\(tr.routeText ?? "Unknown".localized)\n\(tr.routeBackText ?? "Unknown".localized)",
|
||||
target: "nodes",
|
||||
path: "meshtastic:///nodes?nodenum=\(tr.node?.num ?? 0)"
|
||||
)
|
||||
]
|
||||
manager.schedule()
|
||||
}
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
Logger.data.info("💾 Saved Trace Route")
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
Logger.data.error("Error Updating Core Data TraceRouteHop: \(nsError, privacy: .public)")
|
||||
}
|
||||
let logString = String.localizedStringWithFormat("Trace Route request returned: %@".localized, routeString)
|
||||
Logger.mesh.info("🪧 \(logString, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
//
|
||||
// AccessoryManager+MQTT.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/18/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CocoaMQTT
|
||||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
|
||||
extension AccessoryManager {
|
||||
|
||||
func initializeMqtt() async {
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.services.error("Attempt to initialize MQTT without an active connection")
|
||||
return
|
||||
}
|
||||
|
||||
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
|
||||
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(deviceNum))
|
||||
do {
|
||||
let fetchedNodeInfo = try context.fetch(fetchNodeInfoRequest)
|
||||
if fetchedNodeInfo.count == 1 {
|
||||
// Subscribe to Mqtt Client Proxy if enabled
|
||||
if fetchedNodeInfo[0].mqttConfig != nil && fetchedNodeInfo[0].mqttConfig?.enabled ?? false && fetchedNodeInfo[0].mqttConfig?.proxyToClientEnabled ?? false {
|
||||
mqttManager.connectFromConfigSettings(node: fetchedNodeInfo[0])
|
||||
} else {
|
||||
if mqttProxyConnected {
|
||||
mqttManager.mqttClientProxy?.disconnect()
|
||||
}
|
||||
}
|
||||
// Set initial unread message badge states
|
||||
appState.unreadChannelMessages = fetchedNodeInfo[0].myInfo?.unreadMessages ?? 0
|
||||
appState.unreadDirectMessages = fetchedNodeInfo[0].user?.unreadMessages ?? 0
|
||||
}
|
||||
if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].rangeTestConfig?.enabled == true {
|
||||
wantRangeTestPackets = true
|
||||
}
|
||||
if fetchedNodeInfo.count == 1 && fetchedNodeInfo[0].storeForwardConfig?.enabled == true {
|
||||
wantStoreAndForwardPackets = true
|
||||
}
|
||||
} catch {
|
||||
Logger.data.error("Failed to find a node info for the connected node \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// MARK: MqttClientProxyManagerDelegate Methods
|
||||
func onMqttConnected() {
|
||||
mqttProxyConnected = true
|
||||
mqttError = ""
|
||||
Logger.services.info("📲 [MQTT Client Proxy] onMqttConnected now subscribing to \(self.mqttManager.topic, privacy: .public).")
|
||||
mqttManager.mqttClientProxy?.subscribe(mqttManager.topic)
|
||||
}
|
||||
|
||||
func onMqttDisconnected() {
|
||||
mqttProxyConnected = false
|
||||
Logger.services.info("📲 MQTT Disconnected")
|
||||
}
|
||||
|
||||
func onMqttMessageReceived(message: CocoaMQTTMessage) {
|
||||
if message.topic.contains("/stat/") {
|
||||
return
|
||||
}
|
||||
var proxyMessage = MqttClientProxyMessage()
|
||||
proxyMessage.topic = message.topic
|
||||
proxyMessage.data = Data(message.payload)
|
||||
proxyMessage.retained = message.retained
|
||||
|
||||
var toRadio: ToRadio!
|
||||
toRadio = ToRadio()
|
||||
toRadio.mqttClientProxyMessage = proxyMessage
|
||||
Task {
|
||||
try? await self.send(toRadio)
|
||||
}
|
||||
}
|
||||
|
||||
func onMqttError(message: String) {
|
||||
mqttProxyConnected = false
|
||||
mqttError = message
|
||||
Logger.services.info("📲 [MQTT Client Proxy] onMqttError: \(message, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
//
|
||||
// AccessoryManager+Position.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/24/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
import CoreLocation
|
||||
|
||||
extension AccessoryManager {
|
||||
func initializeLocationProvider() {
|
||||
self.locationTask = Task {
|
||||
repeat {
|
||||
try? await Task.sleep(for: .seconds(30)) // sleep for 30 seconds. This throws if task is cancelled
|
||||
|
||||
guard let fromNodeNum = activeConnection?.device.num else {
|
||||
return
|
||||
}
|
||||
|
||||
if UserDefaults.provideLocation {
|
||||
_ = try await sendPosition(channel: 0, destNum: fromNodeNum, wantResponse: false)
|
||||
}
|
||||
} while !Task.isCancelled
|
||||
}
|
||||
}
|
||||
|
||||
public func sendPosition(channel: Int32, destNum: Int64, wantResponse: Bool) async throws {
|
||||
guard let fromNodeNum = activeConnection?.device.num else {
|
||||
throw AccessoryError.ioFailed("Not connected to any device")
|
||||
}
|
||||
|
||||
guard let positionPacket = try await getPositionFromPhoneGPS(destNum: destNum, fixedPosition: false) else {
|
||||
Logger.services.error("Unable to get position data from device GPS to send to node")
|
||||
throw AccessoryError.appError("Unable to get position data from device GPS to send to node")
|
||||
}
|
||||
|
||||
var meshPacket = MeshPacket()
|
||||
meshPacket.to = UInt32(destNum)
|
||||
meshPacket.channel = UInt32(channel)
|
||||
meshPacket.from = UInt32(fromNodeNum)
|
||||
var dataMessage = DataMessage()
|
||||
if let serializedData: Data = try? positionPacket.serializedData() {
|
||||
dataMessage.payload = serializedData
|
||||
dataMessage.portnum = PortNum.positionApp
|
||||
dataMessage.wantResponse = wantResponse
|
||||
meshPacket.decoded = dataMessage
|
||||
} else {
|
||||
Logger.services.error("Failed to serialize position packet data")
|
||||
throw AccessoryError.ioFailed("sendPosition: Unable to serialize position packet data")
|
||||
}
|
||||
|
||||
var toRadio: ToRadio!
|
||||
toRadio = ToRadio()
|
||||
toRadio.packet = meshPacket
|
||||
try await self.send(toRadio)
|
||||
}
|
||||
|
||||
public func getPositionFromPhoneGPS(destNum: Int64, fixedPosition: Bool) async throws -> Position? {
|
||||
var positionPacket = Position()
|
||||
|
||||
guard let lastLocation = LocationsHandler.shared.locationsArray.last else {
|
||||
return nil
|
||||
}
|
||||
|
||||
if lastLocation == CLLocation(latitude: 0, longitude: 0) {
|
||||
return nil
|
||||
}
|
||||
|
||||
positionPacket.latitudeI = Int32(lastLocation.coordinate.latitude * 1e7)
|
||||
positionPacket.longitudeI = Int32(lastLocation.coordinate.longitude * 1e7)
|
||||
let timestamp = lastLocation.timestamp
|
||||
positionPacket.time = UInt32(timestamp.timeIntervalSince1970)
|
||||
positionPacket.timestamp = UInt32(timestamp.timeIntervalSince1970)
|
||||
positionPacket.altitude = Int32(lastLocation.altitude)
|
||||
positionPacket.satsInView = UInt32(LocationsHandler.satsInView)
|
||||
let currentSpeed = lastLocation.speed
|
||||
if currentSpeed > 0 && (!currentSpeed.isNaN || !currentSpeed.isInfinite) {
|
||||
positionPacket.groundSpeed = UInt32(currentSpeed)
|
||||
}
|
||||
let currentHeading = lastLocation.course
|
||||
if (currentHeading > 0 && currentHeading <= 360) && (!currentHeading.isNaN || !currentHeading.isInfinite) {
|
||||
positionPacket.groundTrack = UInt32(currentHeading)
|
||||
}
|
||||
/// Set location source for time
|
||||
if !fixedPosition {
|
||||
/// From GPS treat time as good
|
||||
positionPacket.locationSource = Position.LocSource.locExternal
|
||||
} else {
|
||||
/// From GPS, but time can be old and have drifted
|
||||
positionPacket.locationSource = Position.LocSource.locManual
|
||||
}
|
||||
return positionPacket
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
734
Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
Normal file
734
Meshtastic/Accessory/Accessory Manager/AccessoryManager.swift
Normal file
|
|
@ -0,0 +1,734 @@
|
|||
//
|
||||
// AccessoryManager.swift
|
||||
// Created by Jake Bordens on 7/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import MeshtasticProtobufs
|
||||
import OSLog
|
||||
import CocoaMQTT
|
||||
import Combine
|
||||
|
||||
enum AccessoryError: Error, LocalizedError {
|
||||
case discoveryFailed(String)
|
||||
case connectionFailed(String)
|
||||
case versionMismatch(String)
|
||||
case ioFailed(String)
|
||||
case appError(String)
|
||||
case timeout
|
||||
case disconnected(String)
|
||||
case tooManyRetries
|
||||
case eventStreamCancelled
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .discoveryFailed(let message):
|
||||
return "Discovery failed. \(message)"
|
||||
case .connectionFailed(let message):
|
||||
return "Connection failed. \(message)"
|
||||
case .versionMismatch(let message):
|
||||
return "Version mismatch: \(message)"
|
||||
case .ioFailed(let message):
|
||||
return "Communication failure: \(message)"
|
||||
case .appError(let message):
|
||||
return "Application error: \(message)"
|
||||
case .timeout:
|
||||
return "Timeout"
|
||||
case .disconnected(let message):
|
||||
return "Disconnected: \(message)"
|
||||
case .tooManyRetries:
|
||||
return "Too Many Retries"
|
||||
case .eventStreamCancelled:
|
||||
return "Event stream cancelled"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum AccessoryManagerState: Equatable {
|
||||
case uninitialized
|
||||
case idle
|
||||
case discovering
|
||||
case connecting
|
||||
case retrying(attempt: Int)
|
||||
case retrievingDatabase(nodeCount: Int)
|
||||
case communicating
|
||||
case subscribed
|
||||
|
||||
var description: String {
|
||||
switch self {
|
||||
case .uninitialized:
|
||||
return "Uninitialized"
|
||||
case .idle:
|
||||
return "Idle"
|
||||
case .discovering:
|
||||
return "Discovering"
|
||||
case .connecting:
|
||||
return "Connecting"
|
||||
case .retrying(let attempt):
|
||||
return "Retrying Connection (\(attempt))"
|
||||
case .communicating:
|
||||
return "Communicating"
|
||||
case .subscribed:
|
||||
return "Subscribed"
|
||||
case .retrievingDatabase(let nodeCount):
|
||||
return "Retreiving nodes \(nodeCount)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
class AccessoryManager: ObservableObject, MqttClientProxyManagerDelegate {
|
||||
// Singleton Access. Conditionally compiled
|
||||
#if targetEnvironment(macCatalyst)
|
||||
static let shared = AccessoryManager(transports: [BLETransport(), TCPTransport(), SerialTransport()])
|
||||
#else
|
||||
static let shared = AccessoryManager(transports: [BLETransport(), TCPTransport()])
|
||||
#endif
|
||||
|
||||
// Constants
|
||||
let NONCE_ONLY_CONFIG = 69420
|
||||
let NONCE_ONLY_DB = 69421
|
||||
let minimumVersion = "2.3.15"
|
||||
|
||||
// Global Objects
|
||||
// Chicken/Egg problem. Set in the App object immediately after
|
||||
// AppState and AccessoryManager are created
|
||||
var appState: AppState!
|
||||
let context = PersistenceController.shared.container.viewContext
|
||||
let mqttManager = MqttClientProxyManager.shared
|
||||
|
||||
// Published Stuff
|
||||
@Published var mqttProxyConnected: Bool = false
|
||||
@Published var devices: [Device] = []
|
||||
@Published var state: AccessoryManagerState
|
||||
@Published var mqttError: String = ""
|
||||
@Published var activeDeviceNum: Int64?
|
||||
@Published var allowDisconnect = false
|
||||
@Published var lastConnectionError: Error?
|
||||
@Published var isConnected: Bool = false
|
||||
@Published var isConnecting: Bool = false
|
||||
|
||||
var activeConnection: (device: Device, connection: any Connection)?
|
||||
|
||||
let transports: [any Transport]
|
||||
|
||||
// Config
|
||||
public var wantRangeTestPackets = true
|
||||
var wantStoreAndForwardPackets = false
|
||||
var shouldAutomaticallyConnectToPreferredPeripheral = true
|
||||
|
||||
// Conncetion process
|
||||
var connectionSteps: SequentialSteps?
|
||||
|
||||
// Public due to file separation
|
||||
var discoveryTask: Task<Void, Never>?
|
||||
var connectionEventTask: Task <Void, Error>?
|
||||
var locationTask: Task<Void, Error>?
|
||||
var connectionStepper: SequentialSteps?
|
||||
|
||||
// Flash subjects
|
||||
@Published var packetsSent: Int = 0
|
||||
@Published var packetsReceived: Int = 0
|
||||
|
||||
// Continuations
|
||||
var wantConfigContinuation: CheckedContinuation<Void, Error>?
|
||||
var firstDatabaseNodeInfoContinuation: CheckedContinuation<Void, Error>?
|
||||
var wantDatabaseGate: AsyncGate = AsyncGate()
|
||||
|
||||
// Misc
|
||||
@Published var expectedNodeDBSize: Int?
|
||||
|
||||
var heartbeatTimer: ResettableTimer?
|
||||
var heartbeatResponseTimer: ResettableTimer?
|
||||
|
||||
init(transports: [any Transport] = [BLETransport(), TCPTransport()]) {
|
||||
self.transports = transports
|
||||
self.state = .uninitialized
|
||||
self.mqttManager.delegate = self
|
||||
}
|
||||
|
||||
func transportForType(_ type: TransportType) -> Transport? {
|
||||
return transports.first(where: {$0.type == type })
|
||||
}
|
||||
|
||||
func connectToPreferredDevice() {
|
||||
if !self.isConnected && !self.isConnecting,
|
||||
let preferredDevice = self.devices.first(where: { $0.id.uuidString == UserDefaults.preferredPeripheralId }) {
|
||||
Task { try await self.connect(to: preferredDevice) }
|
||||
}
|
||||
}
|
||||
|
||||
func sendWantConfig() async throws {
|
||||
if let inProgressWantConfigContinuation = wantConfigContinuation {
|
||||
Logger.transport.info("[Accessory] Existing continuation for wantConfig(Config). Cancelling.")
|
||||
inProgressWantConfigContinuation.resume(throwing: CancellationError())
|
||||
wantConfigContinuation = nil
|
||||
}
|
||||
guard let connection = activeConnection?.connection else {
|
||||
Logger.transport.error("Unable to send wantConfig (config): No device connected")
|
||||
return
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler {
|
||||
var toRadio: ToRadio = ToRadio()
|
||||
toRadio.wantConfigID = UInt32(NONCE_ONLY_CONFIG)
|
||||
try await self.send(toRadio)
|
||||
try await connection.startDrainPendingPackets()
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.wantConfigContinuation = cont
|
||||
}
|
||||
self.wantConfigContinuation = nil
|
||||
Logger.transport.info("✅ [Accessory] NONCE_ONLY_CONFIG Done")
|
||||
} onCancel: {
|
||||
Task { @MainActor in
|
||||
wantConfigContinuation?.resume(throwing: CancellationError())
|
||||
wantConfigContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func sendWantDatabase() async throws {
|
||||
if let firstDatabaseNodeInfoContinuation = firstDatabaseNodeInfoContinuation {
|
||||
Logger.transport.info("[Accessory] Existing continuation for firstDatabaseNodeInfo. Cancelling.")
|
||||
firstDatabaseNodeInfoContinuation.resume(throwing: CancellationError())
|
||||
self.firstDatabaseNodeInfoContinuation = nil
|
||||
}
|
||||
|
||||
guard let connection = activeConnection?.connection else {
|
||||
Logger.transport.error("Unable to send wantConfig (Database): No device connected")
|
||||
return
|
||||
}
|
||||
|
||||
try await withTaskCancellationHandler {
|
||||
var toRadio: ToRadio = ToRadio()
|
||||
toRadio.wantConfigID = UInt32(NONCE_ONLY_DB)
|
||||
try await self.send(toRadio)
|
||||
try await connection.startDrainPendingPackets()
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
firstDatabaseNodeInfoContinuation = cont
|
||||
}
|
||||
firstDatabaseNodeInfoContinuation = nil
|
||||
Logger.transport.info("✅ [Accessory] NONCE_ONLY_DB first NodeInfo received.")
|
||||
} onCancel: {
|
||||
Task { @MainActor in
|
||||
firstDatabaseNodeInfoContinuation?.resume(throwing: CancellationError())
|
||||
firstDatabaseNodeInfoContinuation = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func waitForWantDatabaseResponse() async throws {
|
||||
try await wantDatabaseGate.wait()
|
||||
}
|
||||
|
||||
// Fully tears down a connection and sets up the AccessoryManager for the next.
|
||||
// If you are calling this in response to an error, then you should have
|
||||
// exposed the error to the UI or handled the error prior to calling this.
|
||||
func closeConnection() async throws {
|
||||
Logger.transport.debug("[AccessoryManager] received disconnect request")
|
||||
|
||||
if let activeConnection {
|
||||
updateDevice(deviceId: activeConnection.device.id, key: \.connectionState, value: .disconnected)
|
||||
self.activeConnection = nil
|
||||
}
|
||||
|
||||
connectionEventTask?.cancel()
|
||||
connectionEventTask = nil
|
||||
|
||||
locationTask?.cancel()
|
||||
locationTask = nil
|
||||
|
||||
await heartbeatTimer?.cancel(withReason: "Closing connection")
|
||||
await heartbeatResponseTimer?.cancel(withReason: "Closing connection")
|
||||
heartbeatTimer = nil
|
||||
heartbeatResponseTimer = nil
|
||||
|
||||
// Clean up continuations
|
||||
wantConfigContinuation?.resume(throwing: CancellationError())
|
||||
wantConfigContinuation = nil
|
||||
firstDatabaseNodeInfoContinuation?.resume(throwing: CancellationError())
|
||||
firstDatabaseNodeInfoContinuation = nil
|
||||
|
||||
await wantDatabaseGate.cancelAll()
|
||||
await wantDatabaseGate.reset()
|
||||
|
||||
// Turn off the disconnect buttons
|
||||
allowDisconnect = false
|
||||
self.startDiscovery()
|
||||
}
|
||||
|
||||
// Should only be called by UI-facing callers.
|
||||
func disconnect() async throws {
|
||||
// Cancel ongoing connection task if it exists
|
||||
await self.connectionStepper?.cancel()
|
||||
|
||||
// Close out the connection
|
||||
if let activeConnection = activeConnection {
|
||||
try await activeConnection.connection.disconnect(withError: nil, shouldReconnect: false)
|
||||
}
|
||||
}
|
||||
|
||||
// Update device attributes on MainActor for presentation in the UI
|
||||
func updateDevice<T>(deviceId: UUID? = nil, key: WritableKeyPath<Device, T>, value: T) where T: Equatable {
|
||||
guard let deviceId = deviceId ?? self.activeConnection?.device.id else {
|
||||
Logger.transport.error("updateDevice<T> with nil deviceId")
|
||||
return
|
||||
}
|
||||
|
||||
// Update the active device
|
||||
if let activeConnection {
|
||||
var device = activeConnection.device
|
||||
if device[keyPath: key] != value {
|
||||
// Update the @Published stuff for the UI
|
||||
self.objectWillChange.send()
|
||||
|
||||
device[keyPath: key] = value
|
||||
self.activeConnection = (device: device, connection: activeConnection.connection)
|
||||
self.activeDeviceNum = device.num
|
||||
}
|
||||
}
|
||||
|
||||
// Update the device in the devices array if it exists
|
||||
if let index = devices.firstIndex(where: { $0.id == deviceId }) {
|
||||
var device = devices[index]
|
||||
device[keyPath: key] = value
|
||||
if device[keyPath: key] != value {
|
||||
// Update the @Published stuff for the UI
|
||||
self.objectWillChange.send()
|
||||
|
||||
if let index = devices.firstIndex(where: { $0.id == deviceId }) {
|
||||
devices[index] = device
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Durring active connections, this discover list will be empty, so this is expected.
|
||||
// Logger.transport.error("Device with ID \(deviceId) not found in devices list.")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// Update state on MainActor for presentation in the UI
|
||||
func updateState(_ newState: AccessoryManagerState) {
|
||||
#if DEBUG
|
||||
Logger.transport.info("🔗 Updating state from \(self.state.description, privacy: .public) to \(newState.description, privacy: .public)")
|
||||
#endif
|
||||
switch newState {
|
||||
case .uninitialized, .idle, .discovering:
|
||||
self.isConnected = false
|
||||
self.isConnecting = false
|
||||
case .connecting, .communicating, .retrying:
|
||||
self.isConnected = false
|
||||
self.isConnecting = true
|
||||
case .subscribed, .retrievingDatabase:
|
||||
self.isConnected = true
|
||||
self.isConnecting = false
|
||||
}
|
||||
self.state = newState
|
||||
}
|
||||
|
||||
func send(_ data: ToRadio, debugDescription: String? = nil) async throws {
|
||||
packetsSent += 1
|
||||
|
||||
guard let active = activeConnection,
|
||||
await active.connection.isConnected else {
|
||||
throw AccessoryError.connectionFailed("Not connected to any device")
|
||||
}
|
||||
try await active.connection.send(data)
|
||||
if let debugDescription {
|
||||
Logger.transport.info("📻 \(debugDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func didReceive(_ event: ConnectionEvent) {
|
||||
packetsReceived += 1
|
||||
|
||||
switch event {
|
||||
case .data(let fromRadio):
|
||||
// Logger.transport.info("✅ [Accessory] didReceive: \(fromRadio.payloadVariant.debugDescription)")
|
||||
self.processFromRadio(fromRadio)
|
||||
Task {
|
||||
await self.heartbeatResponseTimer?.cancel(withReason: "Data packet received")
|
||||
await self.heartbeatTimer?.reset(delay: .seconds(60.0))
|
||||
}
|
||||
|
||||
case .logMessage(let message):
|
||||
self.didReceiveLog(message: message)
|
||||
Task {
|
||||
await self.heartbeatResponseTimer?.cancel(withReason: "Log message packet received")
|
||||
await self.heartbeatTimer?.reset(delay: .seconds(60.0))
|
||||
}
|
||||
|
||||
case .rssiUpdate(let rssi):
|
||||
guard let deviceId = self.activeConnection?.device.id else {
|
||||
Logger.transport.error("Could not update RSSI, no active connection")
|
||||
return
|
||||
}
|
||||
updateDevice(deviceId: deviceId, key: \.rssi, value: rssi)
|
||||
|
||||
case .error(let error), .errorWithoutReconnect(let error):
|
||||
Task {
|
||||
// Figure out if we'll reconnect
|
||||
if case .errorWithoutReconnect = event {
|
||||
shouldAutomaticallyConnectToPreferredPeripheral = false
|
||||
} else {
|
||||
shouldAutomaticallyConnectToPreferredPeripheral = true
|
||||
}
|
||||
|
||||
Logger.transport.info("🚨 [Accessory] didReceive with failure: \(error.localizedDescription) (willReconnect = \(self.shouldAutomaticallyConnectToPreferredPeripheral, privacy: .public))")
|
||||
|
||||
lastConnectionError = error
|
||||
|
||||
if let connectionStepper = self.connectionStepper {
|
||||
// If we're in the midst of a connection process, tell the stepper that something happened
|
||||
// This cancels retry connection attempts if we've been asked not to reconnect
|
||||
await connectionStepper.cancelCurrentlyExecutingStep(withError: error, cancelFullProcess: !shouldAutomaticallyConnectToPreferredPeripheral)
|
||||
} else {
|
||||
// Normal processing. Expose the error and disconnect
|
||||
try? await self.closeConnection()
|
||||
|
||||
// If we were actively reconnecting, then don't update the status because
|
||||
// we're in the midst of a reconnection flow
|
||||
if !(await self.connectionStepper?.isRunning ?? false) {
|
||||
updateState(.discovering)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
case .disconnected:
|
||||
Task {
|
||||
// This is user-initatied, so don't reconnect
|
||||
shouldAutomaticallyConnectToPreferredPeripheral = false
|
||||
try? await self.closeConnection()
|
||||
updateState(.discovering)
|
||||
}
|
||||
Logger.transport.info("[Accessory] Connection reported user-initiated disconnect.")
|
||||
}
|
||||
}
|
||||
|
||||
func didReceiveLog(message: String) {
|
||||
var log = message
|
||||
/// Debug Log Level
|
||||
if log.starts(with: "DEBUG |") {
|
||||
do {
|
||||
let logString = log
|
||||
if let coordsMatch = try CommonRegex.COORDS_REGEX.firstMatch(in: logString) {
|
||||
log = "\(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces))"
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.debug("🛰️ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private(mask: .none)) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)")
|
||||
} else {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.debug("🕵🏻♂️ \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.debug("🕵🏻♂️ \(log.replacingOccurrences(of: "DEBUG |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
|
||||
}
|
||||
} else if log.starts(with: "INFO |") {
|
||||
do {
|
||||
let logString = log
|
||||
if let coordsMatch = try CommonRegex.COORDS_REGEX.firstMatch(in: logString) {
|
||||
log = "\(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces))"
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.info("🛰️ \(log.prefix(upTo: coordsMatch.range.lowerBound), privacy: .public) \(coordsMatch.0.replacingOccurrences(of: "[,]", with: "", options: .regularExpression), privacy: .private) \(log.suffix(from: coordsMatch.range.upperBound), privacy: .public)")
|
||||
} else {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.info("📢 \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.info("📢 \(log.replacingOccurrences(of: "INFO |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
|
||||
}
|
||||
} else if log.starts(with: "WARN |") {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.warning("⚠️ \(log.replacingOccurrences(of: "WARN |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
|
||||
} else if log.starts(with: "ERROR |") {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.error("💥 \(log.replacingOccurrences(of: "ERROR |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
|
||||
} else if log.starts(with: "CRIT |") {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.critical("🧨 \(log.replacingOccurrences(of: "CRIT |", with: "").trimmingCharacters(in: .whitespaces), privacy: .public)")
|
||||
} else {
|
||||
log = log.replacingOccurrences(of: "[,]", with: "", options: .regularExpression)
|
||||
Logger.radio.debug("📟 \(log, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
private func processFromRadio(_ decodedInfo: FromRadio) {
|
||||
switch decodedInfo.payloadVariant {
|
||||
case .mqttClientProxyMessage(let mqttClientProxyMessage):
|
||||
handleMqttClientProxyMessage(mqttClientProxyMessage)
|
||||
|
||||
case .clientNotification(let clientNotification):
|
||||
handleClientNotification(clientNotification)
|
||||
|
||||
case .myInfo(let myNodeInfo):
|
||||
handleMyInfo(myNodeInfo)
|
||||
|
||||
case .packet(let packet):
|
||||
if case let .decoded(data) = packet.payloadVariant {
|
||||
switch data.portnum {
|
||||
case .textMessageApp, .detectionSensorApp, .alertApp:
|
||||
handleTextMessageAppPacket(packet)
|
||||
case .remoteHardwareApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Remote Hardware App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .positionApp:
|
||||
upsertPositionPacket(packet: packet, context: context)
|
||||
case .waypointApp:
|
||||
waypointPacket(packet: packet, context: context)
|
||||
case .nodeinfoApp:
|
||||
guard let connectedNodeNum = self.activeDeviceNum else {
|
||||
Logger.mesh.error("🕸️ Unable to determine connectedNodeNum for node info upsert.")
|
||||
return
|
||||
}
|
||||
if packet.from != connectedNodeNum {
|
||||
upsertNodeInfoPacket(packet: packet, context: context)
|
||||
} else {
|
||||
Logger.mesh.error("🕸️ Received a node info packet from ourselves over the mesh. Dropping.")
|
||||
}
|
||||
case .routingApp:
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for routingPacket.")
|
||||
return
|
||||
}
|
||||
routingPacket(packet: packet, connectedNodeNum: deviceNum, context: context)
|
||||
case .adminApp:
|
||||
adminAppPacket(packet: packet, context: context)
|
||||
case .replyApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Reply App handling as a text message")
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for replyApp.")
|
||||
return
|
||||
}
|
||||
textMessageAppPacket(packet: packet, wantRangeTestPackets: wantRangeTestPackets, connectedNode: deviceNum, context: context, appState: appState)
|
||||
case .ipTunnelApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for IP Tunnel App UNHANDLED UNHANDLED")
|
||||
case .serialApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Serial App UNHANDLED UNHANDLED")
|
||||
case .storeForwardApp:
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for storeAndForward.")
|
||||
return
|
||||
}
|
||||
storeAndForwardPacket(packet: decodedInfo.packet, connectedNodeNum: deviceNum)
|
||||
case .rangeTestApp:
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for rangeTestApp.")
|
||||
return
|
||||
}
|
||||
if wantRangeTestPackets {
|
||||
textMessageAppPacket(
|
||||
packet: packet,
|
||||
wantRangeTestPackets: true,
|
||||
connectedNode: deviceNum,
|
||||
context: context,
|
||||
appState: appState
|
||||
)
|
||||
} else {
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Range Test App Range testing is disabled.")
|
||||
}
|
||||
case .telemetryApp:
|
||||
guard let deviceNum = activeConnection?.device.num else {
|
||||
Logger.mesh.error("🕸️ No active connection. Unable to determine connectedNodeNum for telemetryApp.")
|
||||
return
|
||||
}
|
||||
telemetryPacket(packet: packet, connectedNode: deviceNum, context: context)
|
||||
case .textMessageCompressedApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Text Message Compressed App UNHANDLED")
|
||||
case .zpsApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Zero Positioning System App UNHANDLED")
|
||||
case .privateApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Private App UNHANDLED UNHANDLED")
|
||||
case .atakForwarder:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Forwarder App UNHANDLED UNHANDLED")
|
||||
case .simulatorApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Simulator App UNHANDLED UNHANDLED")
|
||||
case .audioApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Audio App UNHANDLED UNHANDLED")
|
||||
case .tracerouteApp:
|
||||
handleTraceRouteApp(packet)
|
||||
case .neighborinfoApp:
|
||||
if let neighborInfo = try? NeighborInfo(serializedBytes: decodedInfo.packet.decoded.payload) {
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Neighbor Info App UNHANDLED \((try? neighborInfo.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
}
|
||||
case .paxcounterApp:
|
||||
paxCounterPacket(packet: decodedInfo.packet, context: context)
|
||||
case .mapReportApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received Map Report App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .UNRECOGNIZED:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received UNRECOGNIZED App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .max:
|
||||
Logger.services.info("MAX PORT NUM OF 511")
|
||||
case .atakPlugin:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for ATAK Plugin App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .powerstressApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Power Stress App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .reticulumTunnelApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for Reticulum Tunnel App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .keyVerificationApp:
|
||||
Logger.mesh.warning("🕸️ MESH PACKET received for Key Verification App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .unknownApp:
|
||||
Logger.mesh.warning("🕸️ MESH PACKET received for unknown App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
case .cayenneApp:
|
||||
Logger.mesh.info("🕸️ MESH PACKET received Cayenne App UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
case .nodeInfo(let nodeInfo):
|
||||
handleNodeInfo(nodeInfo)
|
||||
|
||||
case .channel(let channel):
|
||||
handleChannel(channel)
|
||||
|
||||
case .config(let config):
|
||||
handleConfig(config)
|
||||
|
||||
case .moduleConfig(let moduleConfig):
|
||||
handleModuleConfig(moduleConfig)
|
||||
|
||||
case .metadata(let metadata):
|
||||
handleDeviceMetadata(metadata)
|
||||
|
||||
case .deviceuiConfig:
|
||||
#if DEBUG
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for deviceUIConfig UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
#endif
|
||||
case .fileInfo:
|
||||
#if DEBUG
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for fileInfo UNHANDLED \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
#endif
|
||||
case .queueStatus:
|
||||
#if DEBUG
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for queueStatus \((try? decodedInfo.packet.jsonString()) ?? "JSON Decode Failure", privacy: .public)")
|
||||
#else
|
||||
Logger.mesh.info("🕸️ MESH PACKET received for heartbeat response")
|
||||
#endif
|
||||
case .logRecord(let record):
|
||||
didReceiveLog(message: record.stringRepresentation)
|
||||
|
||||
case .configCompleteID(let configCompleteID):
|
||||
// Not sure if we want to do anythign here directly? The continuation stuff lets you
|
||||
// do the next step right in the connection flow.
|
||||
|
||||
// switch configCompleteID {
|
||||
// case UInt32(NONCE_ONLY_CONFIG):
|
||||
// break;
|
||||
// case UInt32(NONCE_ONLY_DB):
|
||||
// case UInt32(NONCE_ONLY_DB):
|
||||
// break;
|
||||
// break:
|
||||
// Logger.mesh.error("✅ [Accessory] Unknown UNHANDLED confligCompleteID: \(configCompleteID)")
|
||||
// }
|
||||
|
||||
Logger.transport.info("✅ [Accessory] Notifying completions that have completed for configCompleteID: \(configCompleteID)")
|
||||
switch configCompleteID {
|
||||
case UInt32(NONCE_ONLY_CONFIG):
|
||||
if let continuation = wantConfigContinuation {
|
||||
continuation.resume()
|
||||
}
|
||||
|
||||
case UInt32(NONCE_ONLY_DB):
|
||||
// Open the gate for the wantDatabaseContinuation
|
||||
Task { await wantDatabaseGate.open() }
|
||||
|
||||
// If we get the "done" for NONCE_ONLY_DB, but are still waiting for the first NodeInfo,
|
||||
// Then the database is probably empty, and can continue
|
||||
if let firstDatabaseNodeInfoContinuation {
|
||||
firstDatabaseNodeInfoContinuation.resume()
|
||||
self.firstDatabaseNodeInfoContinuation = nil
|
||||
}
|
||||
|
||||
default:
|
||||
Logger.transport.error("[Accessory] Unknown nonce completed: \(configCompleteID)")
|
||||
}
|
||||
|
||||
case .rebooted:
|
||||
// If we had an existing connection, then we can probably get away with just a wantConfig?
|
||||
if state == .subscribed {
|
||||
Task { try? await sendWantConfig() }
|
||||
}
|
||||
|
||||
default:
|
||||
Logger.mesh.error("Unknown FromRadio variant: \(decodedInfo.payloadVariant.debugDescription)")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension AccessoryManager {
|
||||
var connectedVersion: String? {
|
||||
return activeConnection?.device.firmwareVersion
|
||||
}
|
||||
|
||||
func checkIsVersionSupported(forVersion: String) -> Bool {
|
||||
let myVersion = connectedVersion ?? "0.0.0"
|
||||
let supportedVersion = UserDefaults.firmwareVersion == "0.0.0" ||
|
||||
forVersion.compare(myVersion, options: .numeric) == .orderedAscending ||
|
||||
forVersion.compare(myVersion, options: .numeric) == .orderedSame
|
||||
return supportedVersion
|
||||
}
|
||||
}
|
||||
|
||||
extension AccessoryManager {
|
||||
func setupPeriodicHeartbeat() async {
|
||||
if heartbeatTimer != nil {
|
||||
Logger.transport.debug("💓 [Heartbeat] Cancelling existing heartbeat timer")
|
||||
await self.heartbeatTimer?.cancel(withReason: "Duplicate setup, cancelling previous timer")
|
||||
self.heartbeatTimer = nil
|
||||
}
|
||||
self.heartbeatTimer = ResettableTimer(isRepeating: true, debugName: "Send Heartbeat") {
|
||||
Logger.transport.debug("💓 [Heartbeat] Sending periodic heartbeat")
|
||||
try? await self.sendHeartbeat()
|
||||
}
|
||||
|
||||
// We can send heartbeats for older versions just fine, but only 2.7.4 and up will respond with
|
||||
// a definite queueStatus packet.
|
||||
if self.checkIsVersionSupported(forVersion: "2.7.4") {
|
||||
self.heartbeatResponseTimer = ResettableTimer(isRepeating: false, debugName: "Heartbeat Timeout") { @MainActor in
|
||||
Logger.transport.error("💓 [Heartbeat] Connection Timeout: Did not receive a packet after heartbeat.")
|
||||
// If we're in the middle of a connection cancel it.
|
||||
await self.connectionStepper?.cancel()
|
||||
|
||||
// Close out the connection
|
||||
if let activeConnection = self.activeConnection {
|
||||
try? await activeConnection.connection.disconnect(withError: AccessoryError.timeout, shouldReconnect: true)
|
||||
} else {
|
||||
self.lastConnectionError = AccessoryError.timeout
|
||||
try? await self.closeConnection()
|
||||
}
|
||||
}
|
||||
}
|
||||
await self.heartbeatTimer?.reset(delay: .seconds(60.0))
|
||||
}
|
||||
}
|
||||
|
||||
enum PossiblyAlreadyDoneContinuation {
|
||||
case alreadyDone
|
||||
case notDone(CheckedContinuation<Void, Error>)
|
||||
}
|
||||
|
||||
extension AccessoryManager {
|
||||
func appDidEnterBackground() {
|
||||
if self.state == .uninitialized { return }
|
||||
if let connection = self.activeConnection?.connection {
|
||||
Logger.transport.info("[AccessoryManager] informing active connection that we are entering the background")
|
||||
Task { await connection.appDidEnterBackground() }
|
||||
} else {
|
||||
Logger.transport.info("[AccessoryManager] suspending scanning while in the background")
|
||||
stopDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
func appDidBecomeActive() {
|
||||
if self.state == .uninitialized { return }
|
||||
if let connection = self.activeConnection?.connection {
|
||||
Logger.transport.info("[AccessoryManager] informing previously active connection that we are active again")
|
||||
Task { await connection.appDidBecomeActive() }
|
||||
} else {
|
||||
if self.discoveryTask == nil {
|
||||
Logger.transport.info("[AccessoryManager] Previosuly in the background but not scanning, starting scanning again")
|
||||
self.startDiscovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
59
Meshtastic/Accessory/Helpers/AsyncGate.swift
Normal file
59
Meshtastic/Accessory/Helpers/AsyncGate.swift
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
//
|
||||
// AsyncGate.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake on 8/20/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
actor AsyncGate {
|
||||
private var waiters: [UUID: CheckedContinuation<Void, Error>] = [:]
|
||||
private var isOpen = false
|
||||
|
||||
/// Wait until the gate is opened. Respects cancellation.
|
||||
func wait() async throws {
|
||||
if isOpen { return }
|
||||
|
||||
let id = UUID()
|
||||
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
waiters[id] = cont
|
||||
}
|
||||
} onCancel: {
|
||||
Task { [weak self] in
|
||||
await self?.cancelWaiter(id: id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens the gate, resuming all current waiters.
|
||||
func open() {
|
||||
isOpen = true
|
||||
for (_, cont) in waiters {
|
||||
cont.resume()
|
||||
}
|
||||
waiters.removeAll()
|
||||
}
|
||||
|
||||
/// Cancels all current waiters with `CancellationError`.
|
||||
func cancelAll() {
|
||||
for (_, cont) in waiters {
|
||||
cont.resume(throwing: CancellationError())
|
||||
}
|
||||
waiters.removeAll()
|
||||
}
|
||||
|
||||
/// Resets the gate back to closed.
|
||||
/// Future waiters will suspend again until `open()` is called.
|
||||
func reset() {
|
||||
isOpen = false
|
||||
}
|
||||
|
||||
private func cancelWaiter(id: UUID) {
|
||||
if let cont = waiters.removeValue(forKey: id) {
|
||||
cont.resume(throwing: CancellationError())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
//
|
||||
// LogRecord+StringRepresentation.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/29/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MeshtasticProtobufs
|
||||
|
||||
extension LogRecord {
|
||||
var stringRepresentation: String {
|
||||
var message = self.source.isEmpty ? self.message : "[\(self.source)] \(self.message)"
|
||||
switch self.level {
|
||||
case .debug:
|
||||
message = "DEBUG | \(message)"
|
||||
case .info:
|
||||
message = "INFO | \(message)"
|
||||
case .warning:
|
||||
message = "WARN | \(message)"
|
||||
case .error:
|
||||
message = "ERROR | \(message)"
|
||||
case .critical:
|
||||
message = "CRIT | \(message)"
|
||||
default:
|
||||
message = "DEBUG | \(message)"
|
||||
}
|
||||
return message
|
||||
}
|
||||
}
|
||||
67
Meshtastic/Accessory/Helpers/ResettableTimer.swift
Normal file
67
Meshtastic/Accessory/Helpers/ResettableTimer.swift
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
//
|
||||
// ResettableTimer.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by jake on 8/16/25.
|
||||
//
|
||||
|
||||
import Foundation // For Duration and Task (though often implicit in Swift environments)
|
||||
import OSLog
|
||||
|
||||
/// A resettable timer implemented using Swift concurrency.
|
||||
/// The timer can optionally be set to repeat, executing the closure repeatedly at the specified interval.
|
||||
/// Calling `reset` cancels any ongoing timer and starts a new one with the given delay.
|
||||
/// For repeating timers, it will continue firing until explicitly cancelled.
|
||||
actor ResettableTimer {
|
||||
private var currentTask: Task<Void, Never>?
|
||||
private let action: @Sendable () async -> Void
|
||||
private let isRepeating: Bool
|
||||
private let debugName: String?
|
||||
/// Initializes the timer with the closure to execute and whether it should repeat.
|
||||
/// - Parameters:
|
||||
/// - isRepeating: If true, the timer will repeat indefinitely until cancelled. Defaults to false for one-shot behavior.
|
||||
/// - action: The closure to run after the delay elapses (and repeatedly if repeating).
|
||||
init(isRepeating: Bool = false, debugName: String? = nil, action: @Sendable @escaping () async -> Void) {
|
||||
self.isRepeating = isRepeating
|
||||
self.action = action
|
||||
self.debugName = debugName
|
||||
}
|
||||
|
||||
/// Resets the timer to a new delay, cancelling any previous scheduled execution.
|
||||
/// - Parameter delay: The new delay duration before executing the action.
|
||||
func reset(delay: Duration, withReason reason: String? = nil) {
|
||||
if let debugName {
|
||||
if let reason {
|
||||
Logger.services.debug("⏱️ [\(debugName)] Resettable timer reset with new duration \(delay): \(reason)")
|
||||
} else {
|
||||
Logger.services.debug("⏱️ [\(debugName)] Resettable timer reset with new duration \(delay)")
|
||||
}
|
||||
}
|
||||
currentTask?.cancel()
|
||||
currentTask = Task {
|
||||
repeat {
|
||||
do {
|
||||
try await Task.sleep(for: delay)
|
||||
if Task.isCancelled { break }
|
||||
await action()
|
||||
} catch {
|
||||
// Timer was cancelled or sleep interrupted; exit the loop.
|
||||
break
|
||||
}
|
||||
} while isRepeating
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancels the timer without starting a new one. For repeating timers, this stops future executions.
|
||||
func cancel(withReason reason: String? = nil) {
|
||||
if let debugName {
|
||||
if let reason {
|
||||
Logger.services.debug("⏱️ [\(debugName)] Resettable timer cancelled: \(reason)")
|
||||
} else {
|
||||
Logger.services.debug("⏱️ [\(debugName)] Resettable timer cancelled")
|
||||
}
|
||||
}
|
||||
currentTask?.cancel()
|
||||
currentTask = nil
|
||||
}
|
||||
}
|
||||
42
Meshtastic/Accessory/Protocols/Connection.swift
Normal file
42
Meshtastic/Accessory/Protocols/Connection.swift
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
//
|
||||
// Connection.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import MeshtasticProtobufs
|
||||
|
||||
protocol Connection: Actor {
|
||||
var type: TransportType { get }
|
||||
|
||||
var isConnected: Bool { get }
|
||||
func send(_ data: ToRadio) async throws
|
||||
func connect() async throws -> AsyncStream<ConnectionEvent>
|
||||
func disconnect(withError: Error?, shouldReconnect: Bool) throws
|
||||
func drainPendingPackets() async throws
|
||||
func startDrainPendingPackets() throws
|
||||
|
||||
func appDidEnterBackground()
|
||||
func appDidBecomeActive()
|
||||
}
|
||||
|
||||
enum ConnectionEvent {
|
||||
case data(FromRadio)
|
||||
case logMessage(String)
|
||||
case rssiUpdate(Int)
|
||||
case error(Error)
|
||||
case errorWithoutReconnect(Error)
|
||||
case disconnected(shouldReconnect: Bool)
|
||||
}
|
||||
|
||||
enum ConnectionState: Equatable {
|
||||
case disconnected
|
||||
case connecting
|
||||
case connected
|
||||
}
|
||||
|
||||
enum ConnectionError: Error, LocalizedError {
|
||||
case ioError(String)
|
||||
}
|
||||
53
Meshtastic/Accessory/Protocols/Device.swift
Normal file
53
Meshtastic/Accessory/Protocols/Device.swift
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
//
|
||||
// Device.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct Device: Identifiable, Hashable {
|
||||
let id: UUID
|
||||
var name: String
|
||||
var transportType: TransportType
|
||||
var identifier: String // e.g., UUID for BLE, IP:port for TCP, port path for Serial
|
||||
|
||||
var num: Int64?
|
||||
var shortName: String?
|
||||
var longName: String?
|
||||
var firmwareVersion: String?
|
||||
var rssi: Int?
|
||||
var lastUpdate: Date?
|
||||
|
||||
var connectionState: ConnectionState
|
||||
|
||||
init(id: UUID, name: String, transportType: TransportType, identifier: String, connectionState: ConnectionState = .disconnected, rssi: Int? = nil) {
|
||||
self.id = id
|
||||
self.name = name
|
||||
self.transportType = transportType
|
||||
self.identifier = identifier
|
||||
self.connectionState = connectionState
|
||||
self.rssi = rssi
|
||||
}
|
||||
|
||||
var rssiString: String {
|
||||
if let rssi {
|
||||
return "\(rssi) dBm"
|
||||
} else {
|
||||
return "n/a"
|
||||
}
|
||||
}
|
||||
|
||||
func getSignalStrength() -> BLESignalStrength? {
|
||||
guard let rssi else { return nil }
|
||||
if NSNumber(value: rssi).compare(NSNumber(-65)) == ComparisonResult.orderedDescending {
|
||||
return BLESignalStrength.strong
|
||||
} else if NSNumber(value: rssi).compare(NSNumber(-85)) == ComparisonResult.orderedDescending {
|
||||
return BLESignalStrength.normal
|
||||
} else {
|
||||
return BLESignalStrength.weak
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
84
Meshtastic/Accessory/Protocols/Transport.swift
Normal file
84
Meshtastic/Accessory/Protocols/Transport.swift
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
//
|
||||
// Transport.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CommonCrypto
|
||||
import SwiftUI
|
||||
|
||||
enum TransportType: String, CaseIterable {
|
||||
case ble = "BLE"
|
||||
case tcp = "TCP"
|
||||
case serial = "Serial"
|
||||
|
||||
var icon: Image {
|
||||
switch self {
|
||||
case .ble:
|
||||
Image("custom.bluetooth")
|
||||
case .tcp:
|
||||
Image(systemName: "network")
|
||||
case .serial:
|
||||
Image(systemName: "cable.connector.horizontal")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TransportStatus: Equatable {
|
||||
case uninitialized
|
||||
case ready
|
||||
case discovering
|
||||
case error(String)
|
||||
}
|
||||
|
||||
enum DiscoveryEvent {
|
||||
case deviceFound(Device)
|
||||
case deviceUpdated(Device)
|
||||
case deviceLost(UUID)
|
||||
case deviceReportedRssi(UUID, Int)
|
||||
}
|
||||
|
||||
protocol Transport {
|
||||
var type: TransportType { get }
|
||||
var status: TransportStatus { get }
|
||||
|
||||
// Discovers devices asynchronously. For ongoing scans (e.g., BLE), this can yield via AsyncStream.
|
||||
func discoverDevices() -> AsyncStream<DiscoveryEvent>
|
||||
|
||||
// Connects to a device and returns a Connection.
|
||||
func connect(to device: Device) async throws -> any Connection
|
||||
|
||||
var requiresPeriodicHeartbeat: Bool { get }
|
||||
var supportsManualConnection: Bool { get }
|
||||
|
||||
func manuallyConnect(withConnectionString: String) async throws
|
||||
}
|
||||
|
||||
// Used to make stable-ish ID's for accessories that don't have a UUID
|
||||
extension String {
|
||||
func toUUIDFormatHash() -> UUID? {
|
||||
// Convert string to data
|
||||
guard let data = self.data(using: .utf8) else { return nil }
|
||||
|
||||
// Create buffer for SHA-256 hash (32 bytes)
|
||||
var digest = [UInt8](repeating: 0, count: Int(CC_SHA256_DIGEST_LENGTH))
|
||||
|
||||
// Perform SHA-256 hashing
|
||||
_ = data.withUnsafeBytes { buffer in
|
||||
CC_SHA256(buffer.baseAddress, CC_LONG(buffer.count), &digest)
|
||||
}
|
||||
|
||||
// Take first 16 bytes (128 bits) for UUID
|
||||
let uuidBytes = Array(digest.prefix(16))
|
||||
|
||||
// Create UUID from bytes
|
||||
return UUID(uuid: (
|
||||
uuidBytes[0], uuidBytes[1], uuidBytes[2], uuidBytes[3],
|
||||
uuidBytes[4], uuidBytes[5], uuidBytes[6], uuidBytes[7],
|
||||
uuidBytes[8], uuidBytes[9], uuidBytes[10], uuidBytes[11],
|
||||
uuidBytes[12], uuidBytes[13], uuidBytes[14], uuidBytes[15]
|
||||
))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,75 @@
|
|||
//
|
||||
// BluetoothAuthorizationHelper.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/31/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import CoreBluetooth
|
||||
|
||||
/// A helper class to manage the CoreBluetooth delegate callbacks.
|
||||
/// This is necessary because CBCentralManagerDelegate requires an NSObject.
|
||||
class BluetoothAuthorizationHelper: NSObject, CBCentralManagerDelegate {
|
||||
|
||||
/// The continuation to resume when the authorization status is determined.
|
||||
private var continuation: CheckedContinuation<Bool, Never>?
|
||||
|
||||
/// The CoreBluetooth central manager.
|
||||
private var centralManager: CBCentralManager?
|
||||
|
||||
/// Requests Bluetooth authorization and awaits the user's response.
|
||||
func requestAuthorization() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
self.continuation = continuation
|
||||
|
||||
// Initializing the CBCentralManager triggers the permission prompt if needed.
|
||||
// The delegate method will be called with the result.
|
||||
// The manager must be retained for the delegate callbacks to occur.
|
||||
self.centralManager = CBCentralManager(delegate: self, queue: nil)
|
||||
}
|
||||
}
|
||||
|
||||
/// The delegate method that receives state updates from the CBCentralManager.
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
switch central.state {
|
||||
case .poweredOn:
|
||||
// Success: User has granted permission and Bluetooth is on.
|
||||
continuation?.resume(returning: true)
|
||||
|
||||
case .unauthorized:
|
||||
// Failure: User has explicitly denied permission.
|
||||
continuation?.resume(returning: false)
|
||||
|
||||
case .poweredOff:
|
||||
// Failure: User needs to turn on Bluetooth in Settings.
|
||||
// For the purpose of this function, the app cannot use BLE.
|
||||
continuation?.resume(returning: false)
|
||||
|
||||
case .unsupported:
|
||||
// Failure: This device does not support Bluetooth Low Energy.
|
||||
continuation?.resume(returning: false)
|
||||
|
||||
case .resetting, .unknown:
|
||||
// The state is temporary or unknown. We wait for the next state update.
|
||||
// Do nothing and let the continuation live.
|
||||
break
|
||||
|
||||
@unknown default:
|
||||
// Handle any future cases gracefully.
|
||||
continuation?.resume(returning: false)
|
||||
}
|
||||
|
||||
// Clean up to prevent resuming more than once.
|
||||
self.continuation = nil
|
||||
}
|
||||
|
||||
/// A static function to provide a clean call site.
|
||||
static func requestBluetoothAuthorization() async -> Bool {
|
||||
// Create an instance of the helper class.
|
||||
// The instance will be retained until the async operation completes.
|
||||
let authorizer = BluetoothAuthorizationHelper()
|
||||
return await authorizer.requestAuthorization()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,513 @@
|
|||
//
|
||||
// BLEConnection.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import CoreBluetooth
|
||||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
|
||||
let meshtasticServiceCBUUID = CBUUID(string: "0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD")
|
||||
let TORADIO_UUID = CBUUID(string: "0xF75C76D2-129E-4DAD-A1DD-7866124401E7")
|
||||
let FROMRADIO_UUID = CBUUID(string: "0x2C55E69E-4993-11ED-B878-0242AC120002")
|
||||
let FROMNUM_UUID = CBUUID(string: "0xED9DA18C-A800-4F66-A670-AA7547E34453")
|
||||
let LOGRADIO_UUID = CBUUID(string: "0x5a3d6e49-06e6-4423-9944-e9de8cdf9547")
|
||||
|
||||
extension CBCharacteristic {
|
||||
|
||||
var meshtasticCharacteristicName: String {
|
||||
switch self.uuid {
|
||||
case TORADIO_UUID:
|
||||
return "TORADIO"
|
||||
case FROMRADIO_UUID:
|
||||
return "FROMRADIO"
|
||||
case FROMNUM_UUID:
|
||||
return "FROMNUM"
|
||||
case LOGRADIO_UUID:
|
||||
return "LOGRADIO"
|
||||
default:
|
||||
return "UNKNOWN (\(self.uuid.uuidString))"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor BLEConnection: Connection {
|
||||
let type = TransportType.ble
|
||||
|
||||
var delegate: BLEConnectionDelegate
|
||||
var peripheral: CBPeripheral
|
||||
var central: CBCentralManager
|
||||
private var needsDrain: Bool = false
|
||||
private var isDraining: Bool = false
|
||||
|
||||
fileprivate var TORADIO_characteristic: CBCharacteristic?
|
||||
fileprivate var FROMRADIO_characteristic: CBCharacteristic?
|
||||
fileprivate var FROMNUM_characteristic: CBCharacteristic?
|
||||
fileprivate var LOGRADIO_characteristic: CBCharacteristic?
|
||||
|
||||
private var connectionStreamContinuation: AsyncStream<ConnectionEvent>.Continuation?
|
||||
|
||||
private var connectContinuation: CheckedContinuation<Void, Error>?
|
||||
private var writeContinuations: [CheckedContinuation<Void, Error>]
|
||||
private var readContinuations: [CheckedContinuation<Data, Error>]
|
||||
|
||||
private var rssiTask: Task<Void, Never>?
|
||||
|
||||
var isConnected: Bool { peripheral.state == .connected }
|
||||
var transport: BLETransport?
|
||||
|
||||
init(peripheral: CBPeripheral, central: CBCentralManager, transport: BLETransport) {
|
||||
self.peripheral = peripheral
|
||||
self.central = central
|
||||
self.transport = transport
|
||||
self.delegate = BLEConnectionDelegate(peripheral: peripheral)
|
||||
self.writeContinuations = []
|
||||
self.readContinuations = []
|
||||
self.delegate.setConnection(self)
|
||||
}
|
||||
|
||||
func disconnect(withError error: Error? = nil, shouldReconnect: Bool) throws {
|
||||
if peripheral.state == .connected {
|
||||
if let characteristic = FROMRADIO_characteristic {
|
||||
peripheral.setNotifyValue(false, for: characteristic)
|
||||
}
|
||||
if let characteristic = FROMNUM_characteristic {
|
||||
peripheral.setNotifyValue(false, for: characteristic)
|
||||
}
|
||||
if let characteristic = LOGRADIO_characteristic {
|
||||
peripheral.setNotifyValue(false, for: characteristic)
|
||||
}
|
||||
}
|
||||
|
||||
transport?.connectionDidDisconnect()
|
||||
|
||||
central.cancelPeripheralConnection(peripheral)
|
||||
peripheral.delegate = nil
|
||||
|
||||
while !writeContinuations.isEmpty {
|
||||
let writeContinuation = writeContinuations.removeFirst()
|
||||
writeContinuation.resume(throwing: AccessoryError.disconnected("Unknown error"))
|
||||
}
|
||||
|
||||
while !readContinuations.isEmpty {
|
||||
let readContinuation = readContinuations.removeFirst()
|
||||
readContinuation.resume(throwing: AccessoryError.disconnected("Unknown error"))
|
||||
}
|
||||
|
||||
if let error {
|
||||
// Inform the AccessoryManager of the error and intent to reconnect
|
||||
if shouldReconnect {
|
||||
connectionStreamContinuation?.yield(.error(error))
|
||||
} else {
|
||||
connectionStreamContinuation?.yield(.errorWithoutReconnect(error))
|
||||
}
|
||||
} else {
|
||||
connectionStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect))
|
||||
}
|
||||
|
||||
connectionStreamContinuation?.finish()
|
||||
connectionStreamContinuation = nil
|
||||
|
||||
rssiTask?.cancel()
|
||||
rssiTask = nil
|
||||
}
|
||||
|
||||
func startDrainPendingPackets() throws {
|
||||
guard isConnected else {
|
||||
throw AccessoryError.ioFailed("Not connected")
|
||||
}
|
||||
needsDrain = true
|
||||
if !isDraining {
|
||||
Task {
|
||||
isDraining = true
|
||||
defer { isDraining = false }
|
||||
while needsDrain {
|
||||
needsDrain = false
|
||||
do {
|
||||
try await drainPendingPackets()
|
||||
} catch {
|
||||
// Handle or log error as needed; for now, just continue to allow retry on next notification
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func drainPendingPackets() async throws {
|
||||
guard isConnected else {
|
||||
throw AccessoryError.ioFailed("Not connected")
|
||||
}
|
||||
repeat {
|
||||
do {
|
||||
let data = try await read()
|
||||
|
||||
if data.count == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
let decodedInfo = try FromRadio(serializedBytes: data)
|
||||
connectionStreamContinuation?.yield(.data(decodedInfo))
|
||||
} catch {
|
||||
try? await self.disconnect(withError: error, shouldReconnect: true)
|
||||
throw error // Re-throw to propagate up to the caller for handling
|
||||
}
|
||||
} while true
|
||||
}
|
||||
|
||||
func didReceiveLogMessage(_ logMessage: String) {
|
||||
self.connectionStreamContinuation?.yield(.logMessage(logMessage))
|
||||
}
|
||||
|
||||
func didUpdateRssi(_ rssi: Int) {
|
||||
self.connectionStreamContinuation?.yield(.rssiUpdate(rssi))
|
||||
}
|
||||
|
||||
func getPacketStream() -> AsyncStream<ConnectionEvent> {
|
||||
AsyncStream<ConnectionEvent> { continuation in
|
||||
self.connectionStreamContinuation = continuation
|
||||
}
|
||||
}
|
||||
|
||||
func discoverServices() async throws {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.connectContinuation = cont
|
||||
peripheral.discoverServices([meshtasticServiceCBUUID])
|
||||
}
|
||||
}
|
||||
|
||||
func connect() async throws -> AsyncStream<ConnectionEvent> {
|
||||
if self.peripheral.state != .connected {
|
||||
throw AccessoryError.ioFailed("BLE peripheral not connected")
|
||||
}
|
||||
return try await withTaskCancellationHandler {
|
||||
try await discoverServices()
|
||||
startRSSITask()
|
||||
return self.getPacketStream()
|
||||
} onCancel: {
|
||||
Task {
|
||||
await continueConnectionProcess(throwing: CancellationError())
|
||||
await self.transport?.connectionDidDisconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func continueConnectionProcess(throwing error: Error? = nil) {
|
||||
if let error {
|
||||
self.connectContinuation?.resume(throwing: error)
|
||||
} else {
|
||||
self.connectContinuation?.resume()
|
||||
}
|
||||
self.connectContinuation = nil
|
||||
}
|
||||
|
||||
func startRSSITask() {
|
||||
if let task = self.rssiTask {
|
||||
task.cancel()
|
||||
}
|
||||
self.rssiTask = Task {
|
||||
do {
|
||||
while !Task.isCancelled {
|
||||
try await Task.sleep(for: .seconds(10))
|
||||
peripheral.readRSSI()
|
||||
}
|
||||
} catch {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func didDiscoverServices(error: Error? ) {
|
||||
if let error = error {
|
||||
self.continueConnectionProcess(throwing: error)
|
||||
return
|
||||
}
|
||||
|
||||
guard let services = peripheral.services else {
|
||||
self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("No services found"))
|
||||
return
|
||||
}
|
||||
|
||||
var foundMeshtasticService = false
|
||||
for service in services where service.uuid == meshtasticServiceCBUUID {
|
||||
foundMeshtasticService = true
|
||||
peripheral.discoverCharacteristics([TORADIO_UUID, FROMRADIO_UUID, FROMNUM_UUID, LOGRADIO_UUID], for: service)
|
||||
Logger.transport.info("🛜 [BLE] Service for Meshtastic discovered by \(self.peripheral.name ?? "Unknown", privacy: .public)")
|
||||
}
|
||||
|
||||
if !foundMeshtasticService {
|
||||
self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("Meshtastic service not found"))
|
||||
}
|
||||
}
|
||||
|
||||
func didDiscoverCharacteristicsFor(service: CBService, error: Error?) {
|
||||
if let error = error {
|
||||
self.continueConnectionProcess(throwing: error)
|
||||
return
|
||||
}
|
||||
guard let characteristics = service.characteristics else {
|
||||
self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("No characteristics"))
|
||||
return
|
||||
}
|
||||
|
||||
for characteristic in characteristics {
|
||||
switch characteristic.uuid {
|
||||
case TORADIO_UUID:
|
||||
Logger.transport.info("🛜 [BLE] did discover TORADIO characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)")
|
||||
TORADIO_characteristic = characteristic
|
||||
|
||||
case FROMRADIO_UUID:
|
||||
Logger.transport.info("🛜 [BLE] did discover FROMRADIO characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)")
|
||||
FROMRADIO_characteristic = characteristic
|
||||
self.peripheral.setNotifyValue(true, for: characteristic)
|
||||
|
||||
case FROMNUM_UUID:
|
||||
Logger.transport.info("🛜 [BLE] did discover FROMNUM (Notify) characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)")
|
||||
FROMNUM_characteristic = characteristic
|
||||
self.peripheral.setNotifyValue(true, for: characteristic)
|
||||
|
||||
case LOGRADIO_UUID:
|
||||
Logger.transport.info("🛜 [BLE] did discover LOGRADIO (Notify) characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)")
|
||||
LOGRADIO_characteristic = characteristic
|
||||
self.peripheral.setNotifyValue(true, for: characteristic)
|
||||
|
||||
default:
|
||||
Logger.transport.info("🛜 [BLE] did discover unsupported \(characteristic.uuid) characteristic for Meshtastic by \(self.peripheral.name ?? "Unknown", privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
if TORADIO_characteristic != nil && FROMRADIO_characteristic != nil && FROMNUM_characteristic != nil {
|
||||
Logger.transport.info("🛜 [BLE] characteristics ready")
|
||||
self.continueConnectionProcess()
|
||||
|
||||
// Read initial RSSI on ready
|
||||
peripheral.readRSSI()
|
||||
} else {
|
||||
Logger.transport.info("🛜 [BLE] Missing required characteristics")
|
||||
self.continueConnectionProcess(throwing: AccessoryError.discoveryFailed("Missing required characteristics"))
|
||||
}
|
||||
}
|
||||
|
||||
func didUpdateValueFor(characteristic: CBCharacteristic, error: Error?) {
|
||||
if let error = error {
|
||||
if characteristic.uuid == FROMRADIO_UUID {
|
||||
Logger.transport.debug("🛜 [BLE] Error updating value for \(characteristic.meshtasticCharacteristicName, privacy: .public): \(error)")
|
||||
if !readContinuations.isEmpty {
|
||||
let readContinuation = self.readContinuations.removeFirst()
|
||||
readContinuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
Task { try await self.handlePeripheralError(error: error) }
|
||||
return
|
||||
}
|
||||
Logger.transport.debug("🛜 [BLE] Did update value for \(characteristic.meshtasticCharacteristicName, privacy: .public)=\(characteristic.value ?? Data(), privacy: .public)")
|
||||
|
||||
guard let value = characteristic.value else { return }
|
||||
|
||||
switch characteristic.uuid {
|
||||
case FROMRADIO_UUID:
|
||||
if !readContinuations.isEmpty {
|
||||
let readContinuation = self.readContinuations.removeFirst()
|
||||
readContinuation.resume(returning: value)
|
||||
}
|
||||
case FROMNUM_UUID:
|
||||
try? startDrainPendingPackets()
|
||||
|
||||
case LOGRADIO_UUID:
|
||||
if let value = characteristic.value,
|
||||
let logRecord = try? LogRecord(serializedBytes: value) {
|
||||
self.didReceiveLogMessage(logRecord.stringRepresentation)
|
||||
}
|
||||
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func didWriteValueFor(characteristic: CBCharacteristic, error: Error?) {
|
||||
guard characteristic.uuid == TORADIO_UUID else {
|
||||
Logger.transport.error("🛜 [BLE] didWriteValueFor a characteristic other than TORADIO_UUID. Should not happen!")
|
||||
return
|
||||
}
|
||||
guard !writeContinuations.isEmpty else {
|
||||
Logger.transport.error("🛜 [BLE] didWriteValueFor with no waiting continuations. Should not happen!")
|
||||
return
|
||||
}
|
||||
|
||||
let writeContinuation = writeContinuations.removeFirst()
|
||||
|
||||
if let error = error {
|
||||
Logger.transport.error("🛜 [BLE] Did write for \(characteristic.meshtasticCharacteristicName, privacy: .public) with error \(error, privacy: .public)")
|
||||
writeContinuation.resume(throwing: error)
|
||||
Task { try await self.handlePeripheralError(error: error) }
|
||||
} else {
|
||||
#if DEBUG
|
||||
// Too much logging to report every write.
|
||||
Logger.transport.error("🛜 [BLE] Did write for \(characteristic.meshtasticCharacteristicName, privacy: .public)")
|
||||
#endif
|
||||
writeContinuation.resume()
|
||||
}
|
||||
}
|
||||
|
||||
func didReadRSSI(RSSI: NSNumber, error: Error?) {
|
||||
if let error = error {
|
||||
Logger.transport.error("🛜 [BLE] Error reading RSSI: \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
connectionStreamContinuation?.yield(.rssiUpdate(RSSI.intValue))
|
||||
}
|
||||
|
||||
func send(_ data: ToRadio) async throws {
|
||||
guard let characteristic = TORADIO_characteristic, isConnected else {
|
||||
throw AccessoryError.ioFailed("Not connected or characteristic not found")
|
||||
}
|
||||
guard let binaryData = try? data.serializedData() else {
|
||||
throw AccessoryError.ioFailed("Failed to serialize data")
|
||||
}
|
||||
guard characteristic.properties.contains(.write) ||
|
||||
characteristic.properties.contains(.writeWithoutResponse) else {
|
||||
throw AccessoryError.ioFailed("Characteristic does not support write")
|
||||
}
|
||||
|
||||
let writeType: CBCharacteristicWriteType = characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
|
||||
try await withCheckedThrowingContinuation { newWriteContinuation in
|
||||
if writeType == .withoutResponse {
|
||||
peripheral.writeValue(binaryData, for: characteristic, type: writeType)
|
||||
newWriteContinuation.resume()
|
||||
} else {
|
||||
writeContinuations.append(newWriteContinuation)
|
||||
peripheral.writeValue(binaryData, for: characteristic, type: writeType)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func read() async throws -> Data {
|
||||
guard let FROMRADIO_characteristic else {
|
||||
throw AccessoryError.ioFailed("No FROMRADIO_characteristic ")
|
||||
}
|
||||
let data: Data = try await withCheckedThrowingContinuation { newReadContinuation in
|
||||
readContinuations.append(newReadContinuation)
|
||||
peripheral.readValue(for: FROMRADIO_characteristic)
|
||||
}
|
||||
if data.isEmpty {
|
||||
Logger.transport.debug("🛜 [BLE] Received empty data, ending drain operation.")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
func handlePeripheralError(error: Error) async throws {
|
||||
var shouldReconnect = false
|
||||
switch error {
|
||||
case let cbError as CBError:
|
||||
switch cbError.code {
|
||||
case .unknown: // 0
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown error.")
|
||||
case .invalidParameters: // 1
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid parameters.")
|
||||
case .invalidHandle: // 2
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to invalid handle.")
|
||||
case .notConnected: // 3
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected because device was not connected.")
|
||||
case .outOfSpace: // 4
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to out of space.")
|
||||
case .operationCancelled: // 5
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation cancelled.")
|
||||
case .connectionTimeout: // 6
|
||||
// Should disconnect, show error, and retry when re-advertised
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection timeout.")
|
||||
shouldReconnect = true
|
||||
case .peripheralDisconnected: // 7
|
||||
// Likely prompting for a PIN
|
||||
// Should disconnect, show error, and retry when re-advertised
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected by peripheral.")
|
||||
shouldReconnect = true
|
||||
case .uuidNotAllowed: // 8
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to UUID not allowed.")
|
||||
case .alreadyAdvertising: // 9
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected because already advertising.")
|
||||
case .connectionFailed: // 10
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection failure.")
|
||||
case .connectionLimitReached: // 11
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to connection limit reached.")
|
||||
case .unknownDevice, .unkownDevice: // 12
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown device.")
|
||||
case .operationNotSupported: // 13
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to operation not supported.")
|
||||
case .peerRemovedPairingInformation: // 14
|
||||
// Should disconnect and not retry
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected because peer removed pairing information.")
|
||||
case .encryptionTimedOut: // 15
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to encryption timeout.")
|
||||
case .tooManyLEPairedDevices: // 16
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to too many LE paired devices.")
|
||||
|
||||
// leGatt cases are watchOS only
|
||||
case .leGattExceededBackgroundNotificationLimit: // 17
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to exceeding LE GATT background notification limit.")
|
||||
case .leGattNearBackgroundNotificationLimit: // 18
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to nearing LE GATT background notification limit.")
|
||||
|
||||
@unknown default:
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected due to unknown future error code: \(cbError.code.rawValue)")
|
||||
}
|
||||
case let otherError:
|
||||
Logger.transport.error("🛜 [BLEConnection] Disconnected with non-CBError: \(otherError.localizedDescription)")
|
||||
}
|
||||
|
||||
// Inform the active connection that there was an error and it should disconnect
|
||||
try self.disconnect(withError: error, shouldReconnect: shouldReconnect)
|
||||
}
|
||||
|
||||
func appDidEnterBackground() {
|
||||
if let task = self.rssiTask {
|
||||
Logger.transport.info("🛜 [BLE] App is entering the background, suspending RSSI reports.")
|
||||
task.cancel()
|
||||
self.rssiTask = nil
|
||||
}
|
||||
}
|
||||
|
||||
func appDidBecomeActive() {
|
||||
if self.rssiTask == nil {
|
||||
Logger.transport.info("🛜 [BLE] App is active, restarting RSSI reports.")
|
||||
self.startRSSITask()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BLEConnectionDelegate: NSObject, CBPeripheralDelegate {
|
||||
private weak var connection: BLEConnection?
|
||||
let peripheral: CBPeripheral
|
||||
|
||||
init(peripheral: CBPeripheral) {
|
||||
self.peripheral = peripheral
|
||||
super.init()
|
||||
peripheral.delegate = self
|
||||
}
|
||||
|
||||
func setConnection(_ connection: BLEConnection) {
|
||||
self.connection = connection
|
||||
}
|
||||
|
||||
// MARK: CBPeripheralDelegate
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
||||
Task { await connection?.didDiscoverServices(error: error) }
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
|
||||
Task { await connection?.didDiscoverCharacteristicsFor(service: service, error: error) }
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
Task { await connection?.didUpdateValueFor(characteristic: characteristic, error: error) }
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
|
||||
Task { await connection?.didWriteValueFor(characteristic: characteristic, error: error) }
|
||||
}
|
||||
|
||||
func peripheral(_ peripheral: CBPeripheral, didReadRSSI RSSI: NSNumber, error: Error?) {
|
||||
Task { await connection?.didReadRSSI(RSSI: RSSI, error: error) }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,394 @@
|
|||
//
|
||||
// BLETransport.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/10/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
@preconcurrency import CoreBluetooth
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
class BLETransport: Transport {
|
||||
|
||||
let meshtasticServiceCBUUID = CBUUID(string: "0x6BA1B218-15A8-461F-9FA8-5DCAE273EAFD")
|
||||
|
||||
let type: TransportType = .ble
|
||||
private var centralManager: CBCentralManager?
|
||||
private var discoveredPeripherals: [UUID: (peripheral: CBPeripheral, lastSeen: Date)] = [:]
|
||||
private var discoveredDeviceContinuation: AsyncStream<DiscoveryEvent>.Continuation?
|
||||
private let delegate: BLEDelegate
|
||||
private var connectingPeripheral: CBPeripheral?
|
||||
private var activeConnection: BLEConnection?
|
||||
private var connectContinuation: CheckedContinuation<BLEConnection, Error>?
|
||||
private var setupCompleteContinuation: CheckedContinuation<Void, Error>?
|
||||
|
||||
var status: TransportStatus = .uninitialized
|
||||
|
||||
private var cleanupTask: Task<Void, Never>?
|
||||
|
||||
// Transport properties
|
||||
var supportsManualConnection: Bool = false
|
||||
let requiresPeriodicHeartbeat = false
|
||||
|
||||
init() {
|
||||
self.centralManager = nil
|
||||
self.discoveredPeripherals = [:]
|
||||
self.discoveredDeviceContinuation = nil
|
||||
self.delegate = BLEDelegate()
|
||||
self.delegate.setTransport(self)
|
||||
}
|
||||
|
||||
nonisolated func discoverDevices() -> AsyncStream<DiscoveryEvent> {
|
||||
AsyncStream { cont in
|
||||
Task {
|
||||
self.discoveredDeviceContinuation = cont
|
||||
if self.centralManager == nil {
|
||||
try await self.setupCentralManager()
|
||||
}
|
||||
centralManager?.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true])
|
||||
|
||||
setupCleanupTask()
|
||||
}
|
||||
cont.onTermination = { _ in
|
||||
Logger.transport.error("🛜 [BLE] Discovery event stream has been canecelled.")
|
||||
self.stopScanning()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupCleanupTask() {
|
||||
if let task = self.cleanupTask {
|
||||
task.cancel()
|
||||
}
|
||||
self.cleanupTask = Task {
|
||||
while !Task.isCancelled {
|
||||
var keysToRemove: [UUID] = []
|
||||
for (deviceId, discoveryEntry) in self.discoveredPeripherals
|
||||
where Date().timeIntervalSince(discoveryEntry.lastSeen) > 30 {
|
||||
keysToRemove.append(deviceId)
|
||||
}
|
||||
for deviceId in keysToRemove {
|
||||
self.discoveredDeviceContinuation?.yield(.deviceLost(deviceId))
|
||||
self.discoveredPeripherals.removeValue(forKey: deviceId)
|
||||
}
|
||||
|
||||
try? await Task.sleep(for: .seconds(15)) // Cleanup every 15 seconds
|
||||
}
|
||||
Logger.transport.debug("🛜 [BLE] Discovery clean up task has been canecelled.")
|
||||
}
|
||||
}
|
||||
|
||||
private func setupCentralManager() async throws {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
self.setupCompleteContinuation = cont
|
||||
centralManager = CBCentralManager(delegate: delegate, queue: .global())
|
||||
}
|
||||
}
|
||||
|
||||
private func stopScanning() {
|
||||
Logger.transport.debug("🛜 [BLE] Stop Scanning: BLE Discovery has been stopped.")
|
||||
centralManager?.stopScan()
|
||||
discoveredPeripherals.removeAll()
|
||||
discoveredDeviceContinuation = nil
|
||||
if let state = centralManager?.state, state == .poweredOn {
|
||||
status = .ready
|
||||
} else {
|
||||
status = .uninitialized
|
||||
}
|
||||
centralManager = nil
|
||||
cleanupTask?.cancel()
|
||||
cleanupTask = nil
|
||||
}
|
||||
|
||||
func handleCentralState(_ state: CBManagerState, central: CBCentralManager) {
|
||||
Logger.transport.error("🛜 [BLE] State has transitioned to: \(cbManagerStateDescription(state), privacy: .public)")
|
||||
switch state {
|
||||
case .poweredOn:
|
||||
if activeConnection != nil {
|
||||
Logger.transport.info("🛜 [BLE] CBManager has poweredOn with an already active connection")
|
||||
}
|
||||
status = .discovering
|
||||
self.setupCompleteContinuation?.resume()
|
||||
self.setupCompleteContinuation = nil
|
||||
|
||||
if self.discoveredDeviceContinuation != nil {
|
||||
// We have someone already subscribed to our discovery event stream.
|
||||
// Likely a powerOff event occcurred and need to now restore scanning.
|
||||
central.scanForPeripherals(withServices: [meshtasticServiceCBUUID], options: [CBCentralManagerScanOptionAllowDuplicatesKey: true])
|
||||
}
|
||||
|
||||
case .poweredOff:
|
||||
status = .error("Bluetooth is powered off")
|
||||
if let connection = activeConnection {
|
||||
Task {
|
||||
Logger.transport.error("🛜 [BLE] Bluetooth has powered off during active connection. Cleaning up.")
|
||||
try await connection.disconnect(withError: AccessoryError.disconnected("Bluetooth powered off"), shouldReconnect: true)
|
||||
self.activeConnection = nil
|
||||
}
|
||||
}
|
||||
status = .ready
|
||||
self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is powered off"))
|
||||
self.setupCompleteContinuation = nil
|
||||
|
||||
case .unauthorized:
|
||||
status = .error("Bluetooth access is unauthorized")
|
||||
self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is unauthorized"))
|
||||
self.setupCompleteContinuation = nil
|
||||
|
||||
case .unsupported:
|
||||
status = .error("Bluetooth is unsupported on this device")
|
||||
self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Bluetooth is unsupported"))
|
||||
self.setupCompleteContinuation = nil
|
||||
|
||||
case .resetting:
|
||||
status = .error("Bluetooth is resetting")
|
||||
// Perhaps don't finish, wait for next state
|
||||
|
||||
case .unknown:
|
||||
status = .error("Bluetooth state is unknown")
|
||||
// Perhaps wait
|
||||
@unknown default:
|
||||
status = .error("Unknown Bluetooth state")
|
||||
self.setupCompleteContinuation?.resume(throwing: AccessoryError.connectionFailed("Unknown Bluetooth State"))
|
||||
self.setupCompleteContinuation = nil
|
||||
}
|
||||
}
|
||||
|
||||
func didDiscover(peripheral: CBPeripheral, rssi: NSNumber) {
|
||||
let id = peripheral.identifier
|
||||
let isNew = discoveredPeripherals[id] == nil
|
||||
if isNew {
|
||||
discoveredPeripherals[id] = (peripheral, Date())
|
||||
}
|
||||
let device = Device(id: id,
|
||||
name: peripheral.name ?? "Unknown",
|
||||
transportType: .ble,
|
||||
identifier: id.uuidString,
|
||||
rssi: rssi.intValue)
|
||||
if isNew {
|
||||
Logger.transport.debug("🛜 [BLE] Did Discover new device: \(peripheral.name ?? "Unknown", privacy: .public) (\(peripheral.identifier, privacy: .public))")
|
||||
discoveredDeviceContinuation?.yield(.deviceFound(device))
|
||||
} else {
|
||||
let rssiVal = rssi.intValue
|
||||
let deviceId = id
|
||||
discoveredPeripherals[id]?.lastSeen = Date()
|
||||
discoveredDeviceContinuation?.yield(.deviceReportedRssi(deviceId, rssiVal))
|
||||
}
|
||||
}
|
||||
|
||||
func connect(to device: Device) async throws -> any Connection {
|
||||
guard let peripheral = discoveredPeripherals[UUID(uuidString: device.identifier)!] else {
|
||||
throw AccessoryError.connectionFailed("Peripheral not found")
|
||||
}
|
||||
guard let cm = centralManager else {
|
||||
throw AccessoryError.connectionFailed("Central manager not available")
|
||||
}
|
||||
|
||||
if await self.activeConnection?.peripheral.state == .disconnected {
|
||||
Logger.transport.error("🛜 [BLE] Connect request while an active (but disconnected)")
|
||||
throw AccessoryError.connectionFailed("Connect request while an active connection exists")
|
||||
}
|
||||
|
||||
let returnConnection = try await withTaskCancellationHandler {
|
||||
let newConnection: BLEConnection = try await withCheckedThrowingContinuation { cont in
|
||||
if self.connectContinuation != nil || self.activeConnection != nil {
|
||||
cont.resume(throwing: AccessoryError.connectionFailed("BLE transport is busy: already connecting or connected"))
|
||||
return
|
||||
}
|
||||
self.connectContinuation = cont
|
||||
self.connectingPeripheral = peripheral.peripheral
|
||||
cm.connect(peripheral.peripheral)
|
||||
}
|
||||
self.activeConnection = newConnection
|
||||
return newConnection
|
||||
} onCancel: {
|
||||
self.connectContinuation?.resume(throwing: CancellationError())
|
||||
self.connectContinuation = nil
|
||||
self.activeConnection = nil
|
||||
self.connectingPeripheral = nil
|
||||
}
|
||||
Logger.transport.debug("🛜 [BLE] Connect complete.")
|
||||
return returnConnection
|
||||
}
|
||||
|
||||
func handlePeripheralDisconnect(peripheral: CBPeripheral) {
|
||||
if let connection = self.activeConnection {
|
||||
discoveredPeripherals.removeValue(forKey: peripheral.identifier)
|
||||
discoveredDeviceContinuation?.yield(.deviceLost(peripheral.identifier))
|
||||
Task {
|
||||
if await connection.peripheral.identifier == peripheral.identifier {
|
||||
try await connection.disconnect(withError: AccessoryError.disconnected("BLE connection lost"), shouldReconnect: true)
|
||||
self.activeConnection = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func handlePeripheralDisconnectError(peripheral: CBPeripheral, error: Error) {
|
||||
var shouldReconnect = false
|
||||
switch error {
|
||||
case let cbError as CBError:
|
||||
switch cbError.code {
|
||||
case .unknown: // 0
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown error.")
|
||||
case .invalidParameters: // 1
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid parameters.")
|
||||
case .invalidHandle: // 2
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to invalid handle.")
|
||||
case .notConnected: // 3
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected because device was not connected.")
|
||||
case .outOfSpace: // 4
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to out of space.")
|
||||
case .operationCancelled: // 5
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to operation cancelled.")
|
||||
case .connectionTimeout: // 6
|
||||
// Should disconnect, show error, and retry when re-advertised
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to connection timeout.")
|
||||
shouldReconnect = true
|
||||
case .peripheralDisconnected: // 7
|
||||
// Likely prompting for a PIN
|
||||
// Should disconnect, show error, and retry when re-advertised
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected by peripheral.")
|
||||
shouldReconnect = true
|
||||
case .uuidNotAllowed: // 8
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to UUID not allowed.")
|
||||
case .alreadyAdvertising: // 9
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected because already advertising.")
|
||||
case .connectionFailed: // 10
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to connection failure.")
|
||||
case .connectionLimitReached: // 11
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to connection limit reached.")
|
||||
case .unknownDevice, .unkownDevice: // 12
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown device.")
|
||||
case .operationNotSupported: // 13
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to operation not supported.")
|
||||
case .peerRemovedPairingInformation: // 14
|
||||
// Should disconnect and not retry
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected because peer removed pairing information.")
|
||||
case .encryptionTimedOut: // 15
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to encryption timeout.")
|
||||
case .tooManyLEPairedDevices: // 16
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to too many LE paired devices.")
|
||||
|
||||
// leGatt cases are watchOS only
|
||||
case .leGattExceededBackgroundNotificationLimit: // 17
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to exceeding LE GATT background notification limit.")
|
||||
case .leGattNearBackgroundNotificationLimit: // 18
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to nearing LE GATT background notification limit.")
|
||||
|
||||
@unknown default:
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected due to unknown future error code: \(cbError.code.rawValue)")
|
||||
}
|
||||
case let otherError:
|
||||
Logger.transport.error("🛜 [BLETransport] Disconnected with non-CBError: \(otherError.localizedDescription)")
|
||||
}
|
||||
|
||||
if let continuation = self.connectContinuation {
|
||||
Logger.transport.debug("🛜 [BLETransport] Error while connecting. Resuming connection continuation with error.")
|
||||
continuation.resume(throwing: error)
|
||||
self.connectContinuation = nil
|
||||
} else if let activeConnection = self.activeConnection {
|
||||
// Inform the active connection that there was an error and it should disconnect
|
||||
Logger.transport.debug("🛜 [BLETransport] Error while connecting. Disconnecting the active connection.")
|
||||
Task {
|
||||
try? await activeConnection.disconnect(withError: error, shouldReconnect: shouldReconnect)
|
||||
self.activeConnection = nil
|
||||
}
|
||||
} else {
|
||||
Logger.transport.error("🚨 [BLETransport] unhandled error. May be in an inconsistent state.")
|
||||
}
|
||||
}
|
||||
|
||||
func handleDidConnect(peripheral: CBPeripheral, central: CBCentralManager) {
|
||||
Logger.transport.debug("🛜 [BLE] Handle Did Connect Connected to peripheral \(peripheral.name ?? "Unknown", privacy: .public)")
|
||||
guard let cont = connectContinuation,
|
||||
let connPeripheral = connectingPeripheral,
|
||||
peripheral.identifier == connPeripheral.identifier else {
|
||||
return
|
||||
}
|
||||
let connection = BLEConnection(peripheral: peripheral, central: central, transport: self)
|
||||
cont.resume(returning: connection)
|
||||
self.connectContinuation = nil
|
||||
self.connectingPeripheral = nil
|
||||
}
|
||||
|
||||
func handleDidFailToConnect(peripheral: CBPeripheral, error: Error?) {
|
||||
guard let cont = connectContinuation,
|
||||
let connPeripheral = connectingPeripheral,
|
||||
peripheral.identifier == connPeripheral.identifier else {
|
||||
return
|
||||
}
|
||||
cont.resume(throwing: error ?? AccessoryError.connectionFailed("Connection failed"))
|
||||
self.connectContinuation = nil
|
||||
self.connectingPeripheral = nil
|
||||
}
|
||||
|
||||
func handleWillRestoreState(dict: [String: Any]) {
|
||||
Logger.transport.debug("🛜 [BLE] Will Restore State was called, unhandled. \(dict, privacy: .public)")
|
||||
}
|
||||
|
||||
func manuallyConnect(withConnectionString: String) async throws {
|
||||
Logger.transport.error("🛜 [BLE] This transport does not support manual connections")
|
||||
}
|
||||
|
||||
// BLETransport handles portions of the connection process, so it needs to be informed that we've closed up shop.
|
||||
func connectionDidDisconnect() {
|
||||
self.activeConnection = nil
|
||||
self.connectingPeripheral = nil
|
||||
}
|
||||
}
|
||||
|
||||
class BLEDelegate: NSObject, CBCentralManagerDelegate {
|
||||
private weak var transport: BLETransport?
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
}
|
||||
|
||||
func setTransport(_ transport: BLETransport) {
|
||||
self.transport = transport
|
||||
}
|
||||
|
||||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||||
transport?.handleCentralState(central.state, central: central)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String: Any], rssi RSSI: NSNumber) {
|
||||
transport?.didDiscover(peripheral: peripheral, rssi: RSSI)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||||
transport?.handleDidConnect(peripheral: peripheral, central: central)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
|
||||
transport?.handleDidFailToConnect(peripheral: peripheral, error: error)
|
||||
}
|
||||
|
||||
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
|
||||
if let error = error as? NSError {
|
||||
transport?.handlePeripheralDisconnectError(peripheral: peripheral, error: error)
|
||||
} else {
|
||||
transport?.handlePeripheralDisconnect(peripheral: peripheral)
|
||||
}
|
||||
}
|
||||
|
||||
// func centralManager(_ central: CBCentralManager, willRestoreState dict: [String : Any]) {
|
||||
// self.transport?.handleWillRestoreState(dict: dict)
|
||||
// }
|
||||
}
|
||||
|
||||
/// Returns a human-readable description for a CBManagerState value.
|
||||
private func cbManagerStateDescription(_ state: CBManagerState) -> String {
|
||||
switch state {
|
||||
case .unknown: return "unknown"
|
||||
case .resetting: return "resetting"
|
||||
case .unsupported: return "unsupported"
|
||||
case .unauthorized: return "unauthorized"
|
||||
case .poweredOff: return "poweredOff"
|
||||
case .poweredOn: return "poweredOn"
|
||||
@unknown default: return "unhandled state"
|
||||
}
|
||||
}
|
||||
263
Meshtastic/Accessory/Transports/Serial/SerialConnection.swift
Normal file
263
Meshtastic/Accessory/Transports/Serial/SerialConnection.swift
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
//
|
||||
// SerialConnection.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/22/25.
|
||||
//
|
||||
#if targetEnvironment(macCatalyst)
|
||||
import Foundation
|
||||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
import Darwin.POSIX.termios
|
||||
|
||||
/// Custom error type for serial connection handling.
|
||||
private enum SerialError: Error, LocalizedError {
|
||||
case eof
|
||||
case ioFailed(String)
|
||||
case notConnected
|
||||
case invalidPacketLength(UInt16)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .eof:
|
||||
return "End of file reached."
|
||||
case .ioFailed(let reason):
|
||||
return "I/O Error: \(reason)"
|
||||
case .notConnected:
|
||||
return "Serial port not connected."
|
||||
case .invalidPacketLength(let length):
|
||||
return "Invalid packet length received: \(length)."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
actor SerialConnection: Connection {
|
||||
let type = TransportType.serial
|
||||
private let path: String
|
||||
private var fd: Int32 = -1
|
||||
private var fileHandle: FileHandle?
|
||||
private var isOpen: Bool = false
|
||||
|
||||
// For DispatchSourceRead implementation
|
||||
private var readSource: DispatchSourceRead?
|
||||
private let readQueue = DispatchQueue(label: "com.meshtastic.serial.read")
|
||||
private var readBuffer = Data()
|
||||
|
||||
private var eventStreamContinuation: AsyncStream<ConnectionEvent>.Continuation?
|
||||
|
||||
var isConnected: Bool { isOpen }
|
||||
|
||||
init(path: String) {
|
||||
self.path = path
|
||||
}
|
||||
|
||||
// MARK: - Reading Logic (DispatchSourceRead Implementation)
|
||||
|
||||
/// Processes the internal buffer to find and yield complete packets.
|
||||
/// This method is always called on the actor's context.
|
||||
private func processBuffer() {
|
||||
let startOfFrame: [UInt8] = [0x94, 0xc3]
|
||||
|
||||
while !readBuffer.isEmpty {
|
||||
guard let startIndex = readBuffer.firstRange(of: startOfFrame)?.lowerBound else {
|
||||
readBuffer.removeAll()
|
||||
return
|
||||
}
|
||||
|
||||
if startIndex > readBuffer.startIndex {
|
||||
readBuffer.removeSubrange(readBuffer.startIndex..<startIndex)
|
||||
}
|
||||
|
||||
guard readBuffer.count >= 4 else { return }
|
||||
|
||||
let lengthBytes = readBuffer.subdata(in: 2..<4)
|
||||
let length = lengthBytes.withUnsafeBytes { $0.load(as: UInt16.self).bigEndian }
|
||||
|
||||
let totalPacketLength = 4 + Int(length)
|
||||
|
||||
guard readBuffer.count >= totalPacketLength else { return }
|
||||
|
||||
let payload = readBuffer.subdata(in: 4..<totalPacketLength)
|
||||
|
||||
if let fromRadio = try? FromRadio(serializedBytes: payload) {
|
||||
eventStreamContinuation?.yield(.data(fromRadio))
|
||||
} else {
|
||||
Logger.transport.error("🔱 [Serial] Failed to deserialize payload. Skipping packet.")
|
||||
}
|
||||
|
||||
readBuffer.removeSubrange(0..<totalPacketLength)
|
||||
}
|
||||
}
|
||||
|
||||
/// The main reader setup, using a DispatchSourceRead for non-blocking I/O.
|
||||
private func startReader() {
|
||||
guard let fileHandle = self.fileHandle else { return }
|
||||
|
||||
let source = DispatchSource.makeReadSource(fileDescriptor: fileHandle.fileDescriptor, queue: readQueue)
|
||||
self.readSource = source
|
||||
|
||||
// The event handler is non-isolated. It must hop back to the actor to access state.
|
||||
source.setEventHandler { [weak self] in
|
||||
let bytesAvailable = source.data
|
||||
Task {
|
||||
if bytesAvailable > 0 {
|
||||
await self?.handleDataAvailable(bytesAvailable: Int(bytesAvailable))
|
||||
} else {
|
||||
await self?.handleReaderEOF()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The cancellation handler also hops back to the actor to clean up.
|
||||
source.setCancelHandler { [weak self] in
|
||||
Task {
|
||||
try? await self?.disconnect(withError: AccessoryError.disconnected("Serial connection lost"), shouldReconnect: true)
|
||||
}
|
||||
}
|
||||
|
||||
source.resume()
|
||||
}
|
||||
|
||||
/// Reads available data from the file handle and processes it.
|
||||
/// This method is always called on the actor's context via a Task.
|
||||
private func handleDataAvailable(bytesAvailable: Int) {
|
||||
guard isOpen, let fileHandle = self.fileHandle else {
|
||||
readSource?.cancel()
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
if let data = try fileHandle.read(upToCount: bytesAvailable) {
|
||||
if !data.isEmpty {
|
||||
appendAndProcess(data: data)
|
||||
} else {
|
||||
handleReaderEOF()
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.transport.error("🔱 [Serial] Read error: \(error, privacy: .public)")
|
||||
handleReaderEOF()
|
||||
}
|
||||
}
|
||||
|
||||
// Actor-isolated methods to be called from other actor-isolated methods.
|
||||
private func appendAndProcess(data: Data) {
|
||||
readBuffer.append(data)
|
||||
processBuffer()
|
||||
}
|
||||
|
||||
private func handleReaderEOF() {
|
||||
Logger.transport.info("🔱 [Serial] Reached end of file. Closing connection.")
|
||||
readSource?.cancel()
|
||||
}
|
||||
|
||||
// MARK: - Connection Lifecycle
|
||||
|
||||
func connect() async throws -> AsyncStream<ConnectionEvent> {
|
||||
fd = open(path, O_RDWR | O_NOCTTY | O_NONBLOCK)
|
||||
if fd == -1 {
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno)!)
|
||||
}
|
||||
|
||||
var term = termios()
|
||||
if tcgetattr(fd, &term) == -1 {
|
||||
close(fd)
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno)!)
|
||||
}
|
||||
|
||||
cfmakeraw(&term)
|
||||
|
||||
term.c_cflag = UInt((CS8 | CREAD | CLOCAL))
|
||||
term.c_oflag = 0
|
||||
term.c_iflag = 0
|
||||
term.c_lflag = 0
|
||||
|
||||
term.c_cc.16 = 0 // VMIN
|
||||
term.c_cc.17 = 1 // VTIME (1 decisecond = 100ms)
|
||||
|
||||
if cfsetspeed(&term, 115200) == -1 {
|
||||
close(fd)
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno)!)
|
||||
}
|
||||
|
||||
if tcsetattr(fd, TCSANOW, &term) == -1 {
|
||||
close(fd)
|
||||
throw POSIXError(POSIXErrorCode(rawValue: errno)!)
|
||||
}
|
||||
|
||||
self.fileHandle = FileHandle(fileDescriptor: fd, closeOnDealloc: true)
|
||||
self.isOpen = true
|
||||
|
||||
startReader()
|
||||
return getPacketStream()
|
||||
}
|
||||
|
||||
func disconnect(withError error: Error? = nil, shouldReconnect: Bool) throws {
|
||||
if let error {
|
||||
// Inform the AccessoryManager of the error and intent to reconnect
|
||||
if shouldReconnect {
|
||||
eventStreamContinuation?.yield(.error(error))
|
||||
} else {
|
||||
eventStreamContinuation?.yield(.errorWithoutReconnect(error))
|
||||
}
|
||||
} else {
|
||||
eventStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect))
|
||||
}
|
||||
eventStreamContinuation?.finish()
|
||||
eventStreamContinuation = nil
|
||||
|
||||
if isOpen {
|
||||
isOpen = false
|
||||
try? fileHandle?.close()
|
||||
fileHandle = nil
|
||||
fd = -1
|
||||
readSource?.cancel()
|
||||
readSource = nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Sending Data
|
||||
|
||||
func send(_ data: ToRadio) async throws {
|
||||
guard isOpen, let fileHandle = self.fileHandle else {
|
||||
throw SerialError.notConnected
|
||||
}
|
||||
let serialized = try data.serializedData()
|
||||
var buffer = Data([0x94, 0xc3])
|
||||
var len: UInt16 = UInt16(serialized.count).bigEndian
|
||||
buffer.append(Data(bytes: &len, count: 2))
|
||||
buffer.append(serialized)
|
||||
|
||||
do {
|
||||
try fileHandle.write(contentsOf: buffer)
|
||||
} catch {
|
||||
throw SerialError.ioFailed(error.localizedDescription)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Stream Management
|
||||
private func getPacketStream() -> AsyncStream<ConnectionEvent> {
|
||||
AsyncStream<ConnectionEvent> { continuation in
|
||||
self.eventStreamContinuation = continuation
|
||||
continuation.onTermination = { _ in
|
||||
Task {
|
||||
await self.readSource?.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// These methods are part of the Connection protocol but are not needed
|
||||
// for a continuously-reading serial connection.
|
||||
func drainPendingPackets() async throws {}
|
||||
func startDrainPendingPackets() throws {}
|
||||
|
||||
func appDidEnterBackground() {
|
||||
|
||||
}
|
||||
|
||||
func appDidBecomeActive() {
|
||||
|
||||
}
|
||||
}
|
||||
#endif
|
||||
125
Meshtastic/Accessory/Transports/Serial/SerialTransport.swift
Normal file
125
Meshtastic/Accessory/Transports/Serial/SerialTransport.swift
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
//
|
||||
// SerialTransport.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/22/25.
|
||||
//
|
||||
|
||||
#if targetEnvironment(macCatalyst)
|
||||
|
||||
import Foundation
|
||||
import OSLog
|
||||
import IOKit.serial
|
||||
import SwiftUI
|
||||
|
||||
class SerialTransport: Transport {
|
||||
|
||||
let type: TransportType = .serial
|
||||
var status: TransportStatus = .uninitialized
|
||||
|
||||
// Transport Properties
|
||||
let requiresPeriodicHeartbeat = true
|
||||
let supportsManualConnection = false
|
||||
|
||||
var portsAlreadyNotified = [String]()
|
||||
var discoveryTask: Task<Void, Never>?
|
||||
|
||||
func discoverDevices() -> AsyncStream<DiscoveryEvent> {
|
||||
AsyncStream { cont in
|
||||
self.status = .discovering
|
||||
self.discoveryTask = Task {
|
||||
while !Task.isCancelled {
|
||||
let ports = self.getSerialPorts()
|
||||
for port in ports {
|
||||
let id = port.toUUIDFormatHash() ?? UUID()
|
||||
if !portsAlreadyNotified.contains(port) {
|
||||
Logger.transport.info("🔱 [Serial] Port \(port, privacy: .public) found.")
|
||||
let newDevice = Device(id: id,
|
||||
name: port.components(separatedBy: "/").last ?? port,
|
||||
transportType: .serial,
|
||||
identifier: port)
|
||||
cont.yield(.deviceFound(newDevice))
|
||||
portsAlreadyNotified.append(port)
|
||||
}
|
||||
}
|
||||
for knownPort in portsAlreadyNotified where !ports.contains(knownPort) {
|
||||
// Previosuly seen port is no longer available
|
||||
Logger.transport.info("🔱 [Serial] Port \(knownPort, privacy: .public) is no longer connected.")
|
||||
if let uuid = knownPort.toUUIDFormatHash() {
|
||||
cont.yield(.deviceLost(uuid))
|
||||
}
|
||||
portsAlreadyNotified.removeAll(where: {$0 == knownPort})
|
||||
}
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
}
|
||||
}
|
||||
cont.onTermination = { _ in
|
||||
self.discoveryTask?.cancel()
|
||||
self.discoveryTask = nil
|
||||
self.portsAlreadyNotified.removeAll()
|
||||
self.status = .ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// DEPRICATED: old approach is just matching filenames
|
||||
// private func getSerialPorts() -> [String] {
|
||||
// do {
|
||||
// let dev = "/dev"
|
||||
// let contents = try FileManager.default.contentsOfDirectory(atPath: dev)
|
||||
// return contents.filter { $0.hasPrefix("cu.") || $0.hasPrefix("tty.") }.map { dev + "/" + $0 }
|
||||
// } catch {
|
||||
// Logger.transport.error("[Serial] Error listing /dev: \(error, privacy: .public)")
|
||||
// return []
|
||||
// }
|
||||
// }
|
||||
|
||||
// New approach, return only specific USB serial devices
|
||||
private func getSerialPorts() -> [String] {
|
||||
var serialPortIterator: io_iterator_t = 0
|
||||
var paths: [String] = []
|
||||
|
||||
// Create a matching dictionary for all serial BSD services
|
||||
guard let matchingDict = IOServiceMatching(kIOSerialBSDServiceValue) as? [String: Any] else {
|
||||
return []
|
||||
}
|
||||
_ = matchingDict.merging([kIOSerialBSDTypeKey: kIOSerialBSDAllTypes]) { _, new in new }
|
||||
|
||||
// Get the iterator for matching services
|
||||
let result = IOServiceGetMatchingServices(kIOMainPortDefault, matchingDict as CFDictionary, &serialPortIterator)
|
||||
if result != KERN_SUCCESS {
|
||||
return []
|
||||
}
|
||||
defer { IOObjectRelease(serialPortIterator) }
|
||||
|
||||
// Iterate through services and extract callout paths (/dev/cu.xxx) only if they have a USB Serial Number property
|
||||
var serialService: io_object_t = 0
|
||||
let usbSerialKey = "USB Serial Number" as CFString
|
||||
let searchOptions: IOOptionBits = UInt32(kIORegistryIterateRecursively | kIORegistryIterateParents)
|
||||
|
||||
repeat {
|
||||
serialService = IOIteratorNext(serialPortIterator)
|
||||
if serialService != 0 {
|
||||
// Check for USB Serial Number in the service or its parents
|
||||
if IORegistryEntrySearchCFProperty(serialService, kIOServicePlane, usbSerialKey, kCFAllocatorDefault, searchOptions) != nil {
|
||||
// Property exists, so this is a USB serial device; get the path
|
||||
if let path = IORegistryEntryCreateCFProperty(serialService, kIOCalloutDeviceKey as CFString, kCFAllocatorDefault, 0).takeRetainedValue() as? String {
|
||||
paths.append(path)
|
||||
}
|
||||
}
|
||||
IOObjectRelease(serialService)
|
||||
}
|
||||
} while serialService != 0
|
||||
|
||||
return paths.sorted() // Sort for consistent UX
|
||||
}
|
||||
|
||||
func connect(to device: Device) async throws -> any Connection {
|
||||
return SerialConnection(path: device.identifier)
|
||||
}
|
||||
|
||||
func manuallyConnect(withConnectionString: String) async throws {
|
||||
Logger.transport.error("🔱 [USB] This transport does not support manual connections")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
227
Meshtastic/Accessory/Transports/TCP/TCPConnection.swift
Normal file
227
Meshtastic/Accessory/Transports/TCP/TCPConnection.swift
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
//
|
||||
// TCPConnection.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/19/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
|
||||
actor TCPConnection: Connection {
|
||||
let type = TransportType.tcp
|
||||
|
||||
private var connection: NWConnection?
|
||||
private let queue = DispatchQueue(label: "tcp.connection")
|
||||
private var readerTask: Task<Void, Never>?
|
||||
private let nwHost: NWEndpoint.Host
|
||||
private let nwPort: NWEndpoint.Port
|
||||
|
||||
private var connectionStreamContinuation: AsyncStream<ConnectionEvent>.Continuation?
|
||||
|
||||
var isConnected: Bool {
|
||||
connection?.state == .ready
|
||||
}
|
||||
|
||||
init(host: String, port: Int) async throws {
|
||||
self.nwHost = NWEndpoint.Host(host)
|
||||
self.nwPort = NWEndpoint.Port(integerLiteral: UInt16(port))
|
||||
}
|
||||
|
||||
private func waitForMagicBytes() async throws -> Bool {
|
||||
let startOfFrame: [UInt8] = [0x94, 0xc3]
|
||||
var waitingOnByte = 0
|
||||
while true {
|
||||
let data = try await receiveData(min: 1, max: 1)
|
||||
if data.count != 1 {
|
||||
// End of stream
|
||||
return false
|
||||
}
|
||||
|
||||
if data[0] == startOfFrame[waitingOnByte] {
|
||||
waitingOnByte += 1
|
||||
} else {
|
||||
waitingOnByte = 0
|
||||
}
|
||||
|
||||
if waitingOnByte > 1 {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func readInteger() async throws -> UInt16? {
|
||||
let data = try await receiveData(min: 2, max: 2)
|
||||
if data.count == 2 {
|
||||
let value = data.withUnsafeBytes { $0.load(as: UInt16.self).bigEndian }
|
||||
return value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private func startReader() {
|
||||
// TODO: @MainActor here because packets come into AccessoryManager out of order otherwise. Need to figure out the concurrency
|
||||
readerTask = Task { @MainActor in
|
||||
while await isConnected {
|
||||
do {
|
||||
if try await waitForMagicBytes() == false {
|
||||
Logger.transport.debug("🌐 [TCP] startReader: EOF while waiting for magic bytes")
|
||||
continue
|
||||
}
|
||||
// Logger.transport.debug("[TCP] startReader: Found magic byte, waiting for length")
|
||||
|
||||
if let length = try? await readInteger() {
|
||||
let payload = try await receiveData(min: Int(length), max: Int(length))
|
||||
if let fromRadio = try? FromRadio(serializedBytes: payload) {
|
||||
await connectionStreamContinuation?.yield(.data(fromRadio))
|
||||
} else {
|
||||
try await self.disconnect(withError: AccessoryError.disconnected("Network connection dropped"), shouldReconnect: true)
|
||||
}
|
||||
} else {
|
||||
Logger.transport.debug("🌐 [TCP] startReader: EOF while waiting for length")
|
||||
}
|
||||
} catch {
|
||||
Logger.transport.error("🌐 [TCP] startReader: Error reading from TCP: \(error, privacy: .public)")
|
||||
try? await self.disconnect(withError: error, shouldReconnect: true)
|
||||
break
|
||||
}
|
||||
}
|
||||
// Logger.services.error("End of TCP reading task: isConnected:\(self.isConnected)")
|
||||
}
|
||||
}
|
||||
|
||||
private func receiveData(min: Int, max: Int) async throws -> Data {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
connection?.receive(minimumIncompleteLength: min, maximumLength: max) { content, _, isComplete, error in
|
||||
if let error = error {
|
||||
cont.resume(throwing: error)
|
||||
return
|
||||
}
|
||||
if isComplete {
|
||||
// cont.resume(returning: Data())
|
||||
cont.resume(throwing: AccessoryError.disconnected("Error while receiving data"))
|
||||
return
|
||||
}
|
||||
cont.resume(returning: content ?? Data())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func send(_ data: ToRadio) async throws {
|
||||
let serialized = try data.serializedData()
|
||||
var buffer = Data()
|
||||
buffer.append(0x94)
|
||||
buffer.append(0xc3)
|
||||
var len = UInt16(serialized.count).bigEndian
|
||||
withUnsafeBytes(of: &len) { buffer.append(contentsOf: $0) }
|
||||
buffer.append(serialized)
|
||||
|
||||
try await withCheckedThrowingContinuation { (cont: CheckedContinuation<Void, Error>) in
|
||||
connection?.send(content: buffer, completion: .contentProcessed { error in
|
||||
if let error = error {
|
||||
cont.resume(throwing: error)
|
||||
} else {
|
||||
cont.resume()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func disconnect(withError error: Error? = nil, shouldReconnect: Bool) throws {
|
||||
Logger.transport.debug("🌐 [TCP] Disconnecting from TCP connection")
|
||||
readerTask?.cancel()
|
||||
readerTask = nil
|
||||
|
||||
connection?.cancel()
|
||||
connection = nil
|
||||
|
||||
if let error {
|
||||
// Inform the AccessoryManager of the error and intent to reconnect
|
||||
if shouldReconnect {
|
||||
connectionStreamContinuation?.yield(.error(error))
|
||||
} else {
|
||||
connectionStreamContinuation?.yield(.errorWithoutReconnect(error))
|
||||
}
|
||||
} else {
|
||||
connectionStreamContinuation?.yield(.disconnected(shouldReconnect: shouldReconnect))
|
||||
}
|
||||
|
||||
connectionStreamContinuation?.finish()
|
||||
connectionStreamContinuation = nil
|
||||
}
|
||||
|
||||
func drainPendingPackets() async throws {
|
||||
// For TCP, since reader is always running, no need to drain separately
|
||||
}
|
||||
|
||||
func startDrainPendingPackets() throws {
|
||||
// For TCP, reader is already started
|
||||
}
|
||||
|
||||
private func getPacketStream() -> AsyncStream<ConnectionEvent> {
|
||||
AsyncStream<ConnectionEvent> { continuation in
|
||||
self.connectionStreamContinuation = continuation
|
||||
continuation.onTermination = { _ in
|
||||
Task { try await self.disconnect(withError: AccessoryError.eventStreamCancelled, shouldReconnect: true) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func connect() async throws -> AsyncStream<ConnectionEvent> {
|
||||
let newConnection = NWConnection(host: nwHost, port: nwPort, using: .tcp)
|
||||
self.connection = newConnection
|
||||
|
||||
try await withTaskCancellationHandler {
|
||||
try await withCheckedThrowingContinuation { cont in
|
||||
newConnection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .ready:
|
||||
cont.resume()
|
||||
case .failed(let error):
|
||||
cont.resume(throwing: error)
|
||||
case .cancelled:
|
||||
cont.resume(throwing: CancellationError())
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
newConnection.start(queue: queue)
|
||||
}
|
||||
} onCancel: {
|
||||
newConnection.cancel()
|
||||
}
|
||||
|
||||
// We've gotten here past the connection and since we haven't thrown, the
|
||||
// connection is in the ready state.
|
||||
|
||||
// Update the state connection handler for in-progress monitoring of state
|
||||
// changes while connected.
|
||||
newConnection.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .failed(let error):
|
||||
Logger.transport.error("🌐 [TCP] Connection failed after ready: \(error, privacy: .public)")
|
||||
Task {
|
||||
try? await self.disconnect(withError: error, shouldReconnect: true)
|
||||
}
|
||||
case .cancelled:
|
||||
Logger.transport.debug("🌐 [TCP] Connection cancelled")
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
startReader()
|
||||
return getPacketStream()
|
||||
|
||||
}
|
||||
|
||||
func appDidEnterBackground() {
|
||||
|
||||
}
|
||||
|
||||
func appDidBecomeActive() {
|
||||
|
||||
}
|
||||
}
|
||||
249
Meshtastic/Accessory/Transports/TCP/TCPTransport.swift
Normal file
249
Meshtastic/Accessory/Transports/TCP/TCPTransport.swift
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
//
|
||||
// TCPTransport.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/19/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Network
|
||||
import OSLog
|
||||
import MeshtasticProtobufs
|
||||
import SwiftUI
|
||||
|
||||
let MESHTASTIC_SERVICE_TYPE = "_meshtastic._tcp."
|
||||
let MESHTASTIC_DOMAIN = "local."
|
||||
|
||||
class TCPTransport: NSObject, Transport, NetServiceBrowserDelegate, NetServiceDelegate {
|
||||
|
||||
let type: TransportType = .tcp
|
||||
var status: TransportStatus = .uninitialized
|
||||
// TODO: Move to NWBrowser (NetServiceBrowser is depricated)
|
||||
private var browser: NetServiceBrowser?
|
||||
private var services: [String: ResolvedService] = [:] // Key: service.name
|
||||
private var continuation: AsyncStream<DiscoveryEvent>.Continuation?
|
||||
|
||||
private var service: NetService?
|
||||
|
||||
// Transport Properties
|
||||
let requiresPeriodicHeartbeat = true
|
||||
let supportsManualConnection = true
|
||||
|
||||
struct ResolvedService {
|
||||
let id: UUID
|
||||
let service: NetService
|
||||
let host: String
|
||||
let port: Int
|
||||
}
|
||||
|
||||
override init() {
|
||||
super.init()
|
||||
browser = NetServiceBrowser()
|
||||
browser?.delegate = self
|
||||
}
|
||||
|
||||
func discoverDevices() -> AsyncStream<DiscoveryEvent> {
|
||||
AsyncStream { cont in
|
||||
self.continuation = cont
|
||||
self.status = .discovering
|
||||
Task {
|
||||
self.browser?.searchForServices(ofType: MESHTASTIC_SERVICE_TYPE, inDomain: MESHTASTIC_DOMAIN)
|
||||
}
|
||||
cont.onTermination = { _ in
|
||||
self.browser?.stop()
|
||||
self.services.removeAll()
|
||||
self.continuation = nil
|
||||
self.status = .ready
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didFind service: NetService, moreComing: Bool) {
|
||||
self.service = service
|
||||
service.delegate = self
|
||||
service.resolve(withTimeout: 5)
|
||||
}
|
||||
|
||||
func netServiceDidResolveAddress(_ service: NetService) {
|
||||
guard let host = service.hostName else {
|
||||
Logger.transport.error("🌐 [TCP] Failed to resolve host for service \(service.name, privacy: .public)")
|
||||
return
|
||||
}
|
||||
let port = service.port
|
||||
let ip = service.ipv4Address ?? "Unknown IP"
|
||||
|
||||
// Use a mishmash of things and hash for stable? ID.
|
||||
let idString = "\(service.name):\(host):\(ip):\(port)".toUUIDFormatHash() ?? UUID()
|
||||
|
||||
// Save the resolved service locally for later
|
||||
services[service.name] = ResolvedService(id: idString, service: service, host: host, port: port)
|
||||
|
||||
let device = Device(id: idString,
|
||||
name: "\(service.name) (\(ip))",
|
||||
transportType: .tcp,
|
||||
identifier: "\(host):\(port)")
|
||||
continuation?.yield(.deviceFound(device))
|
||||
}
|
||||
|
||||
func netService(_ sender: NetService, didNotResolve errorDict: [String: NSNumber]) {
|
||||
Logger.transport.error("🌐 [TCP] Failed to resolve service \(sender.name, privacy: .public): \(errorDict, privacy: .public)")
|
||||
}
|
||||
|
||||
func connect(to device: Device) async throws -> any Connection {
|
||||
Logger.transport.debug("🌐 [TCP] Connect to device: \(device.name, privacy: .public) with identifier: \(device.identifier, privacy: .public)")
|
||||
let parts = device.identifier.split(separator: ":")
|
||||
|
||||
var host: String?
|
||||
var port: Int?
|
||||
|
||||
switch parts.count {
|
||||
case 1:
|
||||
// host & default port
|
||||
host = String(parts[0])
|
||||
port = 4403
|
||||
case 2:
|
||||
// host & port
|
||||
host = String(parts[0])
|
||||
port = Int(parts[1])
|
||||
default:
|
||||
throw AccessoryError.connectionFailed("Invalid identifier format")
|
||||
}
|
||||
guard let host, let port else {
|
||||
throw AccessoryError.connectionFailed("Invalid identifier format")
|
||||
}
|
||||
|
||||
return try await TCPConnection(host: host, port: port)
|
||||
}
|
||||
|
||||
func netServiceBrowser(_ browser: NetServiceBrowser, didRemove service: NetService, moreComing: Bool) {
|
||||
guard let leavingService = services[service.name] else {
|
||||
Logger.transport.error("🌐 [TCP] Service \(service.name, privacy: .public) not found in resolved services")
|
||||
return
|
||||
}
|
||||
|
||||
// Notify the downstream
|
||||
self.continuation?.yield(.deviceLost(leavingService.id))
|
||||
|
||||
// Clean up the resolved services list
|
||||
var keysToRemove = [String]()
|
||||
for (key, value) in services where value.service == service {
|
||||
keysToRemove.append(key)
|
||||
}
|
||||
for removeKey in keysToRemove {
|
||||
services.removeValue(forKey: removeKey)
|
||||
}
|
||||
}
|
||||
|
||||
func manuallyConnect(withConnectionString: String) async throws {
|
||||
let hashedIdentifier = withConnectionString.toUUIDFormatHash() ?? UUID()
|
||||
let manualDevice = Device(id: hashedIdentifier,
|
||||
name: "\(withConnectionString) (Manual)",
|
||||
transportType: .tcp, identifier: withConnectionString)
|
||||
try await AccessoryManager.shared.connect(to: manualDevice)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
extension NetService {
|
||||
var ipv4Address: String? {
|
||||
for addressData in addresses ?? [] {
|
||||
// sockaddr_in is typically 16 bytes; skip if too small
|
||||
guard addressData.count >= 16 else { continue }
|
||||
|
||||
// Byte 1: sin_family (AF_INET == 2 for IPv4)
|
||||
let family = addressData[1]
|
||||
guard family == UInt8(AF_INET) else { continue }
|
||||
|
||||
// Bytes 4-7: sin_addr.s_addr (IPv4 address in network byte order)
|
||||
let ipBytes = addressData[4..<8]
|
||||
|
||||
// Convert each byte to string and join with dots
|
||||
return ipBytes.map { String($0) }.joined(separator: ".")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
extension TCPTransport {
|
||||
static func requestLocalNetworkAuthorization() async -> Bool {
|
||||
await withCheckedContinuation { continuation in
|
||||
var resumeContinuation: CheckedContinuation<Bool, Never>? = continuation
|
||||
let resumeOnce: (Bool) -> Void = { result in
|
||||
resumeContinuation?.resume(returning: result)
|
||||
resumeContinuation = nil
|
||||
}
|
||||
|
||||
let queue = DispatchQueue(label: "com.meshtastic.localNetworkAuth")
|
||||
|
||||
let listener: NWListener
|
||||
do {
|
||||
listener = try NWListener(using: .tcp)
|
||||
} catch {
|
||||
Logger.transport.error("🌐 [TCP Permissions] Failed to create NWListener: \(error)")
|
||||
resumeOnce(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Use a unique name to avoid conflicts
|
||||
let uniqueName = UUID().uuidString
|
||||
listener.service = NWListener.Service(name: uniqueName, type: MESHTASTIC_SERVICE_TYPE, domain: MESHTASTIC_DOMAIN)
|
||||
|
||||
listener.newConnectionHandler = { _ in } // Required to avoid errors
|
||||
|
||||
listener.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .setup, .waiting, .ready, .cancelled:
|
||||
// No-op
|
||||
break
|
||||
case .failed(let error):
|
||||
Logger.transport.error("🌐 [TCP Permissions] Authorization NWListener failed: \(error)")
|
||||
resumeOnce(false)
|
||||
listener.cancel()
|
||||
@unknown default:
|
||||
Logger.transport.debug("🌐 [TCP Permissions] Authorization NWListener unknown state")
|
||||
}
|
||||
}
|
||||
|
||||
listener.start(queue: queue)
|
||||
|
||||
let parameters = NWParameters.tcp
|
||||
parameters.includePeerToPeer = true
|
||||
|
||||
let browser = NWBrowser(for: .bonjour(type: MESHTASTIC_SERVICE_TYPE, domain: MESHTASTIC_DOMAIN ?? "local."), using: parameters)
|
||||
|
||||
browser.stateUpdateHandler = { state in
|
||||
switch state {
|
||||
case .setup, .ready, .cancelled:
|
||||
// No-op
|
||||
break
|
||||
case .waiting(let error):
|
||||
Logger.transport.debug("🌐 [TCP Permissions] Authorization NWBrowser waiting: \(error)")
|
||||
if case .dns(let dnsError) = error, dnsError == DNSServiceErrorType(kDNSServiceErr_PolicyDenied) { // Or check rawValue == -72003
|
||||
resumeOnce(false)
|
||||
browser.cancel()
|
||||
listener.cancel()
|
||||
}
|
||||
case .failed(let error):
|
||||
Logger.transport.error("🌐 [TCP Permissions] Authorization NWBrowser failed: \(error)")
|
||||
resumeOnce(false)
|
||||
browser.cancel()
|
||||
listener.cancel()
|
||||
@unknown default:
|
||||
Logger.transport.debug("🌐 [TCP] Authorization NWBrowser unknown state")
|
||||
}
|
||||
}
|
||||
|
||||
// Key addition: Detect success when the browser finds the service (permission granted)
|
||||
browser.browseResultsChangedHandler = { results, _ in
|
||||
if !results.isEmpty {
|
||||
Logger.transport.debug("🌐 [TCP Permissions] Authorization NWBrowser found results, permission granted")
|
||||
resumeOnce(true)
|
||||
browser.cancel()
|
||||
listener.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
browser.start(queue: queue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@ import AppIntents
|
|||
import MeshtasticProtobufs
|
||||
|
||||
struct AddContactIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Add Contact"
|
||||
static var description: IntentDescription = "Takes a Meshtastic contact URL and saves it to the nodes database"
|
||||
static let title: LocalizedStringResource = "Add Contact"
|
||||
static let description: IntentDescription = "Takes a Meshtastic contact URL and saves it to the nodes database"
|
||||
|
||||
@Parameter(title: "Contact URL", description: "The URL for the node to add")
|
||||
var contactUrl: URL
|
||||
|
|
@ -18,7 +18,7 @@ struct AddContactIntent: AppIntent {
|
|||
// Define the function that performs the main logic
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Ensure the BLE Manager is connected
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
|
|
@ -27,15 +27,11 @@ struct AddContactIntent: AppIntent {
|
|||
// Extract contact information from the URL
|
||||
if let contactData = components.last {
|
||||
let decodedString = contactData.base64urlToBase64()
|
||||
if let decodedData = Data(base64Encoded: decodedString) {
|
||||
if let _ = Data(base64Encoded: decodedString) {
|
||||
do {
|
||||
let success = BLEManager.shared.addContactFromURL(base64UrlString: contactData)
|
||||
if !success {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to add contact")
|
||||
}
|
||||
|
||||
try await AccessoryManager.shared.addContactFromURL(base64UrlString: contactData)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to parse contact data: \(error.localizedDescription)")
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to add/parse contact data: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,19 +9,19 @@ import Foundation
|
|||
import AppIntents
|
||||
|
||||
struct DisconnectNodeIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Disconnect Node"
|
||||
static let title: LocalizedStringResource = "Disconnect Node"
|
||||
|
||||
static var description: IntentDescription = "Disconnect the currently connected node"
|
||||
static let description: IntentDescription = "Disconnect the currently connected node"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
if !BLEManager.shared.isConnected {
|
||||
let isConnected = await AccessoryManager.shared.isConnected
|
||||
if !isConnected {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
if let connectedPeripheral = BLEManager.shared.connectedPeripheral,
|
||||
connectedPeripheral.peripheral.state == .connected {
|
||||
BLEManager.shared.disconnectPeripheral(reconnect: false)
|
||||
} else {
|
||||
do {
|
||||
try await AccessoryManager.shared.disconnect()
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Error disconnecting node")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import Foundation
|
|||
import AppIntents
|
||||
|
||||
struct FactoryResetNodeIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Factory Reset"
|
||||
static var description: IntentDescription = "Perform a factory reset on the node you are connected to"
|
||||
static let title: LocalizedStringResource = "Factory Reset"
|
||||
static let description: IntentDescription = "Perform a factory reset on the node you are connected to"
|
||||
@Parameter(title: "Hard Reset", description: "In addition to Config, Keys and BLE bonds will be wiped", default: false)
|
||||
var hardReset: Bool
|
||||
@Parameter(title: "Provide Confirmation", description: "Show a confirmation dialog before performing the factory reset", default: true)
|
||||
|
|
@ -24,18 +24,20 @@ struct FactoryResetNodeIntent: AppIntent {
|
|||
}
|
||||
|
||||
// Ensure the node is connected
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
// Safely unwrap the connected node information
|
||||
if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num,
|
||||
if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
|
||||
let fromUser = connectedNode.user,
|
||||
let toUser = connectedNode.user {
|
||||
|
||||
// Attempt to send a factory reset command, throw an error if it fails
|
||||
if !BLEManager.shared.sendFactoryReset(fromUser: fromUser, toUser: toUser, resetDevice: hardReset) {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendFactoryReset(fromUser: fromUser, toUser: toUser, resetDevice: hardReset)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to perform factory reset")
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -9,9 +9,9 @@ import Foundation
|
|||
import AppIntents
|
||||
|
||||
struct MessageChannelIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Send a Group Message"
|
||||
static let title: LocalizedStringResource = "Send a Group Message"
|
||||
|
||||
static var description: IntentDescription = "Send a message to a certain meshtastic channel"
|
||||
static let description: IntentDescription = "Send a message to a certain meshtastic channel"
|
||||
|
||||
@Parameter(title: "Message")
|
||||
var messageContent: String
|
||||
|
|
@ -23,7 +23,7 @@ struct MessageChannelIntent: AppIntent {
|
|||
Summary("Send \(\.$messageContent) to \(\.$channelNumber)")
|
||||
}
|
||||
func perform() async throws -> some IntentResult {
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
|
|
@ -41,7 +41,9 @@ struct MessageChannelIntent: AppIntent {
|
|||
throw $messageContent.needsValueError("Message content exceeds 200 bytes.")
|
||||
}
|
||||
|
||||
if !BLEManager.shared.sendMessage(message: messageContent, toUserNum: 0, channel: Int32(channelNumber), isEmoji: false, replyID: 0) {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendMessage(message: messageContent, toUserNum: 0, channel: Int32(channelNumber), isEmoji: false, replyID: 0)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to send message")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ struct MessageNodeIntent: AppIntent {
|
|||
Summary("Send \(\.$messageContent) to \(\.$nodeNumber)")
|
||||
}
|
||||
func perform() async throws -> some IntentResult {
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !AccessoryManager.shared.isConnected {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
|
|
@ -36,7 +36,9 @@ struct MessageNodeIntent: AppIntent {
|
|||
throw $messageContent.needsValueError("Message content exceeds 200 bytes.")
|
||||
}
|
||||
|
||||
if !BLEManager.shared.sendMessage(message: messageContent, toUserNum: Int64(nodeNumber), channel: 0, isEmoji: false, replyID: 0) {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendMessage(message: messageContent, toUserNum: Int64(nodeNumber), channel: 0, isEmoji: false, replyID: 0)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to send message")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,11 +15,11 @@ struct NodePositionIntent: AppIntent {
|
|||
@Parameter(title: "Node Number")
|
||||
var nodeNum: Int
|
||||
|
||||
static var title: LocalizedStringResource = "Get Node Position"
|
||||
static var description: IntentDescription = "Fetch the latest position of a cetain node"
|
||||
static let title: LocalizedStringResource = "Get Node Position"
|
||||
static let description: IntentDescription = "Fetch the latest position of a cetain node"
|
||||
|
||||
func perform() async throws -> some IntentResult & ReturnsValue<CLPlacemark> {
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
let fetchNodeInfoRequest: NSFetchRequest<NSFetchRequestResult> = NSFetchRequest(entityName: "NodeInfoEntity")
|
||||
|
|
|
|||
|
|
@ -9,23 +9,25 @@ import Foundation
|
|||
import AppIntents
|
||||
|
||||
struct RestartNodeIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Restart"
|
||||
static let title: LocalizedStringResource = "Restart"
|
||||
|
||||
static var description: IntentDescription = "Restart to the node you are connected to"
|
||||
static let description: IntentDescription = "Restart to the node you are connected to"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
// Safely unwrap the connectedNode using if let
|
||||
if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num,
|
||||
if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
|
||||
let fromUser = connectedNode.user,
|
||||
let toUser = connectedNode.user {
|
||||
|
||||
// Attempt to send shutdown, throw an error if it fails
|
||||
if !BLEManager.shared.sendReboot(fromUser: fromUser, toUser: toUser) {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendReboot(fromUser: fromUser, toUser: toUser)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to restart")
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import AppIntents
|
|||
// Define the AppIntent for saving channel settings from a URL
|
||||
struct SaveChannelSettingsIntent: AppIntent {
|
||||
// Define a title and description for the intent
|
||||
static var title: LocalizedStringResource = "Save Channel Settings"
|
||||
static var description: IntentDescription = "Takes a Meshtastic channel URL and saves the channel settings."
|
||||
static let title: LocalizedStringResource = "Save Channel Settings"
|
||||
static let description: IntentDescription = "Takes a Meshtastic channel URL and saves the channel settings."
|
||||
|
||||
// Define the input for the intent (the channel URL)
|
||||
@Parameter(title: "Channel URL", description: "The URL for the channel settings")
|
||||
|
|
@ -21,7 +21,7 @@ struct SaveChannelSettingsIntent: AppIntent {
|
|||
// Define the function that performs the main logic
|
||||
func perform() async throws -> some IntentResult {
|
||||
// Ensure the BLE Manager is connected
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
|
|
@ -39,10 +39,13 @@ struct SaveChannelSettingsIntent: AppIntent {
|
|||
|
||||
// If valid channel settings are extracted, attempt to save them
|
||||
if let channelSettings = channelSettings {
|
||||
// Call the BLEManager to save the channel settings
|
||||
let saveResult = BLEManager.shared.saveChannelSet(base64UrlString: channelSettings, addChannels: addChannels)
|
||||
if !saveResult {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to save the channel settings.")
|
||||
Task {
|
||||
do {
|
||||
// Call the AcessoryManager to save the channel settings
|
||||
try await AccessoryManager.shared.saveChannelSet(base64UrlString: channelSettings, addChannels: addChannels)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to save the channel settings.")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
throw AppIntentErrors.AppIntentError.message("Invalid Channel URL: Unable to extract settings.")
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ struct SendWaypointIntent: AppIntent {
|
|||
|
||||
var defaultDate = Date.now.addingTimeInterval(60 * 480)
|
||||
|
||||
static var title = LocalizedStringResource("Send a Waypoint")
|
||||
static let title = LocalizedStringResource("Send a Waypoint")
|
||||
|
||||
@Parameter(title: "Name", default: "Dropped Pin")
|
||||
var nameParameter: String?
|
||||
|
|
@ -39,7 +39,7 @@ struct SendWaypointIntent: AppIntent {
|
|||
var expiration: Date?
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
|
|
@ -87,14 +87,16 @@ struct SendWaypointIntent: AppIntent {
|
|||
newWaypoint.expire = UInt32(expirationDate.timeIntervalSince1970)
|
||||
}
|
||||
if isLocked {
|
||||
if let connectedPeripheral = BLEManager.shared.connectedPeripheral {
|
||||
newWaypoint.lockedTo = UInt32(connectedPeripheral.num)
|
||||
if let deviceNum = await AccessoryManager.shared.activeDeviceNum {
|
||||
newWaypoint.lockedTo = UInt32(deviceNum)
|
||||
} else {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
}
|
||||
|
||||
if !BLEManager.shared.sendWaypoint(waypoint: newWaypoint) {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendWaypoint(waypoint: newWaypoint)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to Send Waypoint")
|
||||
}
|
||||
return .result()
|
||||
|
|
|
|||
|
|
@ -9,25 +9,27 @@ import Foundation
|
|||
import AppIntents
|
||||
|
||||
struct ShutDownNodeIntent: AppIntent {
|
||||
static var title: LocalizedStringResource = "Shut Down"
|
||||
static let title: LocalizedStringResource = "Shut Down"
|
||||
|
||||
static var description: IntentDescription = "Send a shutdown to the node you are connected to"
|
||||
static let description: IntentDescription = "Send a shutdown to the node you are connected to"
|
||||
|
||||
func perform() async throws -> some IntentResult {
|
||||
try await requestConfirmation(result: .result(dialog: "Shut Down Node?"))
|
||||
|
||||
if !BLEManager.shared.isConnected {
|
||||
if !(await AccessoryManager.shared.isConnected) {
|
||||
throw AppIntentErrors.AppIntentError.notConnected
|
||||
}
|
||||
|
||||
// Safely unwrap the connectedNode using if let
|
||||
if let connectedPeripheralNum = BLEManager.shared.connectedPeripheral?.num,
|
||||
if let connectedPeripheralNum = await AccessoryManager.shared.activeDeviceNum,
|
||||
let connectedNode = getNodeInfo(id: connectedPeripheralNum, context: PersistenceController.shared.container.viewContext),
|
||||
let fromUser = connectedNode.user,
|
||||
let toUser = connectedNode.user {
|
||||
|
||||
// Attempt to send shutdown, throw an error if it fails
|
||||
if !BLEManager.shared.sendShutdown(fromUser: fromUser, toUser: toUser) {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendShutdown(fromUser: fromUser, toUser: toUser)
|
||||
} catch {
|
||||
throw AppIntentErrors.AppIntentError.message("Failed to shut down")
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -2,19 +2,14 @@ import Combine
|
|||
import SwiftUI
|
||||
|
||||
class AppState: ObservableObject {
|
||||
@Published
|
||||
var router: Router
|
||||
|
||||
@Published
|
||||
var unreadChannelMessages: Int
|
||||
|
||||
@Published
|
||||
var unreadDirectMessages: Int
|
||||
@Published var router: Router
|
||||
@Published var unreadChannelMessages: Int
|
||||
@Published var unreadDirectMessages: Int
|
||||
|
||||
var totalUnreadMessages: Int {
|
||||
unreadChannelMessages + unreadDirectMessages
|
||||
}
|
||||
|
||||
private var cancellables: Set<AnyCancellable> = []
|
||||
|
||||
init(router: Router) {
|
||||
|
|
|
|||
11
Meshtastic/Assets.xcassets/Symbol.symbolset/Contents.json
Normal file
11
Meshtastic/Assets.xcassets/Symbol.symbolset/Contents.json
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"filename" : "custom.bluetooth.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,101 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 341-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 3300 2200">
|
||||
<!--glyph: "", point size: 100.0, font version: "20.0d8e1", template writer version: "138.0.0"-->
|
||||
<style>.monochrome-0 {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-7c853a049dc13cdf}
|
||||
|
||||
.multicolor-0:tintColor {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-7c853a049dc13cdf}
|
||||
|
||||
.hierarchical-0:primary {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-7c853a049dc13cdf}
|
||||
|
||||
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
|
||||
</style>
|
||||
<g id="Notes">
|
||||
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 856.422 322)">Thin</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1153.13 322)">Light</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 263 1933)">
|
||||
<path d="m46.2402 4.15039c21.7773 0 39.4531-17.627 39.4531-39.4043s-17.6758-39.4043-39.4531-39.4043c-21.7285 0-39.4043 17.627-39.4043 39.4043s17.6758 39.4043 39.4043 39.4043Zm0-7.42188c-17.6758 0-31.9336-14.3066-31.9336-31.9824s14.2578-31.9824 31.9336-31.9824 31.9824 14.3066 31.9824 31.9824-14.3066 31.9824-31.9824 31.9824Zm-17.9688-31.9824c0 2.14844 1.51367 3.61328 3.75977 3.61328h10.498v10.5957c0 2.19727 1.46484 3.71094 3.61328 3.71094 2.24609 0 3.71094-1.51367 3.71094-3.71094v-10.5957h10.5957c2.19727 0 3.71094-1.46484 3.71094-3.61328 0-2.19727-1.51367-3.71094-3.71094-3.71094h-10.5957v-10.5469c0-2.24609-1.46484-3.75977-3.71094-3.75977-2.14844 0-3.61328 1.51367-3.61328 3.75977v10.5469h-10.498c-2.24609 0-3.75977 1.51367-3.75977 3.71094Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 281.506 1933)">
|
||||
<path d="m58.5449 14.5508c27.4902 0 49.8047-22.3145 49.8047-49.8047s-22.3145-49.8047-49.8047-49.8047-49.8047 22.3145-49.8047 49.8047 22.3145 49.8047 49.8047 49.8047Zm0-8.30078c-22.9492 0-41.5039-18.5547-41.5039-41.5039s18.5547-41.5039 41.5039-41.5039 41.5039 18.5547 41.5039 41.5039-18.5547 41.5039-41.5039 41.5039Zm-22.6562-41.5039c0 2.39258 1.66016 4.00391 4.15039 4.00391h14.3555v14.4043c0 2.44141 1.66016 4.15039 4.05273 4.15039 2.44141 0 4.15039-1.66016 4.15039-4.15039v-14.4043h14.4043c2.44141 0 4.15039-1.61133 4.15039-4.00391 0-2.44141-1.70898-4.15039-4.15039-4.15039h-14.4043v-14.3555c0-2.49023-1.70898-4.19922-4.15039-4.19922-2.39258 0-4.05273 1.70898-4.05273 4.19922v14.3555h-14.3555c-2.49023 0-4.15039 1.70898-4.15039 4.15039Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 304.924 1933)">
|
||||
<path d="m74.8535 28.3203c35.1074 0 63.623-28.4668 63.623-63.5742s-28.5156-63.623-63.623-63.623-63.5742 28.5156-63.5742 63.623 28.4668 63.5742 63.5742 63.5742Zm0-9.08203c-30.127 0-54.4922-24.3652-54.4922-54.4922s24.3652-54.4922 54.4922-54.4922 54.4922 24.3652 54.4922 54.4922-24.3652 54.4922-54.4922 54.4922Zm-28.8574-54.4922c0 2.58789 1.85547 4.39453 4.58984 4.39453h19.7266v19.7754c0 2.68555 1.85547 4.58984 4.44336 4.58984 2.68555 0 4.54102-1.85547 4.54102-4.58984v-19.7754h19.7754c2.68555 0 4.58984-1.80664 4.58984-4.39453 0-2.73438-1.85547-4.58984-4.58984-4.58984h-19.7754v-19.7266c0-2.73438-1.85547-4.63867-4.54102-4.63867-2.58789 0-4.44336 1.9043-4.44336 4.63867v19.7266h-19.7266c-2.73438 0-4.58984 1.85547-4.58984 4.58984Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 1953)">Design Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1971)">Symbols are supported in up to nine weights and three scales.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1989)">For optimal layout with text and other symbols, vertically align</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 2007)">symbols with the adjacent text.</text>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="776" x2="776" y1="1919" y2="1933"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 776 1933)">
|
||||
<path d="m16.5527 0.78125c2.58789 0 3.85742-0.976562 4.78516-3.71094l6.29883-17.2363h28.8086l6.29883 17.2363c0.927734 2.73438 2.19727 3.71094 4.73633 3.71094 2.58789 0 4.24805-1.5625 4.24805-4.00391 0-0.830078-0.146484-1.61133-0.537109-2.63672l-22.9004-60.9863c-1.12305-2.97852-3.125-4.49219-6.25-4.49219-3.02734 0-5.07812 1.46484-6.15234 4.44336l-22.9004 61.084c-0.390625 1.02539-0.537109 1.80664-0.537109 2.63672 0 2.44141 1.5625 3.95508 4.10156 3.95508Zm13.4766-28.3691 11.8652-32.8613h0.244141l11.8652 32.8613Z"/>
|
||||
</g>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="792.836" x2="792.836" y1="1919" y2="1933"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 776 1953)">Margins</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1971)">Leading and trailing margins on the left and right side of each symbol</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1989)">can be adjusted by modifying the x-location of the margin guidelines.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2007)">Modifications are automatically applied proportionally to all</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2025)">scales and weights.</text>
|
||||
<g transform="matrix(0.2 0 0 0.2 1289 1933)">
|
||||
<path d="m14.209 9.32617 8.49609 8.54492c4.29688 4.3457 9.22852 4.05273 13.8672-1.07422l53.4668-58.9355-4.83398-4.88281-53.0762 58.3984c-1.75781 2.00195-3.41797 2.49023-5.76172 0.146484l-5.85938-5.81055c-2.34375-2.29492-1.80664-4.00391 0.195312-5.81055l57.373-54.0039-4.88281-4.83398-57.959 54.4434c-4.93164 4.58984-5.32227 9.47266-1.02539 13.8184Zm32.0801-90.9668c-2.09961 2.05078-2.24609 4.93164-1.07422 6.88477 1.17188 1.80664 3.4668 2.97852 6.68945 2.14844 7.32422-1.70898 14.9414-2.00195 22.0703 2.68555l-2.92969 7.27539c-1.70898 4.15039-0.830078 7.08008 1.85547 9.81445l11.4746 11.5723c2.44141 2.44141 4.49219 2.53906 7.32422 2.05078l5.32227-0.976562 3.32031 3.36914-0.195312 2.7832c-0.195312 2.49023 0.439453 4.39453 2.88086 6.78711l3.80859 3.71094c2.39258 2.39258 5.46875 2.53906 7.8125 0.195312l14.5508-14.5996c2.34375-2.34375 2.24609-5.32227-0.146484-7.71484l-3.85742-3.80859c-2.39258-2.39258-4.24805-3.17383-6.64062-2.97852l-2.88086 0.244141-3.22266-3.17383 1.2207-5.61523c0.634766-2.83203-0.146484-5.0293-3.07617-7.95898l-10.9863-10.9375c-16.6992-16.6016-38.8672-16.2109-53.3203-1.75781Zm7.4707 1.85547c12.1582-8.88672 28.6133-7.37305 39.7461 3.75977l12.1582 12.0605c1.17188 1.17188 1.36719 2.09961 1.02539 3.80859l-1.61133 7.42188 7.51953 7.42188 4.93164-0.292969c1.26953-0.0488281 1.66016 0.0488281 2.63672 1.02539l2.88086 2.88086-12.207 12.207-2.88086-2.88086c-0.976562-0.976562-1.12305-1.36719-1.07422-2.68555l0.341797-4.88281-7.4707-7.42188-7.61719 1.26953c-1.61133 0.341797-2.34375 0.195312-3.56445-0.976562l-10.0098-10.0098c-1.26953-1.17188-1.41602-2.00195-0.634766-3.85742l4.39453-10.4492c-7.8125-7.27539-17.9688-10.4004-28.125-7.42188-0.78125 0.195312-1.07422-0.439453-0.439453-0.976562Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 1289 1953)">Exporting</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1971)">Symbols should be outlined when exporting to ensure the</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1989)">design is preserved when submitting to Xcode.</text>
|
||||
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.6.0</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 16 or greater</text>
|
||||
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from custom.bluetooth</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
|
||||
</g>
|
||||
<g id="Guides">
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
|
||||
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
|
||||
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
|
||||
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
|
||||
<line id="right-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2977.69" x2="2977.69" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2889.11" x2="2889.11" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1494.13" x2="1494.13" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1405.56" x2="1405.56" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="604" x2="604" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="515.423" x2="515.423" y1="600.785" y2="720.121"/>
|
||||
</g>
|
||||
<g id="Symbols">
|
||||
<g id="Black-S" transform="matrix(1.00656 0 0 1.00656 2889.11 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" d="M44-70C28.717-70 18.183-62.739 18.183-35C18.183-7.261 28.717 0 44 0C59.283 0 69.817-7.261 69.817-35C69.817-62.739 59.283-70 44-70ZM41.733-65.095L59.859-46.969L47.891-35L59.859-23.031L41.733-4.905L41.733-28.802L31.764-18.827L28.141-22.456L40.65-35L28.141-47.544L31.764-51.173L41.733-41.198L41.733-65.095ZM46.831-52.716L46.831-41.141L52.619-46.929L46.831-52.716ZM46.831-28.853L46.831-17.284L52.619-23.071L46.831-28.853Z"/>
|
||||
</g>
|
||||
<g id="Regular-S" transform="matrix(1.00656 0 0 1.00656 1405.56 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" d="M44-70C28.717-70 18.183-62.739 18.183-35C18.183-7.261 28.717 0 44 0C59.283 0 69.817-7.261 69.817-35C69.817-62.739 59.283-70 44-70ZM41.733-65.095L59.859-46.969L47.891-35L59.859-23.031L41.733-4.905L41.733-28.802L31.764-18.827L28.141-22.456L40.65-35L28.141-47.544L31.764-51.173L41.733-41.198L41.733-65.095ZM46.831-52.716L46.831-41.141L52.619-46.929L46.831-52.716ZM46.831-28.853L46.831-17.284L52.619-23.071L46.831-28.853Z"/>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="matrix(1.00656 0 0 1.00656 515.423 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:primary SFSymbolsPreviewWireframe" d="M44-70C28.717-70 18.183-62.739 18.183-35C18.183-7.261 28.717 0 44 0C59.283 0 69.817-7.261 69.817-35C69.817-62.739 59.283-70 44-70ZM41.733-65.095L59.859-46.969L47.891-35L59.859-23.031L41.733-4.905L41.733-28.802L31.764-18.827L28.141-22.456L40.65-35L28.141-47.544L31.764-51.173L41.733-41.198L41.733-65.095ZM46.831-52.716L46.831-41.141L52.619-46.929L46.831-52.716ZM46.831-28.853L46.831-17.284L52.619-23.071L46.831-28.853Z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
},
|
||||
"symbols" : [
|
||||
{
|
||||
"filename" : "custom.link.slash.svg",
|
||||
"idiom" : "universal"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,115 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!--Generator: Apple Native CoreSVG 341-->
|
||||
<!DOCTYPE svg
|
||||
PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 3300 2200">
|
||||
<!--glyph: "", point size: 100.0, font version: "21.0d5e1", template writer version: "138.0.0"-->
|
||||
<style>.defaults {-sfsymbols-variable-value-mode:color;-sfsymbols-draw-reverses-motion-groups:true}
|
||||
|
||||
.monochrome-0 {-sfsymbols-motion-group:1;-sfsymbols-layer-tags:-5903b80b93a5bfa9 7f37d10e7e35507 link}
|
||||
.monochrome-1 {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-5903b80b93a5bfa9 _slash}
|
||||
.monochrome-2 {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-5903b80b93a5bfa9 _slash}
|
||||
|
||||
.multicolor-0:tintColor {-sfsymbols-motion-group:1;-sfsymbols-layer-tags:-5903b80b93a5bfa9 7f37d10e7e35507 link}
|
||||
.multicolor-1:tintColor {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-5903b80b93a5bfa9 _slash}
|
||||
.multicolor-2:tintColor {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-5903b80b93a5bfa9 _slash}
|
||||
|
||||
.hierarchical-0:secondary {-sfsymbols-motion-group:1;-sfsymbols-layer-tags:-5903b80b93a5bfa9 7f37d10e7e35507 link}
|
||||
.hierarchical-1:primary {opacity:0.0;-sfsymbols-clear-behind:true;-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-5903b80b93a5bfa9 _slash}
|
||||
.hierarchical-2:primary {-sfsymbols-motion-group:0;-sfsymbols-layer-tags:-5903b80b93a5bfa9 _slash}
|
||||
|
||||
.SFSymbolsPreviewWireframe {fill:none;opacity:1.0;stroke:black;stroke-width:0.5}
|
||||
</style>
|
||||
<g id="Notes">
|
||||
<rect height="2200" id="artboard" style="fill:white;opacity:1" width="3300" x="0" y="0"/>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="292" y2="292"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 322)">Weight/Scale Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 559.711 322)">Ultralight</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 856.422 322)">Thin</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1153.13 322)">Light</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1449.84 322)">Regular</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 1746.56 322)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2043.27 322)">Semibold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2339.98 322)">Bold</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2636.69 322)">Heavy</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:middle;" transform="matrix(1 0 0 1 2933.4 322)">Black</text>
|
||||
<line style="fill:none;stroke:black;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1903" y2="1903"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 263 1933)">
|
||||
<path d="m46.2402 4.15039c21.7773 0 39.4531-17.627 39.4531-39.4043s-17.6758-39.4043-39.4531-39.4043c-21.7285 0-39.4043 17.627-39.4043 39.4043s17.6758 39.4043 39.4043 39.4043Zm0-7.42188c-17.6758 0-31.9336-14.3066-31.9336-31.9824s14.2578-31.9824 31.9336-31.9824 31.9824 14.3066 31.9824 31.9824-14.3066 31.9824-31.9824 31.9824Zm3.61328-17.7734v-28.4668c0-2.24609-1.46484-3.75977-3.71094-3.75977-2.14844 0-3.61328 1.51367-3.61328 3.75977v28.4668c0 2.19727 1.46484 3.71094 3.61328 3.71094 2.24609 0 3.71094-1.51367 3.71094-3.71094Zm-17.8223-10.5957h28.418c2.19727 0 3.71094-1.46484 3.71094-3.61328 0-2.19727-1.51367-3.71094-3.71094-3.71094h-28.418c-2.24609 0-3.75977 1.51367-3.75977 3.71094 0 2.14844 1.51367 3.61328 3.75977 3.61328Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 281.506 1933)">
|
||||
<path d="m58.5449 14.5508c27.4902 0 49.8047-22.3145 49.8047-49.8047s-22.3145-49.8047-49.8047-49.8047-49.8047 22.3145-49.8047 49.8047 22.3145 49.8047 49.8047 49.8047Zm0-8.30078c-22.9492 0-41.5039-18.5547-41.5039-41.5039s18.5547-41.5039 41.5039-41.5039 41.5039 18.5547 41.5039 41.5039-18.5547 41.5039-41.5039 41.5039Zm4.05273-23.0957v-36.9141c0-2.49023-1.70898-4.19922-4.15039-4.19922-2.39258 0-4.05273 1.70898-4.05273 4.19922v36.9141c0 2.44141 1.66016 4.15039 4.05273 4.15039 2.44141 0 4.15039-1.66016 4.15039-4.15039Zm-22.5586-14.4043h36.9629c2.44141 0 4.15039-1.61133 4.15039-4.00391 0-2.44141-1.70898-4.15039-4.15039-4.15039h-36.9629c-2.49023 0-4.15039 1.70898-4.15039 4.15039 0 2.39258 1.66016 4.00391 4.15039 4.00391Z"/>
|
||||
</g>
|
||||
<g transform="matrix(0.2 0 0 0.2 304.924 1933)">
|
||||
<path d="m74.8535 28.3203c35.1074 0 63.623-28.4668 63.623-63.5742s-28.5156-63.623-63.623-63.623-63.5742 28.5156-63.5742 63.623 28.4668 63.5742 63.5742 63.5742Zm0-9.08203c-30.127 0-54.4922-24.3652-54.4922-54.4922s24.3652-54.4922 54.4922-54.4922 54.4922 24.3652 54.4922 54.4922-24.3652 54.4922-54.4922 54.4922Zm4.44336-30.3223v-48.4863c0-2.73438-1.85547-4.63867-4.54102-4.63867-2.58789 0-4.44336 1.9043-4.44336 4.63867v48.4863c0 2.68555 1.85547 4.58984 4.44336 4.58984 2.68555 0 4.54102-1.85547 4.54102-4.58984Zm-28.7109-19.7754h48.4863c2.68555 0 4.58984-1.80664 4.58984-4.39453 0-2.73438-1.85547-4.58984-4.58984-4.58984h-48.4863c-2.73438 0-4.58984 1.85547-4.58984 4.58984 0 2.58789 1.85547 4.39453 4.58984 4.39453Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 263 1953)">Design Variations</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1971)">Symbols are supported in up to nine weights and three scales.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1989)">For optimal layout with text and other symbols, vertically align</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 2007)">symbols with the adjacent text.</text>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="776" x2="776" y1="1919" y2="1933"/>
|
||||
<g transform="matrix(0.2 0 0 0.2 776 1933)">
|
||||
<path d="m16.5527 0.78125c2.58789 0 3.85742-0.976562 4.78516-3.71094l20.5566-57.5195h0.244141l20.6055 57.5195c0.927734 2.73438 2.19727 3.71094 4.73633 3.71094 2.58789 0 4.24805-1.5625 4.24805-4.00391 0-0.830078-0.146484-1.61133-0.537109-2.63672l-22.9004-60.9863c-1.12305-2.97852-3.125-4.49219-6.25-4.49219-3.02734 0-5.07812 1.46484-6.15234 4.44336l-22.9004 61.084c-0.390625 1.02539-0.537109 1.80664-0.537109 2.63672 0 2.44141 1.5625 3.95508 4.10156 3.95508Zm10.2051-20.9473h30.6641c2.00195 0 3.66211-1.66016 3.66211-3.66211 0-2.05078-1.66016-3.66211-3.66211-3.66211h-30.6641c-2.00195 0-3.66211 1.61133-3.66211 3.66211 0 2.00195 1.66016 3.66211 3.66211 3.66211Z"/>
|
||||
</g>
|
||||
<line style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="792.836" x2="792.836" y1="1919" y2="1933"/>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 776 1953)">Margins</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1971)">Leading and trailing margins on the left and right side of each symbol</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 1989)">can be adjusted by modifying the x-location of the margin guidelines.</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2007)">Modifications are automatically applied proportionally to all</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 776 2025)">scales and weights.</text>
|
||||
<g transform="matrix(0.2 0 0 0.2 1289 1933)">
|
||||
<path d="m14.209 13.1348 7.86133 7.86133c4.29688 4.39453 9.32617 4.10156 13.8672-1.02539l60.6934-68.2129-4.88281-4.88281-60.2539 67.6758c-1.80664 1.95312-3.4668 2.44141-5.81055 0.0976562l-5.17578-5.12695c-2.29492-2.29492-1.80664-3.95508 0.195312-5.81055l67.4805-62.1582-4.88281-4.83398-68.0664 62.5977c-4.98047 4.58984-5.32227 9.47266-1.02539 13.8184Zm44.873-97.4609c-2.05078 2.00195-2.24609 4.88281-1.07422 6.78711 1.12305 1.80664 3.4668 3.02734 6.5918 2.24609 5.85938-1.66016 12.5977-2.39258 18.8965 0.927734l-2.68555 7.12891c-1.61133 4.00391-0.732422 6.88477 1.70898 9.42383l10.2539 10.3027c2.34375 2.39258 4.54102 2.44141 7.08008 1.95312l4.44336-0.732422 2.58789 2.53906-0.195312 2.24609c-0.0976562 2.29492 0.537109 4.29688 2.7832 6.49414l3.36914 3.32031c2.29492 2.29492 5.51758 2.49023 7.8125 0.195312l12.9883-13.0371c2.29492-2.34375 2.14844-5.37109-0.195312-7.66602l-3.41797-3.41797c-2.19727-2.19727-4.05273-3.02734-6.34766-2.88086l-2.34375 0.244141-2.44141-2.44141 1.02539-4.6875c0.634766-2.73438-0.244141-4.98047-2.88086-7.61719l-11.2793-11.1816c-12.9395-12.8418-35.5957-11.0352-46.6797-0.146484Zm7.08008 2.05078c8.78906-6.39648 25.9766-5.66406 33.6914 1.95312l12.3047 12.207c1.02539 1.02539 1.2207 1.80664 0.927734 3.32031l-1.46484 6.64062 6.73828 6.68945 4.39453-0.244141c1.12305-0.0488281 1.51367 0.0488281 2.34375 0.878906l2.53906 2.49023-10.8398 10.8398-2.49023-2.49023c-0.830078-0.878906-0.976562-1.2207-0.927734-2.39258l0.292969-4.3457-6.68945-6.73828-6.83594 1.17188c-1.41602 0.292969-2.05078 0.195312-3.17383-0.878906l-8.93555-8.88672c-1.07422-1.02539-1.17188-1.70898-0.488281-3.36914l4.58984-11.4746c-6.10352-6.34766-17.041-7.51953-25.5859-4.58984-0.683594 0.244141-0.927734-0.390625-0.390625-0.78125Z"/>
|
||||
</g>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;font-weight:bold;" transform="matrix(1 0 0 1 1289 1953)">Exporting</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1971)">Symbols should be outlined when exporting to ensure the</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 1289 1989)">design is preserved when submitting to Xcode.</text>
|
||||
<text id="template-version" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1933)">Template v.6.0</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1951)">Requires Xcode 16 or greater</text>
|
||||
<text id="descriptive-name" style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1969)">Generated from </text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;text-anchor:end;" transform="matrix(1 0 0 1 3036 1987)">Typeset at 100.0 points</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 726)">Small</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1156)">Medium</text>
|
||||
<text style="stroke:none;fill:black;font-family:sans-serif;font-size:13;" transform="matrix(1 0 0 1 263 1586)">Large</text>
|
||||
</g>
|
||||
<g id="Guides">
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 696)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="696" y2="696"/>
|
||||
<line id="Capline-S" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="625.541" y2="625.541"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1126)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1126" y2="1126"/>
|
||||
<line id="Capline-M" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1055.54" y2="1055.54"/>
|
||||
<g id="H-reference" style="fill:#27AAE1;stroke:none;" transform="matrix(1 0 0 1 339 1556)">
|
||||
<path d="M0.993654 0L3.63775 0L29.3281-67.1323L30.0303-67.1323L30.0303-70.459L28.1226-70.459ZM11.6885-24.4799L46.9815-24.4799L46.2315-26.7285L12.4385-26.7285ZM55.1196 0L57.7637 0L30.6382-70.459L29.4326-70.459L29.4326-67.1323Z"/>
|
||||
</g>
|
||||
<line id="Baseline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1556" y2="1556"/>
|
||||
<line id="Capline-L" style="fill:none;stroke:#27AAE1;opacity:1;stroke-width:0.5;" x1="263" x2="3036" y1="1485.54" y2="1485.54"/>
|
||||
<line id="right-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2986.13" x2="2986.13" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Black-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="2880.67" x2="2880.67" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1499.75" x2="1499.75" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Regular-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="1399.94" x2="1399.94" y1="600.785" y2="720.121"/>
|
||||
<line id="right-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="606.412" x2="606.412" y1="600.785" y2="720.121"/>
|
||||
<line id="left-margin-Ultralight-S" style="fill:none;stroke:#00AEEF;stroke-width:0.5;opacity:1.0;" x1="513.01" x2="513.01" y1="600.785" y2="720.121"/>
|
||||
</g>
|
||||
<g id="Symbols">
|
||||
<g id="Black-S" transform="matrix(1 0 0 1 2880.67 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M60.3516-53.3203L50.9766-44.043C53.2715-43.6035 56.3477-42.2852 57.9102-40.7227C63.4766-35.1562 63.5254-27.5879 58.0078-22.0703L44.2383-8.34961C38.7207-2.83203 31.1523-2.88086 25.6348-8.39844C20.0684-13.9648 20.0195-21.5332 25.5371-27.0508L27.4902-29.0039C25.5371-33.4961 24.4629-39.6484 26.416-44.9219L16.6504-35.3027C6.10352-24.9512 6.25-9.96094 16.6992 0.488281C27.1973 10.9863 42.041 10.9863 52.4902 0.537109L66.8945-13.8184C77.3438-24.2676 77.2949-39.1602 66.8457-49.6094C65.4297-51.0254 62.5488-52.6855 60.3516-53.3203ZM45.1172-17.5293L54.4922-26.8066C52.1973-27.2461 49.1211-28.5645 47.5586-30.127C41.9922-35.6934 41.9434-43.2617 47.4609-48.7793L61.2305-62.5C66.748-68.0176 74.3164-67.9688 79.834-62.4512C85.4004-56.8848 85.4492-49.3164 79.9316-43.7988L77.9785-41.8457C79.9316-37.3535 81.0059-31.2012 79.0527-25.9277L88.8184-35.5469C99.3652-45.8984 99.2188-60.8887 88.7695-71.3379C78.2715-81.8359 63.4277-81.8359 52.9785-71.3867L38.5742-57.0312C28.125-46.582 28.1738-31.6895 38.623-21.2402C40.0391-19.8242 42.9199-18.1641 45.1172-17.5293Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:primary SFSymbolsPreviewWireframe" d="M96.7018-4.48836C100.353-0.837264 100.353 5.09118 96.7018 8.74227C93.0507 12.3934 87.1222 12.3934 83.4711 8.74227L8.75823-65.9706C5.10714-69.6217 5.10714-75.5502 8.75823-79.2013C12.4093-82.8524 18.3378-82.8524 21.9889-79.2013Z" data-clipstroke-keyframes="0 0 0 0.4999044 0.6089134 0 1 0 0.10891342"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M92.5517-0.338247C93.9122 1.02234 93.9122 3.23158 92.5517 4.59217C91.1911 5.95275 88.9818 5.95275 87.6212 4.59217L12.9083-70.1207C11.5478-71.4813 11.5478-73.6906 12.9083-75.0511C14.2689-76.4117 16.4782-76.4117 17.8388-75.0511Z" data-clipstroke-keyframes="0 0 0 0.49988937 0.54707384 0 1 0 0.04707384"/>
|
||||
</g>
|
||||
<g id="Regular-S" transform="matrix(1 0 0 1 1399.94 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M51.709-51.5625L45.9473-45.7031C50.5371-45.0684 53.5156-43.6035 55.7617-41.3574C62.3047-34.8145 62.3047-25.6348 55.8594-19.1895L43.3594-6.68945C36.8652-0.195312 27.6855-0.195312 21.1426-6.68945C14.5508-13.2812 14.5996-22.4609 21.0938-28.9062L27.6855-35.498C26.4648-38.3301 26.1719-41.5527 26.5137-44.2871L15.8203-33.5938C6.39648-24.2188 6.34766-10.8887 15.8691-1.41602C25.3418 8.10547 38.623 8.05664 48.0469-1.36719L61.1328-14.502C70.5078-23.877 70.5566-37.1582 61.084-46.6797C58.9355-48.8281 56.1523-50.4395 51.709-51.5625ZM48.0957-19.5801L53.8086-25.3906C49.2188-26.0254 46.2402-27.4902 44.043-29.7363C37.5-36.2793 37.5-45.459 43.9453-51.9043L56.4453-64.4043C62.9395-70.8984 72.1191-70.9473 78.6621-64.4043C85.2051-57.8125 85.1562-48.6328 78.7109-42.1875L72.1191-35.5957C73.3398-32.7637 73.6328-29.541 73.291-26.8066L83.9844-37.5C93.4082-46.9238 93.457-60.2051 83.9355-69.6777C74.4629-79.1992 61.1328-79.1504 51.7578-69.7266L38.6719-56.5918C29.2969-47.2168 29.1992-33.9355 38.7207-24.4141C40.8691-22.2656 43.6523-20.6543 48.0957-19.5801Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:primary SFSymbolsPreviewWireframe" d="M90.9583-6.06714C94.2398-2.78573 94.2398 2.54245 90.9583 5.82386C87.6769 9.10527 82.3488 9.10527 79.0674 5.82386L8.85165-64.3918C5.57024-67.6733 5.57024-73.0014 8.85165-76.2828C12.1331-79.5643 17.4612-79.5643 20.7426-76.2828Z" data-clipstroke-keyframes="0 0 0 0.50010824 0.6049547 0 1 0 0.10495448"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M87.4919-2.60066C88.8601-1.23245 88.8601 0.98917 87.4919 2.35738C86.1237 3.72558 83.902 3.72558 82.5338 2.35738L12.3181-67.8583C10.9499-69.2265 10.9499-71.4482 12.3181-72.8164C13.6863-74.1846 15.908-74.1846 17.2762-72.8164Z" data-clipstroke-keyframes="0 0 0 0.5002053 0.5497174 0 1 0 0.049717665"/>
|
||||
</g>
|
||||
<g id="Ultralight-S" transform="matrix(1 0 0 1 513.01 696)">
|
||||
<path class="monochrome-0 multicolor-0:tintColor hierarchical-0:secondary SFSymbolsPreviewWireframe" d="M41.2193-50.291L39.0904-48.2007C46.1324-48.8828 50.791-47.1001 54.5357-43.3555C61.8052-36.0859 61.7144-25.4077 54.27-18.0088L41.8609-5.59962C34.4131 1.75731 23.7803 1.89354 16.5562-5.32716C9.28324-12.6455 9.37748-23.2329 16.7798-30.6772L28.3667-42.2187C28.0542-42.8256 27.8974-44.0503 28.4209-45.4224L14.9575-31.9136C6.80517-23.8101 6.62012-12.1147 14.9609-3.77733C23.2984 4.5635 34.9903 4.33304 43.0972-3.7285L56.1377-16.7724C64.2413-24.876 64.4263-36.6133 56.1343-44.9087C52.4874-48.5557 48.1602-50.5303 41.2193-50.291ZM52.1826-20.8516L54.3081-22.9385C47.2661-22.2564 42.6075-24.0391 38.9117-27.7837C31.5967-35.0532 31.6875-45.7314 39.1319-53.1304L51.5865-65.5395C58.9888-72.8965 69.6216-73.0361 76.8457-65.812C84.1607-58.4936 84.0664-47.9063 76.6221-40.4619L65.0806-28.9205C65.3477-28.3135 65.5045-27.0889 64.981-25.7168L78.4898-39.2256C86.5967-47.3325 86.7818-59.0244 78.4864-67.3618C70.1035-75.7027 58.4082-75.4722 50.3501-67.4107L37.3096-54.3667C29.1606-46.2632 28.9722-34.5259 37.313-26.2305C40.9146-22.5835 45.2417-20.6089 52.1826-20.8516Z"/>
|
||||
<path class="monochrome-1 multicolor-1:tintColor hierarchical-1:primary SFSymbolsPreviewWireframe" d="M84.3872-3.06064C85.9098-1.53808 85.9098 0.934172 84.3872 2.45673C82.8647 3.97929 80.3924 3.97929 78.8699 2.45673L9.01478-67.3983C7.49222-68.9209 7.49222-71.3932 9.01478-72.9157C10.5373-74.4383 13.0096-74.4383 14.5321-72.9157Z" data-clipstroke-keyframes="0 0 0 0.5001135 0.5550747 0 1 0 0.05507469"/>
|
||||
<path class="monochrome-2 multicolor-2:tintColor hierarchical-2:primary SFSymbolsPreviewWireframe" d="M82.3363-1.00975C82.727-0.619106 82.727 0.0152006 82.3363 0.405845C81.9457 0.796489 81.3114 0.796489 80.9207 0.405845L11.0657-69.4492C10.675-69.8399 10.675-70.4742 11.0657-70.8648C11.4563-71.2555 12.0906-71.2555 12.4813-70.8648Z" data-clipstroke-keyframes="0 0 0 0.5001266 0.51529884 0 1 0 0.015299082"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -10,7 +10,7 @@ import UniformTypeIdentifiers
|
|||
|
||||
struct CsvDocument: FileDocument {
|
||||
|
||||
static var readableContentTypes = [UTType.commaSeparatedText]
|
||||
static let readableContentTypes = [UTType.commaSeparatedText]
|
||||
|
||||
@State var csvData: String
|
||||
|
||||
|
|
|
|||
|
|
@ -71,7 +71,7 @@ extension UserEntity {
|
|||
return "TLORAC6"
|
||||
case "TLORAT3S3EPAPER":
|
||||
return "TLORAT3S3EPAPER"
|
||||
case "TLORAT3S3V1", "TLORAT3S3" :
|
||||
case "TLORAT3S3V1", "TLORAT3S3":
|
||||
return "TLORAT3S3V1"
|
||||
case "TLORAV211P6":
|
||||
return "TLORAV211P6"
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import OSLog
|
|||
extension Logger {
|
||||
|
||||
/// The logger's subsystem.
|
||||
private static var subsystem = Bundle.main.bundleIdentifier!
|
||||
private static let subsystem = Bundle.main.bundleIdentifier!
|
||||
|
||||
/// All admin messages
|
||||
static let admin = Logger(subsystem: subsystem, category: "🏛 Admin")
|
||||
|
|
@ -33,6 +33,9 @@ extension Logger {
|
|||
/// All logs related to tracking and analytics.
|
||||
static let statistics = Logger(subsystem: subsystem, category: "📊 Stats")
|
||||
|
||||
/// All logs related to the transport layer
|
||||
static let transport = Logger(subsystem: subsystem, category: "🚚 Transport")
|
||||
|
||||
/// Fetch from the logstore
|
||||
static public func fetch(predicateFormat: String) async throws -> [OSLogEntryLog] {
|
||||
|
||||
|
|
|
|||
|
|
@ -78,6 +78,7 @@ extension UserDefaults {
|
|||
case firstLaunch
|
||||
case showDeviceOnboarding
|
||||
case usageDataAndCrashReporting
|
||||
case autoconnectOnDiscovery
|
||||
case testIntEnum
|
||||
}
|
||||
|
||||
|
|
@ -172,6 +173,9 @@ extension UserDefaults {
|
|||
@UserDefault(.showDeviceOnboarding, defaultValue: false)
|
||||
static var showDeviceOnboarding: Bool
|
||||
|
||||
@UserDefault(.autoconnectOnDiscovery, defaultValue: true)
|
||||
static var autoconnectOnDiscovery: Bool
|
||||
|
||||
@UserDefault(.testIntEnum, defaultValue: .one)
|
||||
static var testIntEnum: TestIntEnum
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,11 +11,13 @@ import TipKit
|
|||
import MeshtasticProtobufs
|
||||
|
||||
struct ContactURLHandler {
|
||||
|
||||
static var minimumContactVersion = "2.6.9"
|
||||
static func handleContactUrl(url: URL, bleManager: BLEManager) {
|
||||
let supportedVersion = UserDefaults.firmwareVersion == "0.0.0" ||
|
||||
minimumContactVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedAscending ||
|
||||
minimumContactVersion.compare(UserDefaults.firmwareVersion, options: .numeric) == .orderedSame
|
||||
|
||||
@MainActor
|
||||
static func handleContactUrl(url: URL, accessoryManager: AccessoryManager) {
|
||||
let supportedVersion = accessoryManager.checkIsVersionSupported(forVersion: minimumContactVersion)
|
||||
|
||||
if !supportedVersion {
|
||||
let alertController = UIAlertController(
|
||||
title: "Firmware Upgrade Required",
|
||||
|
|
@ -48,8 +50,14 @@ struct ContactURLHandler {
|
|||
title: "Yes",
|
||||
style: .default,
|
||||
handler: { _ in
|
||||
let success = bleManager.addContactFromURL(base64UrlString: contactData)
|
||||
Logger.services.debug("Contact added from URL: \(success ? "success" : "failed")")
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.addContactFromURL(base64UrlString: contactData)
|
||||
Logger.services.debug("Contact added from URL successfully")
|
||||
} catch {
|
||||
Logger.services.debug("Contact added from URL failed with error \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
))
|
||||
alertController.addAction(UIAlertAction(
|
||||
|
|
|
|||
|
|
@ -227,7 +227,8 @@ import OSLog
|
|||
return true
|
||||
}
|
||||
// Default location (Apple Park) used as a fallback.
|
||||
static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090)
|
||||
// nonisolated because it is never mutated
|
||||
nonisolated static let DefaultLocation = CLLocationCoordinate2D(latitude: 37.3346, longitude: -122.0090)
|
||||
/// Provides the current location, falling back to last known or a default if necessary.
|
||||
static var currentLocation: CLLocationCoordinate2D {
|
||||
// Attempt to get the most recent location from the manager.
|
||||
|
|
|
|||
|
|
@ -56,48 +56,56 @@ func generateMessageMarkdown (message: String) -> String {
|
|||
}
|
||||
|
||||
func localConfig (config: Config, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) {
|
||||
|
||||
if config.payloadVariant == Config.OneOf_PayloadVariant.bluetooth(config.bluetooth) {
|
||||
switch config.payloadVariant {
|
||||
case .bluetooth:
|
||||
upsertBluetoothConfigPacket(config: config.bluetooth, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == Config.OneOf_PayloadVariant.device(config.device) {
|
||||
case .device:
|
||||
upsertDeviceConfigPacket(config: config.device, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == Config.OneOf_PayloadVariant.display(config.display) {
|
||||
case .display:
|
||||
upsertDisplayConfigPacket(config: config.display, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == Config.OneOf_PayloadVariant.lora(config.lora) {
|
||||
case .lora:
|
||||
upsertLoRaConfigPacket(config: config.lora, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == Config.OneOf_PayloadVariant.network(config.network) {
|
||||
case .network:
|
||||
upsertNetworkConfigPacket(config: config.network, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == Config.OneOf_PayloadVariant.position(config.position) {
|
||||
case .position:
|
||||
upsertPositionConfigPacket(config: config.position, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == Config.OneOf_PayloadVariant.power(config.power) {
|
||||
case .power:
|
||||
upsertPowerConfigPacket(config: config.power, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == Config.OneOf_PayloadVariant.security(config.security) {
|
||||
case .security:
|
||||
upsertSecurityConfigPacket(config: config.security, nodeNum: nodeNum, context: context)
|
||||
default:
|
||||
#if DEBUG
|
||||
Logger.services.error("⁉️ Unknown Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
func moduleConfig (config: ModuleConfig, context: NSManagedObjectContext, nodeNum: Int64, nodeLongName: String) {
|
||||
|
||||
if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.ambientLighting(config.ambientLighting) {
|
||||
switch config.payloadVariant {
|
||||
case .ambientLighting:
|
||||
upsertAmbientLightingModuleConfigPacket(config: config.ambientLighting, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.cannedMessage(config.cannedMessage) {
|
||||
case .cannedMessage:
|
||||
upsertCannedMessagesModuleConfigPacket(config: config.cannedMessage, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.detectionSensor(config.detectionSensor) {
|
||||
case .detectionSensor:
|
||||
upsertDetectionSensorModuleConfigPacket(config: config.detectionSensor, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.externalNotification(config.externalNotification) {
|
||||
case .externalNotification:
|
||||
upsertExternalNotificationModuleConfigPacket(config: config.externalNotification, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.mqtt(config.mqtt) {
|
||||
case .mqtt:
|
||||
upsertMqttModuleConfigPacket(config: config.mqtt, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.paxcounter(config.paxcounter) {
|
||||
case .paxcounter:
|
||||
upsertPaxCounterModuleConfigPacket(config: config.paxcounter, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.rangeTest(config.rangeTest) {
|
||||
case .rangeTest:
|
||||
upsertRangeTestModuleConfigPacket(config: config.rangeTest, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.serial(config.serial) {
|
||||
case .serial:
|
||||
upsertSerialModuleConfigPacket(config: config.serial, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.telemetry(config.telemetry) {
|
||||
case .telemetry:
|
||||
upsertTelemetryModuleConfigPacket(config: config.telemetry, nodeNum: nodeNum, context: context)
|
||||
} else if config.payloadVariant == ModuleConfig.OneOf_PayloadVariant.storeForward(config.storeForward) {
|
||||
case .storeForward:
|
||||
upsertStoreForwardModuleConfigPacket(config: config.storeForward, nodeNum: nodeNum, context: context)
|
||||
default:
|
||||
#if DEBUG
|
||||
Logger.services.error("⁉️ Unknown Module Config variant UNHANDLED \(config.payloadVariant.debugDescription, privacy: .public)")
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -258,7 +266,7 @@ func deviceMetadataPacket (metadata: DeviceMetadata, fromNum: Int64, sessionPass
|
|||
|
||||
func nodeInfoPacket (nodeInfo: NodeInfo, channel: UInt32, context: NSManagedObjectContext) -> NodeInfoEntity? {
|
||||
|
||||
let logString = String.localizedStringWithFormat("Node info received for: %@".localized, String(nodeInfo.num))
|
||||
let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, String(nodeInfo.num))
|
||||
Logger.mesh.info("📟 \(logString, privacy: .public)")
|
||||
|
||||
guard nodeInfo.num > 0 else { return nil }
|
||||
|
|
@ -716,139 +724,139 @@ func routingPacket (packet: MeshPacket, connectedNodeNum: Int64, context: NSMana
|
|||
}
|
||||
|
||||
func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManagedObjectContext) {
|
||||
|
||||
if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) {
|
||||
let logString = String.localizedStringWithFormat("Telemetry received for: %@".localized, String(packet.from))
|
||||
Logger.mesh.info("📈 \(logString, privacy: .public)")
|
||||
if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) {
|
||||
/// Other unhandled telemetry packets
|
||||
return
|
||||
}
|
||||
let telemetry = TelemetryEntity(context: context)
|
||||
let fetchNodeTelemetryRequest = NodeInfoEntity.fetchRequest()
|
||||
fetchNodeTelemetryRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from))
|
||||
do {
|
||||
let fetchedNode = try context.fetch(fetchNodeTelemetryRequest)
|
||||
if fetchedNode.count == 1 {
|
||||
/// Currently only Device Metrics and Environment Telemetry are supported in the app
|
||||
if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) {
|
||||
// Device Metrics
|
||||
telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx)
|
||||
telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization)
|
||||
telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel))
|
||||
telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage)
|
||||
telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds))
|
||||
telemetry.metricsType = 0
|
||||
Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
} else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) {
|
||||
// Environment Metrics
|
||||
telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure)
|
||||
telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current)
|
||||
telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq))
|
||||
telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance)
|
||||
telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity)
|
||||
telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature)
|
||||
telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current)
|
||||
telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage)
|
||||
telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight)
|
||||
telemetry.distance = telemetryMessage.environmentMetrics.hasDistance.then(telemetryMessage.environmentMetrics.distance)
|
||||
telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed)
|
||||
telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust)
|
||||
telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull)
|
||||
telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection))
|
||||
telemetry.irLux = telemetryMessage.environmentMetrics.hasIrLux.then(telemetryMessage.environmentMetrics.irLux)
|
||||
telemetry.lux = telemetryMessage.environmentMetrics.hasLux.then(telemetryMessage.environmentMetrics.lux)
|
||||
telemetry.whiteLux = telemetryMessage.environmentMetrics.hasWhiteLux.then(telemetryMessage.environmentMetrics.whiteLux)
|
||||
telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux.then(telemetryMessage.environmentMetrics.uvLux)
|
||||
telemetry.radiation = telemetryMessage.environmentMetrics.hasRadiation.then(telemetryMessage.environmentMetrics.radiation)
|
||||
telemetry.rainfall1H = telemetryMessage.environmentMetrics.hasRainfall1H.then(telemetryMessage.environmentMetrics.rainfall1H)
|
||||
telemetry.rainfall24H = telemetryMessage.environmentMetrics.hasRainfall24H.then(telemetryMessage.environmentMetrics.rainfall24H)
|
||||
telemetry.soilTemperature = telemetryMessage.environmentMetrics.hasSoilTemperature.then(telemetryMessage.environmentMetrics.soilTemperature)
|
||||
telemetry.soilMoisture = telemetryMessage.environmentMetrics.hasSoilMoisture.then(telemetryMessage.environmentMetrics.soilMoisture)
|
||||
telemetry.metricsType = 1
|
||||
} else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) {
|
||||
// Local Stats for Live activity
|
||||
telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds)
|
||||
telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization
|
||||
telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx
|
||||
telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx)
|
||||
telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx)
|
||||
telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad)
|
||||
telemetry.numRxDupe = Int32(truncatingIfNeeded: telemetryMessage.localStats.numRxDupe)
|
||||
telemetry.numTxRelay = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelay)
|
||||
telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled)
|
||||
telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes)
|
||||
telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes)
|
||||
telemetry.metricsType = 4
|
||||
Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
} else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) {
|
||||
Logger.data.info("📈 [Power Metrics] Received for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage)
|
||||
telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current)
|
||||
telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage)
|
||||
telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch2Current)
|
||||
telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage)
|
||||
telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current)
|
||||
telemetry.metricsType = 2
|
||||
}
|
||||
telemetry.snr = packet.rxSnr
|
||||
telemetry.rssi = packet.rxRssi
|
||||
telemetry.time = Date(timeIntervalSince1970: TimeInterval(Int64(truncatingIfNeeded: telemetryMessage.time)))
|
||||
guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else {
|
||||
return
|
||||
}
|
||||
mutableTelemetries.add(telemetry)
|
||||
if packet.rxTime > 0 {
|
||||
fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(packet.rxTime))
|
||||
} else {
|
||||
fetchedNode[0].lastHeard = Date()
|
||||
}
|
||||
fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet
|
||||
Task { @MainActor in
|
||||
if let telemetryMessage = try? Telemetry(serializedBytes: packet.decoded.payload) {
|
||||
if telemetryMessage.variant != Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) && telemetryMessage.variant != Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) && telemetryMessage.variant != Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) {
|
||||
/// Other unhandled telemetry packets
|
||||
return
|
||||
}
|
||||
try context.save()
|
||||
Logger.data.info("💾 [TelemetryEntity] of type \(MetricsTypes(rawValue: Int(telemetry.metricsType))?.name ?? "Unknown Metrics Type", privacy: .public) Saved for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
if telemetry.metricsType == 0 {
|
||||
// Connected Device Metrics
|
||||
// ------------------------
|
||||
// Low Battery notification
|
||||
if connectedNode == Int64(packet.from) {
|
||||
let batteryLevel = telemetry.batteryLevel ?? 0
|
||||
if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 {
|
||||
let manager = LocalNotificationManager()
|
||||
manager.notifications = [
|
||||
Notification(
|
||||
id: ("notification.id.\(UUID().uuidString)"),
|
||||
title: "Critically Low Battery!",
|
||||
subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")",
|
||||
content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.",
|
||||
target: "nodes",
|
||||
path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)"
|
||||
)
|
||||
]
|
||||
manager.schedule()
|
||||
let telemetry = TelemetryEntity(context: context)
|
||||
let fetchNodeTelemetryRequest = NodeInfoEntity.fetchRequest()
|
||||
fetchNodeTelemetryRequest.predicate = NSPredicate(format: "num == %lld", Int64(packet.from))
|
||||
do {
|
||||
let fetchedNode = try context.fetch(fetchNodeTelemetryRequest)
|
||||
if fetchedNode.count == 1 {
|
||||
/// Currently only Device Metrics and Environment Telemetry are supported in the app
|
||||
if telemetryMessage.variant == Telemetry.OneOf_Variant.deviceMetrics(telemetryMessage.deviceMetrics) {
|
||||
// Device Metrics
|
||||
Logger.data.info("📈 [Telemetry] Device Metrics Received for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
telemetry.airUtilTx = telemetryMessage.deviceMetrics.hasAirUtilTx.then(telemetryMessage.deviceMetrics.airUtilTx)
|
||||
telemetry.channelUtilization = telemetryMessage.deviceMetrics.hasChannelUtilization.then(telemetryMessage.deviceMetrics.channelUtilization)
|
||||
telemetry.batteryLevel = telemetryMessage.deviceMetrics.hasBatteryLevel.then(Int32(telemetryMessage.deviceMetrics.batteryLevel))
|
||||
telemetry.voltage = telemetryMessage.deviceMetrics.hasVoltage.then(telemetryMessage.deviceMetrics.voltage)
|
||||
telemetry.uptimeSeconds = telemetryMessage.deviceMetrics.hasUptimeSeconds.then(Int32(telemetryMessage.deviceMetrics.uptimeSeconds))
|
||||
telemetry.metricsType = 0
|
||||
Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.deviceMetrics.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.deviceMetrics.airUtilTx, privacy: .public) for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
} else if telemetryMessage.variant == Telemetry.OneOf_Variant.environmentMetrics(telemetryMessage.environmentMetrics) {
|
||||
// Environment Metrics
|
||||
Logger.data.info("📈 [Telemetry] Environment Metrics Received for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
telemetry.barometricPressure = telemetryMessage.environmentMetrics.hasBarometricPressure.then(telemetryMessage.environmentMetrics.barometricPressure)
|
||||
telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current)
|
||||
telemetry.iaq = telemetryMessage.environmentMetrics.hasIaq.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.iaq))
|
||||
telemetry.gasResistance = telemetryMessage.environmentMetrics.hasGasResistance.then(telemetryMessage.environmentMetrics.gasResistance)
|
||||
telemetry.relativeHumidity = telemetryMessage.environmentMetrics.hasRelativeHumidity.then(telemetryMessage.environmentMetrics.relativeHumidity)
|
||||
telemetry.temperature = telemetryMessage.environmentMetrics.hasTemperature.then(telemetryMessage.environmentMetrics.temperature)
|
||||
telemetry.current = telemetryMessage.environmentMetrics.hasCurrent.then(telemetryMessage.environmentMetrics.current)
|
||||
telemetry.voltage = telemetryMessage.environmentMetrics.hasVoltage.then(telemetryMessage.environmentMetrics.voltage)
|
||||
telemetry.weight = telemetryMessage.environmentMetrics.hasWeight.then(telemetryMessage.environmentMetrics.weight)
|
||||
telemetry.distance = telemetryMessage.environmentMetrics.hasDistance.then(telemetryMessage.environmentMetrics.distance)
|
||||
telemetry.windSpeed = telemetryMessage.environmentMetrics.hasWindSpeed.then(telemetryMessage.environmentMetrics.windSpeed)
|
||||
telemetry.windGust = telemetryMessage.environmentMetrics.hasWindGust.then(telemetryMessage.environmentMetrics.windGust)
|
||||
telemetry.windLull = telemetryMessage.environmentMetrics.hasWindLull.then(telemetryMessage.environmentMetrics.windLull)
|
||||
telemetry.windDirection = telemetryMessage.environmentMetrics.hasWindDirection.then(Int32(truncatingIfNeeded: telemetryMessage.environmentMetrics.windDirection))
|
||||
telemetry.irLux = telemetryMessage.environmentMetrics.hasIrLux.then(telemetryMessage.environmentMetrics.irLux)
|
||||
telemetry.lux = telemetryMessage.environmentMetrics.hasLux.then(telemetryMessage.environmentMetrics.lux)
|
||||
telemetry.whiteLux = telemetryMessage.environmentMetrics.hasWhiteLux.then(telemetryMessage.environmentMetrics.whiteLux)
|
||||
telemetry.uvLux = telemetryMessage.environmentMetrics.hasUvLux.then(telemetryMessage.environmentMetrics.uvLux)
|
||||
telemetry.radiation = telemetryMessage.environmentMetrics.hasRadiation.then(telemetryMessage.environmentMetrics.radiation)
|
||||
telemetry.rainfall1H = telemetryMessage.environmentMetrics.hasRainfall1H.then(telemetryMessage.environmentMetrics.rainfall1H)
|
||||
telemetry.rainfall24H = telemetryMessage.environmentMetrics.hasRainfall24H.then(telemetryMessage.environmentMetrics.rainfall24H)
|
||||
telemetry.soilTemperature = telemetryMessage.environmentMetrics.hasSoilTemperature.then(telemetryMessage.environmentMetrics.soilTemperature)
|
||||
telemetry.soilMoisture = telemetryMessage.environmentMetrics.hasSoilMoisture.then(telemetryMessage.environmentMetrics.soilMoisture)
|
||||
telemetry.metricsType = 1
|
||||
} else if telemetryMessage.variant == Telemetry.OneOf_Variant.localStats(telemetryMessage.localStats) {
|
||||
// Local Stats for Live activity
|
||||
telemetry.uptimeSeconds = Int32(telemetryMessage.localStats.uptimeSeconds)
|
||||
telemetry.channelUtilization = telemetryMessage.localStats.channelUtilization
|
||||
telemetry.airUtilTx = telemetryMessage.localStats.airUtilTx
|
||||
telemetry.numPacketsTx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsTx)
|
||||
telemetry.numPacketsRx = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRx)
|
||||
telemetry.numPacketsRxBad = Int32(truncatingIfNeeded: telemetryMessage.localStats.numPacketsRxBad)
|
||||
telemetry.numRxDupe = Int32(truncatingIfNeeded: telemetryMessage.localStats.numRxDupe)
|
||||
telemetry.numTxRelay = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelay)
|
||||
telemetry.numTxRelayCanceled = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTxRelayCanceled)
|
||||
telemetry.numOnlineNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numOnlineNodes)
|
||||
telemetry.numTotalNodes = Int32(truncatingIfNeeded: telemetryMessage.localStats.numTotalNodes)
|
||||
telemetry.metricsType = 4
|
||||
Logger.statistics.info("📈 [Mesh Statistics] Channel Utilization: \(telemetryMessage.localStats.channelUtilization, privacy: .public) Airtime: \(telemetryMessage.localStats.airUtilTx, privacy: .public) Packets Sent: \(telemetryMessage.localStats.numPacketsTx, privacy: .public) Packets Received: \(telemetryMessage.localStats.numPacketsRx, privacy: .public) Bad Packets Received: \(telemetryMessage.localStats.numPacketsRxBad, privacy: .public) Nodes Online: \(telemetryMessage.localStats.numOnlineNodes, privacy: .public) of \(telemetryMessage.localStats.numTotalNodes, privacy: .public) nodes for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
} else if telemetryMessage.variant == Telemetry.OneOf_Variant.powerMetrics(telemetryMessage.powerMetrics) {
|
||||
Logger.data.info("📈 [Telemetry] Power Metrics Received for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
telemetry.powerCh1Voltage = telemetryMessage.powerMetrics.hasCh1Voltage.then(telemetryMessage.powerMetrics.ch1Voltage)
|
||||
telemetry.powerCh1Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch1Current)
|
||||
telemetry.powerCh2Voltage = telemetryMessage.powerMetrics.hasCh2Voltage.then(telemetryMessage.powerMetrics.ch2Voltage)
|
||||
telemetry.powerCh2Current = telemetryMessage.powerMetrics.hasCh1Current.then(telemetryMessage.powerMetrics.ch2Current)
|
||||
telemetry.powerCh3Voltage = telemetryMessage.powerMetrics.hasCh3Voltage.then(telemetryMessage.powerMetrics.ch3Voltage)
|
||||
telemetry.powerCh3Current = telemetryMessage.powerMetrics.hasCh3Current.then(telemetryMessage.powerMetrics.ch3Current)
|
||||
telemetry.metricsType = 2
|
||||
}
|
||||
telemetry.snr = packet.rxSnr
|
||||
telemetry.rssi = packet.rxRssi
|
||||
telemetry.time = Date(timeIntervalSince1970: TimeInterval(Int64(truncatingIfNeeded: telemetryMessage.time)))
|
||||
guard let mutableTelemetries = fetchedNode[0].telemetries!.mutableCopy() as? NSMutableOrderedSet else {
|
||||
return
|
||||
}
|
||||
mutableTelemetries.add(telemetry)
|
||||
if packet.rxTime > 0 {
|
||||
fetchedNode[0].lastHeard = Date(timeIntervalSince1970: TimeInterval(packet.rxTime))
|
||||
} else {
|
||||
fetchedNode[0].lastHeard = Date()
|
||||
}
|
||||
fetchedNode[0].telemetries = mutableTelemetries.copy() as? NSOrderedSet
|
||||
}
|
||||
} else if telemetry.metricsType == 4 {
|
||||
// Update our live activity if there is one running, not available on mac
|
||||
try context.save()
|
||||
Logger.data.info("💾 [TelemetryEntity] of type \(MetricsTypes(rawValue: Int(telemetry.metricsType))?.name ?? "Unknown Metrics Type", privacy: .public) Saved for Node: \(packet.from.toHex(), privacy: .public)")
|
||||
if telemetry.metricsType == 0 {
|
||||
// Connected Device Metrics
|
||||
// ------------------------
|
||||
// Low Battery notification
|
||||
if connectedNode == Int64(packet.from) {
|
||||
let batteryLevel = telemetry.batteryLevel ?? 0
|
||||
if UserDefaults.lowBatteryNotifications && batteryLevel > 0 && batteryLevel < 4 {
|
||||
let manager = LocalNotificationManager()
|
||||
manager.notifications = [
|
||||
Notification(
|
||||
id: ("notification.id.\(UUID().uuidString)"),
|
||||
title: "Critically Low Battery!",
|
||||
subtitle: "AKA \(telemetry.nodeTelemetry?.user?.shortName ?? "UNK")",
|
||||
content: "Time to charge your radio, there is \(telemetry.batteryLevel?.formatted(.number) ?? Constants.nilValueIndicator)% battery remaining.",
|
||||
target: "nodes",
|
||||
path: "meshtastic:///nodes?nodenum=\(telemetry.nodeTelemetry?.num ?? 0)"
|
||||
)
|
||||
]
|
||||
manager.schedule()
|
||||
}
|
||||
}
|
||||
} else if telemetry.metricsType == 4 {
|
||||
// Update our live activity if there is one running, not available on mac
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
#if canImport(ActivityKit)
|
||||
|
||||
let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())!
|
||||
let date = Date.now...fifteenMinutesLater
|
||||
let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) },
|
||||
channelUtilization: telemetry.channelUtilization,
|
||||
airtime: telemetry.airUtilTx,
|
||||
sentPackets: UInt32(telemetry.numPacketsTx),
|
||||
receivedPackets: UInt32(telemetry.numPacketsRx),
|
||||
badReceivedPackets: UInt32(telemetry.numPacketsRxBad),
|
||||
dupeReceivedPackets: UInt32(telemetry.numRxDupe),
|
||||
packetsSentRelay: UInt32(telemetry.numTxRelay),
|
||||
packetsCanceledRelay: UInt32(telemetry.numTxRelayCanceled),
|
||||
nodesOnline: UInt32(telemetry.numOnlineNodes),
|
||||
totalNodes: UInt32(telemetry.numTotalNodes),
|
||||
timerRange: date)
|
||||
let fifteenMinutesLater = Calendar.current.date(byAdding: .minute, value: (Int(15) ), to: Date())!
|
||||
let date = Date.now...fifteenMinutesLater
|
||||
let updatedMeshStatus = MeshActivityAttributes.MeshActivityStatus(uptimeSeconds: telemetry.uptimeSeconds.map { UInt32($0) },
|
||||
channelUtilization: telemetry.channelUtilization,
|
||||
airtime: telemetry.airUtilTx,
|
||||
sentPackets: UInt32(telemetry.numPacketsTx),
|
||||
receivedPackets: UInt32(telemetry.numPacketsRx),
|
||||
badReceivedPackets: UInt32(telemetry.numPacketsRxBad),
|
||||
dupeReceivedPackets: UInt32(telemetry.numRxDupe),
|
||||
packetsSentRelay: UInt32(telemetry.numTxRelay),
|
||||
packetsCanceledRelay: UInt32(telemetry.numTxRelayCanceled),
|
||||
nodesOnline: UInt32(telemetry.numOnlineNodes),
|
||||
totalNodes: UInt32(telemetry.numTotalNodes),
|
||||
timerRange: date)
|
||||
|
||||
let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default)
|
||||
let alertConfiguration = AlertConfiguration(title: "Mesh activity update", body: "Updated Node Stats Data.", sound: .default)
|
||||
let updatedContent = ActivityContent(state: updatedMeshStatus, staleDate: nil)
|
||||
|
||||
let meshActivity = Activity<MeshActivityAttributes>.activities.first(where: { $0.attributes.nodeNum == connectedNode })
|
||||
|
|
@ -861,14 +869,15 @@ func telemetryPacket(packet: MeshPacket, connectedNode: Int64, context: NSManage
|
|||
}
|
||||
#endif
|
||||
#endif
|
||||
}
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
Logger.data.error("💥 Error Saving Telemetry for Node \(packet.from, privacy: .public) Error: \(nsError, privacy: .public)")
|
||||
}
|
||||
} catch {
|
||||
context.rollback()
|
||||
let nsError = error as NSError
|
||||
Logger.data.error("💥 Error Saving Telemetry for Node \(packet.from, privacy: .public) Error: \(nsError, privacy: .public)")
|
||||
} else {
|
||||
Logger.data.error("💥 Error Fetching NodeInfoEntity for Node \(packet.from.toHex(), privacy: .public)")
|
||||
}
|
||||
} else {
|
||||
Logger.data.error("💥 Error Fetching NodeInfoEntity for Node \(packet.from.toHex(), privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -879,7 +888,7 @@ func textMessageAppPacket(
|
|||
connectedNode: Int64,
|
||||
storeForward: Bool = false,
|
||||
context: NSManagedObjectContext,
|
||||
appState: AppState
|
||||
appState: AppState?
|
||||
) {
|
||||
var messageText = String(bytes: packet.decoded.payload, encoding: .utf8)
|
||||
let rangeRef = Reference(Int.self)
|
||||
|
|
@ -1015,7 +1024,7 @@ func textMessageAppPacket(
|
|||
if newMessage.fromUser != nil && newMessage.toUser != nil {
|
||||
// Set Unread Message Indicators
|
||||
if packet.to == connectedNode {
|
||||
appState.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0
|
||||
appState?.unreadDirectMessages = newMessage.toUser?.unreadMessages ?? 0
|
||||
}
|
||||
if !(newMessage.fromUser?.mute ?? false) {
|
||||
// Create an iOS Notification for the received DM message
|
||||
|
|
@ -1043,7 +1052,7 @@ func textMessageAppPacket(
|
|||
do {
|
||||
let fetchedMyInfo = try context.fetch(fetchMyInfoRequest)
|
||||
if !fetchedMyInfo.isEmpty {
|
||||
appState.unreadChannelMessages = fetchedMyInfo[0].unreadMessages
|
||||
appState?.unreadChannelMessages = fetchedMyInfo[0].unreadMessages
|
||||
for channel in (fetchedMyInfo[0].channels?.array ?? []) as? [ChannelEntity] ?? [] {
|
||||
if channel.index == newMessage.channel {
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
|
|
|
|||
|
|
@ -100,9 +100,15 @@
|
|||
<key>NSBluetoothAlwaysUsageDescription</key>
|
||||
<string>We use bluetooth to connect to nearby Meshtastic Devices</string>
|
||||
<key>NSBluetoothPeripheralUsageDescription</key>
|
||||
<string>Bluetooth is used to connect an iPhone to a user's meshtastic device to allow text messaging and location data for the mesh network.</string>
|
||||
<string>Bluetooth is used to connect an iPhone to a user's meshtastic device to allow text messaging and location data for the mesh network.</string>
|
||||
<key>NSBonjourServices</key>
|
||||
<array>
|
||||
<string>_meshtastic._tcp</string>
|
||||
</array>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We use the camera to share channels using a QR Code</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>We use local networking to connect to network-based nodes.</string>
|
||||
<key>NSLocationAlwaysAndWhenInUseUsageDescription</key>
|
||||
<string>We use your location to display it on the mesh map as well as to have GPS coordinates to send to the connected device. Route Recording uses location in the background.</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@
|
|||
<true/>
|
||||
<key>com.apple.security.device.bluetooth</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.serial</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
|
|
|
|||
|
|
@ -10,7 +10,9 @@ import DatadogCrashReporting
|
|||
import DatadogRUM
|
||||
import DatadogTrace
|
||||
import DatadogLogs
|
||||
|
||||
#if DEBUG
|
||||
import DatadogSessionReplay
|
||||
#endif
|
||||
@main
|
||||
struct MeshtasticAppleApp: App {
|
||||
|
||||
|
|
@ -19,7 +21,7 @@ struct MeshtasticAppleApp: App {
|
|||
@ObservedObject var appState: AppState
|
||||
|
||||
private let persistenceController: PersistenceController
|
||||
|
||||
private let accessoryManager: AccessoryManager
|
||||
@Environment(\.scenePhase) var scenePhase
|
||||
@State var saveChannels = false
|
||||
@State var incomingUrl: URL?
|
||||
|
|
@ -27,7 +29,9 @@ struct MeshtasticAppleApp: App {
|
|||
@State var addChannels = false
|
||||
|
||||
init() {
|
||||
|
||||
let persistenceController = PersistenceController.shared
|
||||
|
||||
let appState = AppState(
|
||||
router: Router()
|
||||
)
|
||||
|
|
@ -35,9 +39,13 @@ struct MeshtasticAppleApp: App {
|
|||
// RUM Client Tokens are NOT secret
|
||||
let appID = "79fe92a9-74c9-4c8f-ba63-6308384ecfa9"
|
||||
let clientToken = "pub4427bea20dbdb08a6af68034de22cd3b"
|
||||
let environment = "testflight"
|
||||
var environment = "AppStore"
|
||||
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
|
||||
#if DEBUG
|
||||
environment = "TestFlight"
|
||||
#endif
|
||||
Datadog.initialize(
|
||||
with: Datadog.Configuration(
|
||||
clientToken: clientToken,
|
||||
|
|
@ -67,10 +75,24 @@ struct MeshtasticAppleApp: App {
|
|||
"hardware_model": UserDefaults.hardwareModel
|
||||
]
|
||||
RUMMonitor.shared().addAttributes(attributes)
|
||||
#if DEBUG
|
||||
SessionReplay.enable(
|
||||
with: SessionReplay.Configuration(
|
||||
replaySampleRate: 100,
|
||||
textAndInputPrivacyLevel: .maskSensitiveInputs,
|
||||
imagePrivacyLevel: .maskNone,
|
||||
touchPrivacyLevel: .show,
|
||||
startRecordingImmediately: true,
|
||||
featureFlags: [.swiftui: true]
|
||||
)
|
||||
)
|
||||
#endif
|
||||
#endif
|
||||
accessoryManager = AccessoryManager.shared
|
||||
accessoryManager.appState = appState
|
||||
|
||||
self._appState = ObservedObject(wrappedValue: appState)
|
||||
// Initialize the BLEManager singleton with the necessary dependencies
|
||||
BLEManager.setup(appState: appState, context: persistenceController.container.viewContext)
|
||||
|
||||
self.persistenceController = persistenceController
|
||||
// Wire up router
|
||||
self.appDelegate.router = appState.router
|
||||
|
|
@ -81,6 +103,13 @@ struct MeshtasticAppleApp: App {
|
|||
// Show tips in development
|
||||
try? Tips.resetDatastore()
|
||||
#endif
|
||||
if !UserDefaults.firstLaunch {
|
||||
// If this is first launch, we will show onboarding screens which
|
||||
// Step through the authorization process. Do not start discovery
|
||||
// unitl this workflow completes, otherwise the discovery process
|
||||
// may trigger permission dialogs too soon.
|
||||
accessoryManager.startDiscovery()
|
||||
}
|
||||
}
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
|
|
@ -102,8 +131,7 @@ struct MeshtasticAppleApp: App {
|
|||
SaveChannelQRCode(
|
||||
channelSetLink: channelSettings ?? "Empty Channel URL",
|
||||
addChannels: addChannels,
|
||||
bleManager: BLEManager.shared
|
||||
)
|
||||
accessoryManager: accessoryManager )
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
|
|
@ -112,7 +140,7 @@ struct MeshtasticAppleApp: App {
|
|||
self.incomingUrl = userActivity.webpageURL
|
||||
self.saveChannels = false
|
||||
if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/v/#") == true {
|
||||
ContactURLHandler.handleContactUrl(url: self.incomingUrl!, bleManager: BLEManager.shared)
|
||||
ContactURLHandler.handleContactUrl(url: self.incomingUrl!, accessoryManager: accessoryManager)
|
||||
} else if self.incomingUrl?.absoluteString.lowercased().contains("meshtastic.org/e/") == true {
|
||||
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
|
||||
self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
|
||||
|
|
@ -140,7 +168,7 @@ struct MeshtasticAppleApp: App {
|
|||
Logger.mesh.debug("Some sort of URL was received \(url, privacy: .public)")
|
||||
self.incomingUrl = url
|
||||
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
|
||||
ContactURLHandler.handleContactUrl(url: url, bleManager: BLEManager.shared)
|
||||
ContactURLHandler.handleContactUrl(url: url, accessoryManager: accessoryManager)
|
||||
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
|
||||
if let components = self.incomingUrl?.absoluteString.components(separatedBy: "#") {
|
||||
self.addChannels = Bool(self.incomingUrl?["add"] ?? "false") ?? false
|
||||
|
|
@ -179,8 +207,8 @@ struct MeshtasticAppleApp: App {
|
|||
switch newScenePhase {
|
||||
case .background:
|
||||
Logger.services.info("🎬 [App] Scene is in the background")
|
||||
accessoryManager.appDidEnterBackground()
|
||||
do {
|
||||
|
||||
try persistenceController.container.viewContext.save()
|
||||
Logger.services.info("💾 [App] Saved CoreData ViewContext when the app went to the background.")
|
||||
|
||||
|
|
@ -192,13 +220,14 @@ struct MeshtasticAppleApp: App {
|
|||
Logger.services.info("🎬 [App] Scene is inactive")
|
||||
case .active:
|
||||
Logger.services.info("🎬 [App] Scene is active")
|
||||
accessoryManager.appDidBecomeActive()
|
||||
@unknown default:
|
||||
Logger.services.error("🍎 [App] Apple must have changed something")
|
||||
}
|
||||
}
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
.environmentObject(appState)
|
||||
.environmentObject(BLEManager.shared)
|
||||
.environmentObject(accessoryManager)
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,45 +50,58 @@ class MeshtasticAppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificat
|
|||
case "messageNotification.thumbsUpAction":
|
||||
if let channel = userInfo["channel"] as? Int32,
|
||||
let replyID = userInfo["messageId"] as? Int64 {
|
||||
let tapbackResponse = !BLEManager.shared.sendMessage(
|
||||
message: Tapbacks.thumbsUp.emojiString,
|
||||
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
|
||||
channel: channel,
|
||||
isEmoji: true,
|
||||
replyID: replyID
|
||||
)
|
||||
Logger.services.info("Tapback response sent")
|
||||
} else {
|
||||
Logger.services.error("Failed to retrieve channel or messageId from userInfo")
|
||||
Task {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: Tapbacks.thumbsUp.emojiString,
|
||||
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
|
||||
channel: channel,
|
||||
isEmoji: true,
|
||||
replyID: replyID
|
||||
)
|
||||
Logger.services.info("Tapback response sent")
|
||||
} catch {
|
||||
Logger.services.error("Failed to retrieve channel or messageId from userInfo")
|
||||
}
|
||||
}
|
||||
}
|
||||
case "messageNotification.thumbsDownAction":
|
||||
if let channel = userInfo["channel"] as? Int32,
|
||||
let replyID = userInfo["messageId"] as? Int64 {
|
||||
let tapbackResponse = !BLEManager.shared.sendMessage(
|
||||
message: Tapbacks.thumbsDown.emojiString,
|
||||
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
|
||||
channel: channel,
|
||||
isEmoji: true,
|
||||
replyID: replyID
|
||||
)
|
||||
Logger.services.info("Tapback response sent")
|
||||
} else {
|
||||
Logger.services.error("Failed to retrieve channel or messageId from userInfo")
|
||||
Task {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: Tapbacks.thumbsDown.emojiString,
|
||||
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
|
||||
channel: channel,
|
||||
isEmoji: true,
|
||||
replyID: replyID
|
||||
)
|
||||
Logger.services.info("Tapback response sent")
|
||||
} catch {
|
||||
Logger.services.error("Failed to retrieve channel or messageId from userInfo")
|
||||
}
|
||||
}
|
||||
}
|
||||
case "messageNotification.replyInputAction":
|
||||
if let userInput = (response as? UNTextInputNotificationResponse)?.userText,
|
||||
let channel = userInfo["channel"] as? Int32,
|
||||
let replyID = userInfo["messageId"] as? Int64 {
|
||||
let tapbackResponse = !BLEManager.shared.sendMessage(
|
||||
message: userInput,
|
||||
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
|
||||
channel: channel,
|
||||
isEmoji: false,
|
||||
replyID: replyID
|
||||
)
|
||||
Logger.services.info("Actionable notification reply sent")
|
||||
} else {
|
||||
Logger.services.error("Failed to retrieve user input, channel, or messageId from userInfo")
|
||||
Task {
|
||||
do {
|
||||
try await AccessoryManager.shared.sendMessage(
|
||||
message: userInput,
|
||||
toUserNum: userInfo["userNum"] as? Int64 ?? 0,
|
||||
channel: channel,
|
||||
isEmoji: false,
|
||||
replyID: replyID
|
||||
)
|
||||
|
||||
Logger.services.info("Actionable notification reply sent")
|
||||
} catch {
|
||||
Logger.services.error("Failed to retrieve user input, channel, or messageId from userInfo")
|
||||
}
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
|
|
|
|||
|
|
@ -167,7 +167,7 @@ public func clearCoreDataDatabase(context: NSManagedObjectContext, includeRoutes
|
|||
|
||||
func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext) {
|
||||
|
||||
let logString = String.localizedStringWithFormat("Node info received for: %@".localized, packet.from.toHex())
|
||||
let logString = String.localizedStringWithFormat("[NodeInfo] received for: %@".localized, packet.from.toHex())
|
||||
Logger.mesh.info("📟 \(logString, privacy: .public)")
|
||||
|
||||
guard packet.from > 0 else { return }
|
||||
|
|
@ -403,7 +403,7 @@ func upsertNodeInfoPacket (packet: MeshPacket, context: NSManagedObjectContext)
|
|||
|
||||
func upsertPositionPacket (packet: MeshPacket, context: NSManagedObjectContext) {
|
||||
|
||||
let logString = String.localizedStringWithFormat("Position Packet received from node: %@".localized, String(packet.from))
|
||||
let logString = String.localizedStringWithFormat("[Position] received from node: %@".localized, String(packet.from))
|
||||
Logger.mesh.info("📍 \(logString, privacy: .public)")
|
||||
|
||||
let fetchNodePositionRequest = NodeInfoEntity.fetchRequest()
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@
|
|||
"platformioTarget": "tlora-v2-1-1_6",
|
||||
"architecture": "esp32",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 1,
|
||||
"supportLevel": 3,
|
||||
"displayName": "LILYGO T-LoRa V2.1-1.6",
|
||||
"tags": [
|
||||
"LilyGo"
|
||||
|
|
@ -42,7 +42,7 @@
|
|||
"platformioTarget": "tbeam",
|
||||
"architecture": "esp32",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 1,
|
||||
"supportLevel": 3,
|
||||
"displayName": "LILYGO T-Beam",
|
||||
"tags": [
|
||||
"LilyGo"
|
||||
|
|
@ -163,6 +163,7 @@
|
|||
"platformioTarget": "rak11200",
|
||||
"architecture": "esp32",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 3,
|
||||
"displayName": "RAK WisBlock 11200",
|
||||
"tags": [
|
||||
"RAK"
|
||||
|
|
@ -189,7 +190,7 @@
|
|||
"platformioTarget": "tlora-v2-1-1_8",
|
||||
"architecture": "esp32",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 2,
|
||||
"supportLevel": 3,
|
||||
"displayName": "LILYGO T-LoRa V2.1-1.8",
|
||||
"tags": [
|
||||
"LilyGo",
|
||||
|
|
@ -266,7 +267,7 @@
|
|||
"platformioTarget": "wio-tracker-wm1110",
|
||||
"architecture": "nrf52840",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 1,
|
||||
"supportLevel": 3,
|
||||
"displayName": "Seeed Wio WM1110 Tracker",
|
||||
"tags": [
|
||||
"Seeed"
|
||||
|
|
@ -536,7 +537,7 @@
|
|||
"platformioTarget": "t-watch-s3",
|
||||
"architecture": "esp32-s3",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 1,
|
||||
"supportLevel": 3,
|
||||
"displayName": "LILYGO T-Watch S3",
|
||||
"tags": [
|
||||
"LilyGo"
|
||||
|
|
@ -766,7 +767,7 @@
|
|||
"platformioTarget": "seeed-xiao-s3",
|
||||
"architecture": "esp32-s3",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 1,
|
||||
"supportLevel": 3,
|
||||
"displayName": "Seeed Xiao ESP32-S3",
|
||||
"tags": [
|
||||
"Seeed"
|
||||
|
|
@ -777,6 +778,22 @@
|
|||
"requiresDfu": true,
|
||||
"partitionScheme": "8MB"
|
||||
},
|
||||
{
|
||||
"hwModel": 105,
|
||||
"hwModelSlug": "WISMESH_TAG",
|
||||
"platformioTarget": "rak_wismeshtag",
|
||||
"architecture": "nrf52840",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 1,
|
||||
"displayName": "RAK WisMesh Tag",
|
||||
"tags": [
|
||||
"RAK"
|
||||
],
|
||||
"images": [
|
||||
"rak_wismesh_tag.svg"
|
||||
],
|
||||
"requiresDfu": true
|
||||
},
|
||||
{
|
||||
"hwModel": 84,
|
||||
"hwModelSlug": "WISMESH_TAP",
|
||||
|
|
@ -858,6 +875,23 @@
|
|||
],
|
||||
"hasInkHud": true
|
||||
},
|
||||
{
|
||||
"hwModel": 107,
|
||||
"hwModelSlug": "THINKNODE_M5",
|
||||
"platformioTarget": "thinknode_m5",
|
||||
"architecture": "esp32-s3",
|
||||
"activelySupported": true,
|
||||
"supportLevel": 1,
|
||||
"displayName": "ThinkNode M5",
|
||||
"tags": [
|
||||
"Elecrow"
|
||||
],
|
||||
"requiresDfu": false,
|
||||
"images": [
|
||||
"thinknode_m1.svg"
|
||||
],
|
||||
"hasInkHud": true
|
||||
},
|
||||
{
|
||||
"hwModel": 90,
|
||||
"hwModelSlug": "THINKNODE_M2",
|
||||
|
|
@ -992,5 +1026,23 @@
|
|||
],
|
||||
"partitionScheme": "16MB",
|
||||
"hasMui": true
|
||||
},
|
||||
{
|
||||
"hwModel": 102,
|
||||
"hwModelSlug": "T_DECK_PRO",
|
||||
"platformioTarget": "t-deck-pro",
|
||||
"architecture": "esp32-s3",
|
||||
"activelySupported": false,
|
||||
"supportLevel": 1,
|
||||
"displayName": "LILYGO T-Deck Pro",
|
||||
"tags": [
|
||||
"LilyGo"
|
||||
],
|
||||
"images": [
|
||||
"tdeck_pro.svg"
|
||||
],
|
||||
"requiresDfu": true,
|
||||
"hasMui": false,
|
||||
"partitionScheme": "16MB"
|
||||
}
|
||||
]
|
||||
|
|
|
|||
|
|
@ -57,13 +57,13 @@ enum SettingsNavigationState: String {
|
|||
struct NavigationState: Hashable {
|
||||
enum Tab: String, Hashable {
|
||||
case messages
|
||||
case bluetooth
|
||||
case connect
|
||||
case nodes
|
||||
case map
|
||||
case settings
|
||||
}
|
||||
|
||||
var selectedTab: Tab = .bluetooth
|
||||
var selectedTab: Tab = .connect
|
||||
var messages: MessagesNavigationState?
|
||||
var nodeListSelectedNodeNum: Int64?
|
||||
var map: MapNavigationState?
|
||||
|
|
|
|||
|
|
@ -13,30 +13,30 @@ class Router: ObservableObject {
|
|||
|
||||
init(
|
||||
navigationState: NavigationState = NavigationState(
|
||||
selectedTab: .bluetooth
|
||||
selectedTab: .connect
|
||||
)
|
||||
) {
|
||||
self.navigationState = navigationState
|
||||
|
||||
$navigationState.sink { destination in
|
||||
Logger.services.info("🛣 Routed to \(String(describing: destination), privacy: .public)")
|
||||
Logger.services.info("🛣 [App] Routed to \(destination.selectedTab.rawValue, privacy: .public)")
|
||||
}.store(in: &cancellables)
|
||||
}
|
||||
|
||||
func route(url: URL) {
|
||||
guard url.scheme == "meshtastic" else {
|
||||
Logger.services.error("🛣 Received routing URL \(url, privacy: .public) with invalid scheme. Ignoring route.")
|
||||
Logger.services.error("🛣 [App] Received routing URL \(url, privacy: .public) with invalid scheme. Ignoring route.")
|
||||
return
|
||||
}
|
||||
guard let components = URLComponents(url: url, resolvingAgainstBaseURL: false) else {
|
||||
Logger.services.error("🛣 Received routing URL \(url, privacy: .public) with invalid host path. Ignoring route.")
|
||||
Logger.services.error("🛣 [App] Received routing URL \(url, privacy: .public) with invalid host path. Ignoring route.")
|
||||
return
|
||||
}
|
||||
|
||||
if components.path == "/messages" {
|
||||
routeMessages(components)
|
||||
} else if components.path == "/bluetooth" {
|
||||
navigationState.selectedTab = .bluetooth
|
||||
} else if components.path == "/connect" {
|
||||
navigationState.selectedTab = .connect
|
||||
} else if components.path == "/nodes" {
|
||||
routeNodes(components)
|
||||
} else if components.path == "/map" {
|
||||
|
|
@ -44,7 +44,7 @@ class Router: ObservableObject {
|
|||
} else if components.path.hasPrefix("/settings") {
|
||||
routeSettings(components)
|
||||
} else {
|
||||
Logger.services.warning("Failed to route url: \(url, privacy: .public)")
|
||||
Logger.services.warning("🛣 [App] Failed to route url: \(url, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
0
Meshtastic/ShowTime.swift
Normal file
0
Meshtastic/ShowTime.swift
Normal file
|
|
@ -7,16 +7,16 @@
|
|||
import SwiftUI
|
||||
import TipKit
|
||||
|
||||
struct BluetoothConnectionTip: Tip {
|
||||
struct ConnectionTip: Tip {
|
||||
|
||||
var id: String {
|
||||
return "tip.bluetooth.connect"
|
||||
return "tip.connect"
|
||||
}
|
||||
var title: Text {
|
||||
Text("Connected Radio")
|
||||
}
|
||||
var message: Text? {
|
||||
Text("Shows information for the Lora radio connected via bluetooth. You can swipe left to disconnect the radio and long press to start the live activity.")
|
||||
Text("Shows information for the connected Lora radio. You can swipe left to disconnect the radio and long press to start the live activity.")
|
||||
}
|
||||
var image: Image? {
|
||||
Image(systemName: "flipphone")
|
||||
|
|
|
|||
|
|
@ -1,379 +0,0 @@
|
|||
//
|
||||
// Connect.swift
|
||||
// Meshtastic Apple
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 8/18/21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import CoreData
|
||||
import CoreLocation
|
||||
import CoreBluetooth
|
||||
import OSLog
|
||||
import TipKit
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
|
||||
struct Connect: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@ObservedObject var router: Router
|
||||
@State var node: NodeInfoEntity?
|
||||
@State var isUnsetRegion = false
|
||||
@State var invalidFirmwareVersion = false
|
||||
@State var liveActivityStarted = false
|
||||
@State var presentingSwitchPreferredPeripheral = false
|
||||
@State var selectedPeripherialId = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
List {
|
||||
if bleManager.isSwitchedOn {
|
||||
Section {
|
||||
if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == .connected {
|
||||
TipView(BluetoothConnectionTip(), arrowEdge: .bottom)
|
||||
.tipViewStyle(PersistentTip())
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
VStack(alignment: .center) {
|
||||
CircleText(text: node?.user?.shortName ?? "?", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90)
|
||||
.padding(.trailing, 5)
|
||||
if node?.latestDeviceMetrics != nil {
|
||||
BatteryCompact(batteryLevel: node?.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor)
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
}
|
||||
.padding(.trailing)
|
||||
VStack(alignment: .leading) {
|
||||
if node != nil {
|
||||
Text(connectedPeripheral.longName.addingVariationSelectors).font(.title2)
|
||||
}
|
||||
Text("BLE Name").font(.callout)+Text(": \(bleManager.connectedPeripheral?.peripheral.name?.addingVariationSelectors ?? "Unknown".localized)")
|
||||
.font(.callout).foregroundColor(Color.gray)
|
||||
if node != nil {
|
||||
Text("Firmware Version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "Unknown".localized)")
|
||||
.font(.callout).foregroundColor(Color.gray)
|
||||
}
|
||||
if bleManager.isSubscribed {
|
||||
Text("Subscribed").font(.callout)
|
||||
.foregroundColor(.green)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "square.stack.3d.down.forward")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.foregroundColor(.orange)
|
||||
Text("Communicating").font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.gray)
|
||||
.padding([.top])
|
||||
.swipeActions {
|
||||
if bleManager.allowDisconnect {
|
||||
Button(role: .destructive) {
|
||||
if let connectedPeripheral = bleManager.connectedPeripheral,
|
||||
connectedPeripheral.peripheral.state == .connected {
|
||||
bleManager.disconnectPeripheral(reconnect: false)
|
||||
}
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
|
||||
if node != nil {
|
||||
Label("\(String(node!.num))", systemImage: "number")
|
||||
Label("BLE RSSI \(connectedPeripheral.rssi)", systemImage: "cellularbars")
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if bleManager.isSubscribed {
|
||||
Button {
|
||||
if !liveActivityStarted {
|
||||
#if canImport(ActivityKit)
|
||||
Logger.services.info("Start live activity.")
|
||||
startNodeActivity()
|
||||
#endif
|
||||
} else {
|
||||
#if canImport(ActivityKit)
|
||||
Logger.services.info("Stop live activity.")
|
||||
endActivity()
|
||||
#endif
|
||||
}
|
||||
} label: {
|
||||
Label("Mesh Live Activity", systemImage: liveActivityStarted ? "stop" : "play")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if bleManager.allowDisconnect {
|
||||
Button(role: .destructive) {
|
||||
if let connectedPeripheral = bleManager.connectedPeripheral,
|
||||
connectedPeripheral.peripheral.state == .connected {
|
||||
bleManager.disconnectPeripheral(reconnect: false)
|
||||
}
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
if !bleManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!) {
|
||||
Logger.mesh.error("Shutdown Failed")
|
||||
}
|
||||
|
||||
} label: {
|
||||
Label("Power Off", systemImage: "power")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isUnsetRegion {
|
||||
HStack {
|
||||
NavigationLink {
|
||||
LoRaConfig(node: node)
|
||||
} label: {
|
||||
Label("Set LoRa Region", systemImage: "globe.americas.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if bleManager.isConnecting {
|
||||
HStack {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.resizable()
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 60, height: 60)
|
||||
.padding(.trailing)
|
||||
if bleManager.timeoutTimerCount == 0 {
|
||||
Text("Connecting . .")
|
||||
.font(.title2)
|
||||
.foregroundColor(.orange)
|
||||
} else {
|
||||
VStack {
|
||||
|
||||
Text("Connection Attempt \(bleManager.timeoutTimerCount) of 10")
|
||||
.font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.swipeActions {
|
||||
Button(role: .destructive) {
|
||||
bleManager.cancelPeripheralConnection()
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if bleManager.lastConnectionError.count > 0 {
|
||||
Text(bleManager.lastConnectionError).font(.callout).foregroundColor(.red)
|
||||
}
|
||||
HStack {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
.resizable()
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundColor(.red)
|
||||
.frame(width: 60, height: 60)
|
||||
.padding(.trailing)
|
||||
Text("No device connected").font(.title3)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.textCase(nil)
|
||||
|
||||
if !self.bleManager.isConnected {
|
||||
Section(header: Text("Available Radios").font(.title)) {
|
||||
ForEach(bleManager.peripherals.filter({ $0.peripheral.state == CBPeripheralState.disconnected }).sorted(by: { $0.name < $1.name })) { peripheral in
|
||||
HStack {
|
||||
if UserDefaults.preferredPeripheralId == peripheral.peripheral.identifier.uuidString {
|
||||
Image(systemName: "star.fill")
|
||||
.imageScale(.large).foregroundColor(.yellow)
|
||||
.padding(.trailing)
|
||||
} else {
|
||||
Image(systemName: "circle.fill")
|
||||
.imageScale(.large).foregroundColor(.gray)
|
||||
.padding(.trailing)
|
||||
}
|
||||
Button(action: {
|
||||
if UserDefaults.preferredPeripheralId.count > 0 && peripheral.peripheral.identifier.uuidString != UserDefaults.preferredPeripheralId {
|
||||
if let connectedPeripheral = bleManager.connectedPeripheral, connectedPeripheral.peripheral.state == CBPeripheralState.connected {
|
||||
bleManager.disconnectPeripheral()
|
||||
}
|
||||
presentingSwitchPreferredPeripheral = true
|
||||
selectedPeripherialId = peripheral.peripheral.identifier.uuidString
|
||||
} else {
|
||||
self.bleManager.connectTo(peripheral: peripheral.peripheral)
|
||||
}
|
||||
}) {
|
||||
Text(peripheral.name).font(.callout)
|
||||
}
|
||||
Spacer()
|
||||
VStack {
|
||||
SignalStrengthIndicator(signalStrength: peripheral.getSignalStrength())
|
||||
}
|
||||
}.padding([.bottom, .top])
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
|
||||
Button("Connect to new radio?", role: .destructive) {
|
||||
UserDefaults.preferredPeripheralId = selectedPeripherialId
|
||||
UserDefaults.preferredPeripheralNum = 0
|
||||
if bleManager.connectedPeripheral != nil && bleManager.connectedPeripheral.peripheral.state == CBPeripheralState.connected {
|
||||
bleManager.disconnectPeripheral()
|
||||
}
|
||||
clearCoreDataDatabase(context: context, includeRoutes: false)
|
||||
let radio = bleManager.peripherals.first(where: { $0.peripheral.identifier.uuidString == selectedPeripherialId })
|
||||
if radio != nil {
|
||||
bleManager.connectTo(peripheral: radio!.peripheral)
|
||||
}
|
||||
}
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
|
||||
} else {
|
||||
Text("Bluetooth is off")
|
||||
.foregroundColor(.red)
|
||||
.font(.title)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
if let connectedPeripheral = bleManager.connectedPeripheral {
|
||||
Button(role: .destructive, action: {
|
||||
if connectedPeripheral.peripheral.state == CBPeripheralState.connected {
|
||||
bleManager.disconnectPeripheral(reconnect: false)
|
||||
}
|
||||
}) {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding()
|
||||
}
|
||||
if bleManager.isConnecting {
|
||||
Button(role: .destructive, action: {
|
||||
bleManager.cancelPeripheralConnection()
|
||||
|
||||
}) {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding()
|
||||
}
|
||||
#endif
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.navigationTitle("Bluetooth")
|
||||
.navigationBarItems(
|
||||
leading: MeshtasticLogo(),
|
||||
trailing: ZStack {
|
||||
ConnectedDevice(
|
||||
bluetoothOn: bleManager.isSwitchedOn,
|
||||
deviceConnected: bleManager.connectedPeripheral != nil,
|
||||
name: bleManager.connectedPeripheral?.shortName ?? "?",
|
||||
mqttProxyConnected: bleManager.mqttProxyConnected,
|
||||
mqttTopic: bleManager.mqttManager.topic
|
||||
)
|
||||
}
|
||||
)
|
||||
}
|
||||
.sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) {
|
||||
InvalidVersion(minimumVersion: self.bleManager.minimumVersion, version: self.bleManager.connectedVersion)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.automatic)
|
||||
}
|
||||
.onChange(of: self.bleManager.invalidVersion) {
|
||||
invalidFirmwareVersion = self.bleManager.invalidVersion
|
||||
}
|
||||
.onChange(of: self.bleManager.isSubscribed) { _, sub in
|
||||
|
||||
if UserDefaults.preferredPeripheralId.count > 0 && sub {
|
||||
|
||||
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
|
||||
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", Int64(bleManager.connectedPeripheral?.num ?? -1))
|
||||
|
||||
do {
|
||||
node = try context.fetch(fetchNodeInfoRequest).first
|
||||
if let loRaConfig = node?.loRaConfig, loRaConfig.regionCode == RegionCodes.unset.rawValue {
|
||||
isUnsetRegion = true
|
||||
} else {
|
||||
isUnsetRegion = false
|
||||
}
|
||||
} catch {
|
||||
Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
#if canImport(ActivityKit)
|
||||
func startNodeActivity() {
|
||||
liveActivityStarted = true
|
||||
// 15 Minutes Local Stats Interval
|
||||
let timerSeconds = 900
|
||||
let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
|
||||
let mostRecent = localStats?.lastObject as? TelemetryEntity
|
||||
|
||||
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown")
|
||||
|
||||
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
|
||||
let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
|
||||
channelUtilization: mostRecent?.channelUtilization ?? 0.0,
|
||||
airtime: mostRecent?.airUtilTx ?? 0.0,
|
||||
sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0),
|
||||
receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0),
|
||||
badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0),
|
||||
dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0),
|
||||
packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0),
|
||||
packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0),
|
||||
nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0),
|
||||
totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0),
|
||||
timerRange: Date.now...future)
|
||||
|
||||
let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!)
|
||||
|
||||
do {
|
||||
let myActivity = try Activity<MeshActivityAttributes>.request(attributes: activityAttributes, content: activityContent,
|
||||
pushType: nil)
|
||||
Logger.services.info("Requested MyActivity live activity. ID: \(myActivity.id)")
|
||||
} catch {
|
||||
Logger.services.error("Error requesting live activity: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func endActivity() {
|
||||
liveActivityStarted = false
|
||||
Task {
|
||||
for activity in Activity<MeshActivityAttributes>.activities where activity.attributes.nodeNum == node?.num ?? 0 {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
func didDismissSheet() {
|
||||
bleManager.disconnectPeripheral(reconnect: false)
|
||||
}
|
||||
}
|
||||
517
Meshtastic/Views/Connect/Connect.swift
Normal file
517
Meshtastic/Views/Connect/Connect.swift
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
//
|
||||
// Connect.swift
|
||||
// Meshtastic Apple
|
||||
//
|
||||
// Copyright(c) Garth Vander Houwen 8/18/21.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import MapKit
|
||||
import CoreData
|
||||
import CoreLocation
|
||||
import CoreBluetooth
|
||||
import OSLog
|
||||
import TipKit
|
||||
#if canImport(ActivityKit)
|
||||
import ActivityKit
|
||||
#endif
|
||||
|
||||
struct Connect: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State var router: Router
|
||||
@State var node: NodeInfoEntity?
|
||||
@State var isUnsetRegion = false
|
||||
@State var invalidFirmwareVersion = false
|
||||
@State var liveActivityStarted = false
|
||||
@State var presentingSwitchPreferredPeripheral = false
|
||||
@State var selectedPeripherialId = ""
|
||||
|
||||
var body: some View {
|
||||
NavigationStack {
|
||||
VStack {
|
||||
List {
|
||||
Section {
|
||||
if let connectedDevice = accessoryManager.activeConnection?.device,
|
||||
accessoryManager.isConnected || accessoryManager.isConnecting {
|
||||
TipView(ConnectionTip(), arrowEdge: .bottom)
|
||||
.tipViewStyle(PersistentTip())
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
VStack(alignment: .center) {
|
||||
CircleText(text: node?.user?.shortName?.addingVariationSelectors ?? "?", color: Color(UIColor(hex: UInt32(node?.num ?? 0))), circleSize: 90)
|
||||
.padding(.trailing, 5)
|
||||
if node?.latestDeviceMetrics != nil {
|
||||
BatteryCompact(batteryLevel: node?.latestDeviceMetrics?.batteryLevel ?? 0, font: .caption, iconFont: .callout, color: .accentColor)
|
||||
.padding(.trailing, 5)
|
||||
}
|
||||
}
|
||||
.padding(.trailing)
|
||||
VStack(alignment: .leading) {
|
||||
if node != nil {
|
||||
Text(connectedDevice.longName?.addingVariationSelectors ?? "Unknown".localized).font(.title2)
|
||||
}
|
||||
Text("Connection Name").font(.callout)+Text(": \(connectedDevice.name.addingVariationSelectors)")
|
||||
.font(.callout).foregroundColor(Color.gray)
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
TransportIcon(transportType: connectedDevice.transportType)
|
||||
if connectedDevice.transportType == .ble {
|
||||
connectedDevice.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0, width: 5, height: 20) }
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.padding(0)
|
||||
if node != nil {
|
||||
Text("Firmware Version").font(.callout)+Text(": \(node?.metadata?.firmwareVersion ?? "Unknown".localized)")
|
||||
.font(.callout).foregroundColor(Color.gray)
|
||||
}
|
||||
switch accessoryManager.state {
|
||||
case .subscribed:
|
||||
Text("Subscribed").font(.callout)
|
||||
.foregroundColor(.green)
|
||||
case .retrievingDatabase(let nodeCount):
|
||||
HStack {
|
||||
Image(systemName: "square.stack.3d.down.forward")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.foregroundColor(.teal)
|
||||
if let expectedNodeDBSize = accessoryManager.expectedNodeDBSize {
|
||||
if UIDevice.current.userInterfaceIdiom == .phone {
|
||||
VStack(alignment: .leading, spacing: 2.0) {
|
||||
Text("Retrieving nodes").font(.callout)
|
||||
.foregroundColor(.teal)
|
||||
ProgressView(value: Double(nodeCount), total: Double(expectedNodeDBSize))
|
||||
}
|
||||
} else {
|
||||
// iPad/Mac with more space, show progress bar AFTER the label
|
||||
HStack {
|
||||
Text("Retrieving nodes").font(.callout)
|
||||
.foregroundColor(.teal)
|
||||
ProgressView(value: Double(nodeCount), total: Double(expectedNodeDBSize))
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
Text("Retrieving nodes \(nodeCount)").font(.callout)
|
||||
.foregroundColor(.teal)
|
||||
}
|
||||
}
|
||||
case .communicating:
|
||||
HStack {
|
||||
Image(systemName: "square.stack.3d.down.forward")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.foregroundColor(.orange)
|
||||
Text("Communicating").font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
case .retrying(let attempt):
|
||||
HStack {
|
||||
Image(systemName: "square.stack.3d.down.forward")
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.foregroundColor(.orange)
|
||||
Text("Retrying (attempt \(attempt))").font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(Color.gray)
|
||||
.padding([.top])
|
||||
.swipeActions {
|
||||
if accessoryManager.allowDisconnect {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
try await accessoryManager.disconnect()
|
||||
}
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}.disabled(!accessoryManager.allowDisconnect)
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
|
||||
if node != nil {
|
||||
Label("\(String(node!.num))", systemImage: "number")
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
if accessoryManager.state == .subscribed {
|
||||
Button {
|
||||
if !liveActivityStarted {
|
||||
#if canImport(ActivityKit)
|
||||
Logger.services.info("Start live activity.")
|
||||
startNodeActivity()
|
||||
#endif
|
||||
} else {
|
||||
#if canImport(ActivityKit)
|
||||
Logger.services.info("Stop live activity.")
|
||||
endActivity()
|
||||
#endif
|
||||
}
|
||||
} label: {
|
||||
Label("Mesh Live Activity", systemImage: liveActivityStarted ? "stop" : "play")
|
||||
}
|
||||
}
|
||||
#endif
|
||||
if accessoryManager.allowDisconnect {
|
||||
Button(role: .destructive) {
|
||||
if accessoryManager.allowDisconnect {
|
||||
Task {
|
||||
try await accessoryManager.disconnect()
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendShutdown(fromUser: node!.user!, toUser: node!.user!)
|
||||
} catch {
|
||||
Logger.mesh.error("Shutdown Failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
} label: {
|
||||
Label("Power Off", systemImage: "power")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if isUnsetRegion {
|
||||
HStack {
|
||||
NavigationLink {
|
||||
LoRaConfig(node: node)
|
||||
} label: {
|
||||
Label("Set LoRa Region", systemImage: "globe.americas.fill")
|
||||
.foregroundColor(.red)
|
||||
.font(.title)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if accessoryManager.isConnecting {
|
||||
HStack {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right")
|
||||
.resizable()
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundColor(.orange)
|
||||
.frame(width: 60, height: 60)
|
||||
.padding(.trailing)
|
||||
switch accessoryManager.state {
|
||||
case .connecting, .communicating:
|
||||
Text("Connecting . .")
|
||||
.font(.title2)
|
||||
.foregroundColor(.orange)
|
||||
case .retrievingDatabase:
|
||||
Text("Retreiving nodes . .")
|
||||
.font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
case .retrying(let attempt):
|
||||
Text("Connection Attempt \(attempt) of 10")
|
||||
.font(.callout)
|
||||
.foregroundColor(.orange)
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.swipeActions {
|
||||
if accessoryManager.allowDisconnect {
|
||||
Button(role: .destructive) {
|
||||
Task {
|
||||
try await accessoryManager.disconnect()
|
||||
}
|
||||
} label: {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}.disabled(!accessoryManager.allowDisconnect)
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
|
||||
if let lastError = accessoryManager.lastConnectionError as? Error {
|
||||
Text(lastError.localizedDescription).font(.callout).foregroundColor(.red)
|
||||
}
|
||||
HStack {
|
||||
Image("custom.link.slash")
|
||||
.resizable()
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.foregroundColor(.red)
|
||||
.frame(width: 60, height: 60)
|
||||
.padding(.trailing)
|
||||
Text("No device connected").font(.title3)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
}
|
||||
.textCase(nil)
|
||||
|
||||
if !(accessoryManager.isConnected || accessoryManager .isConnecting) {
|
||||
Section(header: HStack {
|
||||
Text("Available Radios").font(.title)
|
||||
Spacer()
|
||||
ManualConnectionMenu()
|
||||
}) {
|
||||
ForEach(accessoryManager.devices.sorted(by: { $0.name < $1.name })) { device in
|
||||
HStack {
|
||||
if UserDefaults.preferredPeripheralId == device.id.uuidString {
|
||||
Image(systemName: "star.fill")
|
||||
.imageScale(.large).foregroundColor(.yellow)
|
||||
.padding(.trailing)
|
||||
} else {
|
||||
Image(systemName: "circle.fill")
|
||||
.imageScale(.large).foregroundColor(.gray)
|
||||
.padding(.trailing)
|
||||
}
|
||||
VStack(alignment: .leading) {
|
||||
Button(action: {
|
||||
if UserDefaults.preferredPeripheralId.count > 0 && device.id.uuidString != UserDefaults.preferredPeripheralId {
|
||||
if accessoryManager.allowDisconnect {
|
||||
Task { try await accessoryManager.disconnect() }
|
||||
}
|
||||
presentingSwitchPreferredPeripheral = true
|
||||
selectedPeripherialId = device.id.uuidString
|
||||
} else {
|
||||
Task {
|
||||
try? await accessoryManager.connect(to: device)
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Text(device.name).font(.callout)
|
||||
}
|
||||
// Show transport type
|
||||
TransportIcon(transportType: device.transportType)
|
||||
}
|
||||
Spacer()
|
||||
VStack {
|
||||
device.getSignalStrength().map { SignalStrengthIndicator(signalStrength: $0) }
|
||||
}
|
||||
}.padding([.bottom, .top])
|
||||
}
|
||||
}
|
||||
.confirmationDialog("Connecting to a new radio will clear all app data on the phone.", isPresented: $presentingSwitchPreferredPeripheral, titleVisibility: .visible) {
|
||||
Button("Connect to new radio?", role: .destructive) {
|
||||
UserDefaults.preferredPeripheralId = selectedPeripherialId
|
||||
UserDefaults.preferredPeripheralNum = 0
|
||||
if accessoryManager.allowDisconnect {
|
||||
Task { try await accessoryManager.disconnect() }
|
||||
}
|
||||
clearCoreDataDatabase(context: context, includeRoutes: false)
|
||||
if let radio = accessoryManager.devices.first(where: { $0.id.uuidString == selectedPeripherialId }) {
|
||||
Task {
|
||||
try await accessoryManager.connect(to: radio)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.textCase(nil)
|
||||
}
|
||||
}
|
||||
|
||||
HStack(alignment: .center) {
|
||||
Spacer()
|
||||
#if targetEnvironment(macCatalyst)
|
||||
// TODO: should this be allowDisconnect?
|
||||
if accessoryManager.allowDisconnect {
|
||||
Button(role: .destructive, action: {
|
||||
if accessoryManager.allowDisconnect {
|
||||
Task {
|
||||
try await accessoryManager.disconnect()
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Label("Disconnect", systemImage: "antenna.radiowaves.left.and.right.slash")
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.large)
|
||||
.padding()
|
||||
}
|
||||
#endif
|
||||
Spacer()
|
||||
}
|
||||
.padding(.bottom, 10)
|
||||
}
|
||||
.navigationTitle("Connect")
|
||||
.navigationBarItems(
|
||||
leading: MeshtasticLogo(),
|
||||
trailing: ZStack {
|
||||
ConnectedDevice(
|
||||
deviceConnected: accessoryManager.isConnected,
|
||||
name: accessoryManager.activeConnection?.device.shortName ?? "?",
|
||||
mqttProxyConnected: accessoryManager.mqttProxyConnected,
|
||||
mqttTopic: accessoryManager.mqttManager.topic
|
||||
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
}
|
||||
// TODO: REMOVING VERSION STUFF?
|
||||
// .sheet(isPresented: $invalidFirmwareVersion, onDismiss: didDismissSheet) {
|
||||
// InvalidVersion(minimumVersion: accessoryManager.minimumVersion, version: accessoryManager.activeConnection?.device.firmwareVersion ?? "?.?.?")
|
||||
// .presentationDetents([.large])
|
||||
// .presentationDragIndicator(.automatic)
|
||||
// }
|
||||
// .onChange(of: accessoryManager) {
|
||||
// invalidFirmwareVersion = self.bleManager.invalidVersion
|
||||
// }
|
||||
.onChange(of: self.accessoryManager.state) { _, state in
|
||||
|
||||
if let deviceNum = accessoryManager.activeDeviceNum, UserDefaults.preferredPeripheralId.count > 0 && state == .subscribed {
|
||||
|
||||
let fetchNodeInfoRequest = NodeInfoEntity.fetchRequest()
|
||||
fetchNodeInfoRequest.predicate = NSPredicate(format: "num == %lld", deviceNum)
|
||||
|
||||
do {
|
||||
node = try context.fetch(fetchNodeInfoRequest).first
|
||||
if let loRaConfig = node?.loRaConfig, loRaConfig.regionCode == RegionCodes.unset.rawValue {
|
||||
isUnsetRegion = true
|
||||
} else {
|
||||
isUnsetRegion = false
|
||||
}
|
||||
} catch {
|
||||
Logger.data.error("💥 Error fetching node info: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
#if !targetEnvironment(macCatalyst)
|
||||
#if canImport(ActivityKit)
|
||||
func startNodeActivity() {
|
||||
liveActivityStarted = true
|
||||
// 15 Minutes Local Stats Interval
|
||||
let timerSeconds = 900
|
||||
let localStats = node?.telemetries?.filtered(using: NSPredicate(format: "metricsType == 4"))
|
||||
let mostRecent = localStats?.lastObject as? TelemetryEntity
|
||||
|
||||
let activityAttributes = MeshActivityAttributes(nodeNum: Int(node?.num ?? 0), name: node?.user?.longName?.addingVariationSelectors ?? "unknown")
|
||||
|
||||
let future = Date(timeIntervalSinceNow: Double(timerSeconds))
|
||||
let initialContentState = MeshActivityAttributes.ContentState(uptimeSeconds: UInt32(mostRecent?.uptimeSeconds ?? 0),
|
||||
channelUtilization: mostRecent?.channelUtilization ?? 0.0,
|
||||
airtime: mostRecent?.airUtilTx ?? 0.0,
|
||||
sentPackets: UInt32(mostRecent?.numPacketsTx ?? 0),
|
||||
receivedPackets: UInt32(mostRecent?.numPacketsRx ?? 0),
|
||||
badReceivedPackets: UInt32(mostRecent?.numPacketsRxBad ?? 0),
|
||||
dupeReceivedPackets: UInt32(mostRecent?.numRxDupe ?? 0),
|
||||
packetsSentRelay: UInt32(mostRecent?.numTxRelay ?? 0),
|
||||
packetsCanceledRelay: UInt32(mostRecent?.numTxRelayCanceled ?? 0),
|
||||
nodesOnline: UInt32(mostRecent?.numOnlineNodes ?? 0),
|
||||
totalNodes: UInt32(mostRecent?.numTotalNodes ?? 0),
|
||||
timerRange: Date.now...future)
|
||||
|
||||
let activityContent = ActivityContent(state: initialContentState, staleDate: Calendar.current.date(byAdding: .minute, value: 15, to: Date())!)
|
||||
|
||||
do {
|
||||
let myActivity = try Activity<MeshActivityAttributes>.request(attributes: activityAttributes, content: activityContent,
|
||||
pushType: nil)
|
||||
Logger.services.info("Requested MyActivity live activity. ID: \(myActivity.id)")
|
||||
} catch {
|
||||
Logger.services.error("Error requesting live activity: \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
func endActivity() {
|
||||
liveActivityStarted = false
|
||||
Task {
|
||||
for activity in Activity<MeshActivityAttributes>.activities where activity.attributes.nodeNum == node?.num ?? 0 {
|
||||
await activity.end(nil, dismissalPolicy: .immediate)
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
#endif
|
||||
func didDismissSheet() {
|
||||
// bleManager.disconnectPeripheral(reconnect: false)
|
||||
Task {
|
||||
try await accessoryManager.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct TransportIcon: View {
|
||||
var transportType: TransportType
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
var body: some View {
|
||||
let transport = accessoryManager.transportForType(transportType)
|
||||
return HStack(spacing: 3.0) {
|
||||
if let icon = transport?.type.icon {
|
||||
icon
|
||||
.font(.title2)
|
||||
.foregroundColor(transport?.type == .ble ? Color.accentColor : Color.primary)
|
||||
} else {
|
||||
Image(systemName: "questionmark")
|
||||
.font(.title2)
|
||||
}
|
||||
Text(transport?.type.rawValue ?? "Unknown".localized)
|
||||
.font(.title3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ManualConnectionMenu: View {
|
||||
private struct IterableTransport: Identifiable {
|
||||
let id: UUID
|
||||
let icon: Image
|
||||
let title: String
|
||||
let transport: any Transport
|
||||
}
|
||||
|
||||
private var transports: [IterableTransport]
|
||||
|
||||
init() {
|
||||
self.transports = AccessoryManager.shared.transports.filter { $0.supportsManualConnection}.map { transport in
|
||||
IterableTransport(id: UUID(), icon: transport.type.icon, title: transport.type.rawValue, transport: transport)
|
||||
}
|
||||
}
|
||||
|
||||
@State private var selectedTransport: IterableTransport?
|
||||
@State private var showAlert: Bool = false
|
||||
@State private var connectionString = ""
|
||||
|
||||
var body: some View {
|
||||
Menu {
|
||||
ForEach(transports) { transport in
|
||||
Button {
|
||||
self.selectedTransport = transport
|
||||
self.showAlert = true
|
||||
} label: {
|
||||
Label(title: { Text(transport.title)}, icon: { transport.icon })
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Manual", systemImage: "plus")
|
||||
}.alert("Manual connection string", isPresented: $showAlert, presenting: selectedTransport) { selectedTransport in
|
||||
// This continues to be quick and dirty. A better system is needed.
|
||||
TextField("Enter hostname[:port]", text: $connectionString)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
.onChange(of: connectionString) { _, newValue in
|
||||
// Filter to only allow valid characters for hostname/IP:port
|
||||
let allowedCharacters = CharacterSet(charactersIn: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-:")
|
||||
let filtered = String(newValue.unicodeScalars.filter { allowedCharacters.contains($0) })
|
||||
if filtered != newValue {
|
||||
connectionString = filtered
|
||||
}
|
||||
}
|
||||
|
||||
Button("OK", action: {
|
||||
if !connectionString.isEmpty {
|
||||
Task {
|
||||
try await selectedTransport.transport.manuallyConnect(withConnectionString: connectionString)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,8 +6,8 @@ import SwiftUI
|
|||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var appState: AppState
|
||||
|
||||
@ObservedObject var router: Router
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State var router: Router
|
||||
@State var isShowingDeviceOnboardingFlow: Bool = false
|
||||
|
||||
init(appState: AppState, router: Router) {
|
||||
|
|
@ -33,9 +33,9 @@ struct ContentView: View {
|
|||
router: appState.router
|
||||
)
|
||||
.tabItem {
|
||||
Label("Bluetooth", systemImage: "antenna.radiowaves.left.and.right")
|
||||
Label("Connect", systemImage: "link")
|
||||
}
|
||||
.tag(NavigationState.Tab.bluetooth)
|
||||
.tag(NavigationState.Tab.connect)
|
||||
|
||||
NodeList(
|
||||
router: appState.router
|
||||
|
|
@ -63,6 +63,7 @@ struct ContentView: View {
|
|||
isPresented: $isShowingDeviceOnboardingFlow,
|
||||
onDismiss: {
|
||||
UserDefaults.firstLaunch = false
|
||||
accessoryManager.startDiscovery()
|
||||
}, content: {
|
||||
DeviceOnboarding()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -45,7 +45,8 @@ struct SignalStrengthIndicator: View {
|
|||
}
|
||||
|
||||
let signalStrength: BLESignalStrength
|
||||
|
||||
var width: CGFloat = 8
|
||||
var height: CGFloat = 40
|
||||
var body: some View {
|
||||
Group {
|
||||
HStack {
|
||||
|
|
@ -53,7 +54,7 @@ struct SignalStrengthIndicator: View {
|
|||
RoundedRectangle(cornerRadius: 3)
|
||||
.divided(amount: (CGFloat(bar) + 1) / CGFloat(3))
|
||||
.fill(getColor().opacity(bar <= signalStrength.rawValue ? 1 : 0.3))
|
||||
.frame(width: 8, height: 40)
|
||||
.frame(width: width, height: height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ struct BatteryCompact: View {
|
|||
return "Charging".localized
|
||||
} else {
|
||||
// Normal battery level
|
||||
return String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(level))
|
||||
return String(format: NSLocalizedString("Battery Level %d", comment: "VoiceOver value for battery level"), Int(level))
|
||||
}
|
||||
} ?? "Unknown")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -53,7 +53,7 @@ struct BatteryGauge: View {
|
|||
}
|
||||
}
|
||||
.accessibilityLabel(NSLocalizedString("Battery Level", comment: "VoiceOver label for battery gauge"))
|
||||
.accessibilityValue(String(format: NSLocalizedString("Battery Level %", comment: "VoiceOver value for battery level"), Int(batteryLevel)))
|
||||
.accessibilityValue(String(format: NSLocalizedString("Battery Level %d", comment: "VoiceOver value for battery level"), Int(batteryLevel)))
|
||||
.tint(gradient)
|
||||
.gaugeStyle(.accessoryCircular)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,83 +1,86 @@
|
|||
/*
|
||||
Abstract:
|
||||
A view that draws the indicator used in the upper right corner for views using BLE
|
||||
*/
|
||||
Abstract:
|
||||
A view draws the indicator used in the upper right corner for views using BLE
|
||||
*/
|
||||
|
||||
import SwiftUI
|
||||
|
||||
struct ConnectedDevice: View {
|
||||
var bluetoothOn: Bool
|
||||
var deviceConnected: Bool
|
||||
var name: String
|
||||
var mqttProxyConnected: Bool = false
|
||||
var mqttUplinkEnabled: Bool = false
|
||||
var mqttDownlinkEnabled: Bool = false
|
||||
var mqttTopic: String = ""
|
||||
var phoneOnly: Bool = false
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
var deviceConnected: Bool
|
||||
var name: String
|
||||
var mqttProxyConnected: Bool = false
|
||||
var mqttUplinkEnabled: Bool = false
|
||||
var mqttDownlinkEnabled: Bool = false
|
||||
var mqttTopic: String = ""
|
||||
var phoneOnly: Bool = false
|
||||
var showActivityLights: Bool
|
||||
|
||||
var body: some View {
|
||||
init(deviceConnected: Bool, name: String, mqttProxyConnected: Bool = false, mqttUplinkEnabled: Bool = false, mqttDownlinkEnabled: Bool = false, mqttTopic: String = "", phoneOnly: Bool = false, showActivityLights: Bool = true) {
|
||||
self.deviceConnected = deviceConnected
|
||||
self.name = name
|
||||
self.mqttProxyConnected = mqttProxyConnected
|
||||
self.mqttUplinkEnabled = mqttUplinkEnabled
|
||||
self.mqttDownlinkEnabled = mqttDownlinkEnabled
|
||||
self.mqttTopic = mqttTopic
|
||||
self.phoneOnly = phoneOnly
|
||||
self.showActivityLights = showActivityLights
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
|
||||
if bluetoothOn {
|
||||
if deviceConnected {
|
||||
// Create an HStack for connected state with proper accessibility
|
||||
HStack {
|
||||
if mqttUplinkEnabled || mqttDownlinkEnabled {
|
||||
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Image(systemName: "antenna.radiowaves.left.and.right.circle.fill")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.green)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.accessibilityHidden(true)
|
||||
Text(name.addingVariationSelectors)
|
||||
.font(name.isEmoji() ? .title : .callout)
|
||||
.foregroundColor(.gray)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver())
|
||||
} else {
|
||||
// Create a container for disconnected state
|
||||
HStack {
|
||||
Image(systemName: "antenna.radiowaves.left.and.right.slash")
|
||||
.imageScale(.medium)
|
||||
.foregroundColor(.red)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("No Bluetooth device connected".localized)
|
||||
}
|
||||
} else {
|
||||
// Create a container for Bluetooth off state
|
||||
if showActivityLights {
|
||||
RXTXIndicatorWidget()
|
||||
}
|
||||
if (phoneOnly && UIDevice.current.userInterfaceIdiom == .phone) || !phoneOnly {
|
||||
if deviceConnected {
|
||||
// Create an HStack for connected state with proper accessibility
|
||||
HStack {
|
||||
Text("Bluetooth is off".localized)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.red)
|
||||
if mqttUplinkEnabled || mqttDownlinkEnabled {
|
||||
MQTTIcon(connected: mqttProxyConnected, uplink: mqttUplinkEnabled, downlink: mqttDownlinkEnabled, topic: mqttTopic)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
Image(systemName: "link.circle.fill")
|
||||
.imageScale(.large)
|
||||
.foregroundColor(.green)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.accessibilityHidden(true)
|
||||
Text(name.addingVariationSelectors)
|
||||
.font(name.isEmoji() ? .title : .callout)
|
||||
.foregroundColor(.gray)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("Bluetooth is off".localized)
|
||||
.accessibilityLabel("Connected to Bluetooth device".localized + ", " + name.formatNodeNameForVoiceOver())
|
||||
} else {
|
||||
// Create a container for disconnected state
|
||||
HStack {
|
||||
Image("custom.link.slash")
|
||||
.imageScale(.medium)
|
||||
.foregroundColor(.red)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
.accessibilityHidden(true)
|
||||
}
|
||||
.accessibilityElement(children: .ignore)
|
||||
.accessibilityLabel("No Bluetooth device connected".localized)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.iOS26Modifier { $0.padding(.horizontal, 5.0) }
|
||||
}
|
||||
}
|
||||
|
||||
struct ConnectedDevice_Previews: PreviewProvider {
|
||||
static var previews: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true)
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#")
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
|
||||
ConnectedDevice(bluetoothOn: true, deviceConnected: false, name: "MEMO", mqttProxyConnected: false)
|
||||
}.previewLayout(.fixed(width: 150, height: 275))
|
||||
}
|
||||
static var previews: some View {
|
||||
VStack(alignment: .trailing) {
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: true)
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: true, mqttTopic: "msh/US/2/e/#")
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: true, mqttDownlinkEnabled: false)
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: false, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true, mqttUplinkEnabled: false, mqttDownlinkEnabled: true)
|
||||
ConnectedDevice(deviceConnected: true, name: "MEMO", mqttProxyConnected: true)
|
||||
ConnectedDevice(deviceConnected: false, name: "MEMO", mqttProxyConnected: false)
|
||||
}.previewLayout(.fixed(width: 150, height: 275))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,11 +25,10 @@ struct MQTTIcon: View {
|
|||
.imageScale(.large)
|
||||
.foregroundColor(connected ? .green : .secondary)
|
||||
.symbolRenderingMode(.hierarchical)
|
||||
}.popover(isPresented: self.$isPopoverOpen, arrowEdge: .bottom, content: {
|
||||
}.popover(isPresented: self.$isPopoverOpen, content: {
|
||||
VStack(spacing: 0.5) {
|
||||
Text("Topic: \(topic)".localized)
|
||||
.padding(20)
|
||||
Button("Close", action: { self.isPopoverOpen = false }).padding([.bottom], 20)
|
||||
}
|
||||
.presentationCompactAdaptation(.popover)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -13,20 +13,36 @@ struct MeshtasticLogo: View {
|
|||
var body: some View {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
VStack {
|
||||
Image("logo-white")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
.scaledToFit()
|
||||
if #available(iOS 26.0, macOS 26.0, *) {
|
||||
Image(colorScheme == .dark ? "logo-white" : "logo-black")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
.scaledToFit()
|
||||
} else {
|
||||
Image("logo-white")
|
||||
.resizable()
|
||||
.foregroundColor(.accentColor)
|
||||
.scaledToFit()
|
||||
}
|
||||
|
||||
}
|
||||
.padding(.bottom, 5)
|
||||
.padding(.top, 5)
|
||||
#else
|
||||
if #available(iOS 26.0, macOS 26.0, *) {
|
||||
VStack {
|
||||
Image(colorScheme == .dark ? "logo-white" : "logo-black")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
} else {
|
||||
VStack {
|
||||
Image(colorScheme == .dark ? "logo-white" : "logo-black")
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
}
|
||||
.padding(.bottom, 5)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
|
|
|||
112
Meshtastic/Views/Helpers/RXTXIndicatorView.swift
Normal file
112
Meshtastic/Views/Helpers/RXTXIndicatorView.swift
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
//
|
||||
// RXTXIndicatorView.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by jake on 8/5/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import OSLog
|
||||
|
||||
struct RXTXIndicatorWidget: View {
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State private var isPopoverOpen = false
|
||||
|
||||
let fontSize: CGFloat = 7.0
|
||||
var body: some View {
|
||||
Button( action: {
|
||||
if !isPopoverOpen && accessoryManager.isConnected {
|
||||
Task {
|
||||
// TODO: replace with a heartbeat when the heartbeat works
|
||||
try await Task.sleep(for: .seconds(0.5)) // little delay for user affordance
|
||||
if accessoryManager.checkIsVersionSupported(forVersion: "2.7.4") {
|
||||
Logger.transport.debug("[RXTXIndicator] sending heartbeat (2.7.4+)")
|
||||
try await accessoryManager.sendHeartbeat()
|
||||
} else {
|
||||
Logger.transport.debug("[RXTXIndicator] sending metadata request (pre 2.7.4 does not support heartbeat nonce)")
|
||||
_ = try await accessoryManager.requestDeviceMetadata()
|
||||
}
|
||||
}
|
||||
}
|
||||
self.isPopoverOpen.toggle()
|
||||
}) {
|
||||
VStack(spacing: 3.0) {
|
||||
HStack(spacing: 2.0) {
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: fontSize))
|
||||
LEDIndicator(flash: $accessoryManager.packetsSent, color: .green)
|
||||
}.frame(maxHeight: fontSize)
|
||||
HStack(spacing: 2.0) {
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: fontSize))
|
||||
LEDIndicator(flash: $accessoryManager.packetsReceived, color: .red)
|
||||
}.frame(maxHeight: fontSize)
|
||||
}
|
||||
.contentShape(Rectangle()) // Make sure the whole thing is tappable
|
||||
.popover(isPresented: self.$isPopoverOpen,
|
||||
attachmentAnchor: .rect(.bounds),
|
||||
arrowEdge: .top) {
|
||||
Button(action: {
|
||||
self.isPopoverOpen = false
|
||||
}) {
|
||||
VStack(spacing: 0.5) {
|
||||
Text("Packet Count")
|
||||
.font(.caption)
|
||||
.bold()
|
||||
.padding(2.0)
|
||||
Divider()
|
||||
VStack(alignment: .leading) {
|
||||
HStack(spacing: 3.0) {
|
||||
HStack(spacing: 2.0) {
|
||||
LEDIndicator(flash: $accessoryManager.packetsSent, color: .green)
|
||||
.frame(maxHeight: fontSize)
|
||||
Image(systemName: "arrow.up")
|
||||
.font(.system(size: fontSize))
|
||||
}
|
||||
Text("To Radio (TX): \(accessoryManager.packetsSent)")
|
||||
.font(.caption2)
|
||||
Spacer()
|
||||
}
|
||||
HStack(spacing: 3.0) {
|
||||
HStack(spacing: 2.0) {
|
||||
LEDIndicator(flash: $accessoryManager.packetsReceived, color: .red)
|
||||
.frame(maxHeight: fontSize)
|
||||
Image(systemName: "arrow.down")
|
||||
.font(.system(size: fontSize))
|
||||
}
|
||||
Text("From Radio (RX): \(accessoryManager.packetsReceived)")
|
||||
.font(.caption2)
|
||||
Spacer()
|
||||
}
|
||||
}.padding(2.0)
|
||||
}.padding(10)
|
||||
.contentShape(Rectangle()) // Make sure the whole thing is tappable
|
||||
}.buttonStyle(.plain)
|
||||
.presentationCompactAdaptation(.popover)
|
||||
}
|
||||
}.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
|
||||
struct LEDIndicator: View {
|
||||
@Environment(\.colorScheme) var colorScheme
|
||||
@Binding var flash: Int
|
||||
let color: Color
|
||||
|
||||
@State private var brightness: Double = 0.0
|
||||
|
||||
var body: some View {
|
||||
Circle()
|
||||
.foregroundColor(color.opacity(brightness))
|
||||
.overlay(
|
||||
Circle()
|
||||
.stroke(colorScheme == .light ? Color.black : Color.white, lineWidth: 0.5)
|
||||
).onChange(of: flash) { _, _ in
|
||||
brightness = 1.0
|
||||
withAnimation(.easeOut(duration: 0.3)) {
|
||||
brightness = 0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
33
Meshtastic/Views/Helpers/View+iOS26Modifier.swift
Normal file
33
Meshtastic/Views/Helpers/View+iOS26Modifier.swift
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
//
|
||||
// View+iOS26Modifier.swift
|
||||
// Meshtastic
|
||||
//
|
||||
// Created by Jake Bordens on 7/29/25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func iOS26Modifier(
|
||||
_ contentBuilder: (@escaping (Self) -> some View)
|
||||
) -> some View {
|
||||
if #available(iOS 26.0, macOS 26.0, *) {
|
||||
contentBuilder(self)
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func olderThaniOS26Modifier(
|
||||
_ contentBuilder: (@escaping (Self) -> some View)
|
||||
) -> some View {
|
||||
if #available(iOS 26.0, macOS 26.0, *) {
|
||||
self
|
||||
} else {
|
||||
contentBuilder(self)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -12,7 +12,7 @@ import OSLog
|
|||
struct ChannelList: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
@Binding
|
||||
var node: NodeInfoEntity?
|
||||
|
|
@ -131,14 +131,22 @@ struct ChannelList: View {
|
|||
Button {
|
||||
channel.mute.toggle()
|
||||
do {
|
||||
let adminMessageId = bleManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!)
|
||||
if adminMessageId > 0 {
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
Task {
|
||||
do {
|
||||
_ = try await accessoryManager.saveChannel(channel: channel.protoBuf, fromUser: node.user!, toUser: node.user!)
|
||||
Task { @MainActor in
|
||||
do {
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("💥 Save Channel Mute Error")
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.mesh.error("Unable to save channel")
|
||||
}
|
||||
}
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("💥 Save Channel Mute Error")
|
||||
}
|
||||
} label: {
|
||||
Label(channel.mute ? "Show Alerts" : "Hide Alerts", systemImage: channel.mute ? "bell" : "bell.slash")
|
||||
|
|
@ -160,7 +168,7 @@ struct ChannelList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
.padding([.top, .bottom])
|
||||
.olderThaniOS26Modifier { $0.padding([.top, .bottom]) }
|
||||
.listStyle(.plain)
|
||||
.navigationTitle("Channels")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import SwiftUI
|
|||
struct ChannelMessageList: View {
|
||||
@EnvironmentObject var appState: AppState
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
// Keyboard State
|
||||
@FocusState var messageFieldFocused: Bool
|
||||
@ObservedObject var myInfo: MyInfoEntity
|
||||
|
|
@ -25,7 +25,25 @@ struct ChannelMessageList: View {
|
|||
@State private var hasReachedBottom = false
|
||||
@State private var gotFirstUnreadMessage: Bool = false
|
||||
|
||||
@State private var messageToHighlight: Int64 = 0
|
||||
@State private var messageToHighlight: Int64 = 0
|
||||
|
||||
@FetchRequest private var allPrivateMessages: FetchedResults<MessageEntity>
|
||||
|
||||
init(myInfo: MyInfoEntity, channel: ChannelEntity) {
|
||||
self.myInfo = myInfo
|
||||
self.channel = channel
|
||||
|
||||
// Configure fetch request here
|
||||
let request: NSFetchRequest<MessageEntity> = MessageEntity.fetchRequest()
|
||||
request.sortDescriptors = [
|
||||
NSSortDescriptor(keyPath: \MessageEntity.messageTimestamp, ascending: true)
|
||||
]
|
||||
request.predicate = NSPredicate(
|
||||
format: "channel == %ld AND toUser == nil AND isEmoji == false",
|
||||
channel.index
|
||||
)
|
||||
_allPrivateMessages = FetchRequest(fetchRequest: request)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
|
|
@ -33,9 +51,10 @@ struct ChannelMessageList: View {
|
|||
ZStack(alignment: .bottomTrailing) {
|
||||
ScrollView {
|
||||
LazyVStack {
|
||||
ForEach(Array(channel.allPrivateMessages.enumerated()), id: \.element.id) { index, message in
|
||||
ForEach(allPrivateMessages) { message in
|
||||
// Get the previous message, if it exists
|
||||
let previousMessage = index > 0 ? channel.allPrivateMessages[index - 1] : nil
|
||||
let thisMessageIndex = allPrivateMessages.firstIndex(of: message) ?? 0
|
||||
let previousMessage = thisMessageIndex > 0 ? allPrivateMessages[thisMessageIndex - 1] : nil
|
||||
let currentUser: Bool = (Int64(preferredPeripheralNum) == message.fromUser?.num ? true : false)
|
||||
if message.displayTimestamp(aboveMessage: previousMessage) {
|
||||
Text(message.timestamp.formatted(date: .abbreviated, time: .shortened))
|
||||
|
|
@ -43,7 +62,7 @@ struct ChannelMessageList: View {
|
|||
.foregroundColor(.gray)
|
||||
}
|
||||
if message.replyID > 0 {
|
||||
let messageReply = channel.allPrivateMessages.first(where: { $0.messageId == message.replyID })
|
||||
let messageReply = allPrivateMessages.first(where: { $0.messageId == message.replyID })
|
||||
HStack {
|
||||
Button {
|
||||
if let messageNum = messageReply?.messageId {
|
||||
|
|
@ -130,7 +149,7 @@ struct ChannelMessageList: View {
|
|||
}
|
||||
}
|
||||
.padding(.bottom)
|
||||
.id(channel.allPrivateMessages.firstIndex(of: message))
|
||||
.id(allPrivateMessages.firstIndex(of: message))
|
||||
|
||||
if !currentUser {
|
||||
Spacer(minLength: 50)
|
||||
|
|
@ -149,7 +168,7 @@ struct ChannelMessageList: View {
|
|||
if !message.read {
|
||||
message.read = true
|
||||
do {
|
||||
for unreadMessage in channel.allPrivateMessages.filter({ !$0.read }) {
|
||||
for unreadMessage in allPrivateMessages.filter({ !$0.read }) {
|
||||
unreadMessage.read = true
|
||||
}
|
||||
try context.save()
|
||||
|
|
@ -161,7 +180,7 @@ struct ChannelMessageList: View {
|
|||
}
|
||||
}
|
||||
// Check if we've reached the bottom message
|
||||
if message.messageId == channel.allPrivateMessages.last?.messageId {
|
||||
if message.messageId == allPrivateMessages.last?.messageId {
|
||||
hasReachedBottom = true
|
||||
showScrollToBottomButton = false
|
||||
}
|
||||
|
|
@ -180,20 +199,22 @@ struct ChannelMessageList: View {
|
|||
}
|
||||
.scrollDismissesKeyboard(.interactively)
|
||||
.onFirstAppear {
|
||||
if channel.unreadMessages == 0 {
|
||||
withAnimation {
|
||||
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
|
||||
hasReachedBottom = true
|
||||
}
|
||||
} else {
|
||||
if let firstUnreadMessageId = channel.allPrivateMessages.first(where: { !$0.read })?.messageId {
|
||||
DispatchQueue.main.async {
|
||||
if channel.unreadMessages == 0 {
|
||||
withAnimation {
|
||||
scrollView.scrollTo(firstUnreadMessageId, anchor: .top)
|
||||
showScrollToBottomButton = true
|
||||
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
|
||||
hasReachedBottom = true
|
||||
}
|
||||
} else {
|
||||
if let firstUnreadMessageId = allPrivateMessages.first(where: { !$0.read })?.messageId {
|
||||
withAnimation {
|
||||
scrollView.scrollTo(firstUnreadMessageId, anchor: .top)
|
||||
showScrollToBottomButton = true
|
||||
}
|
||||
}
|
||||
}
|
||||
gotFirstUnreadMessage = true
|
||||
}
|
||||
gotFirstUnreadMessage = true
|
||||
}
|
||||
.onReceive(NotificationCenter.default.publisher(for: UIResponder.keyboardDidShowNotification)) { _ in
|
||||
withAnimation {
|
||||
|
|
@ -202,7 +223,7 @@ struct ChannelMessageList: View {
|
|||
showScrollToBottomButton = false
|
||||
}
|
||||
}
|
||||
.onChange(of: channel.allPrivateMessages) {
|
||||
.onChange(of: allPrivateMessages.count) {
|
||||
if hasReachedBottom {
|
||||
withAnimation {
|
||||
scrollView.scrollTo("bottomAnchor", anchor: .bottom)
|
||||
|
|
@ -248,18 +269,17 @@ struct ChannelMessageList: View {
|
|||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ZStack {
|
||||
ConnectedDevice(
|
||||
bluetoothOn: bleManager.isSwitchedOn,
|
||||
deviceConnected: bleManager.connectedPeripheral != nil,
|
||||
name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?",
|
||||
|
||||
deviceConnected: accessoryManager.isConnected,
|
||||
name: accessoryManager.activeConnection?.device.shortName ?? "?",
|
||||
// mqttProxyConnected defaults to false, so if it's not enabled it will still be false
|
||||
mqttProxyConnected: bleManager.mqttProxyConnected && (channel.uplinkEnabled || channel.downlinkEnabled),
|
||||
mqttProxyConnected: accessoryManager.mqttProxyConnected && (channel.uplinkEnabled || channel.downlinkEnabled),
|
||||
mqttUplinkEnabled: channel.uplinkEnabled,
|
||||
mqttDownlinkEnabled: channel.downlinkEnabled,
|
||||
mqttTopic: bleManager.mqttManager.topic
|
||||
mqttTopic: accessoryManager.mqttManager.topic
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import SwiftUI
|
||||
import CoreData
|
||||
import OSLog
|
||||
|
||||
struct MessageContextMenuItems: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
let message: MessageEntity
|
||||
let tapBackDestination: MessageDestination
|
||||
|
|
@ -22,15 +23,21 @@ struct MessageContextMenuItems: View {
|
|||
Menu("Tapback") {
|
||||
ForEach(Tapbacks.allCases) { tb in
|
||||
Button {
|
||||
let sentMessage = bleManager.sendMessage(
|
||||
message: tb.emojiString,
|
||||
toUserNum: tapBackDestination.userNum,
|
||||
channel: tapBackDestination.channelNum,
|
||||
isEmoji: true,
|
||||
replyID: message.messageId
|
||||
)
|
||||
if sentMessage {
|
||||
self.context.refresh(tapBackDestination.managedObject, mergeChanges: true)
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: tb.emojiString,
|
||||
toUserNum: tapBackDestination.userNum,
|
||||
channel: tapBackDestination.channelNum,
|
||||
isEmoji: true,
|
||||
replyID: message.messageId
|
||||
)
|
||||
Task { @MainActor in
|
||||
self.context.refresh(tapBackDestination.managedObject, mergeChanges: true)
|
||||
}
|
||||
} catch {
|
||||
Logger.services.warning("Failed to send tapback.")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Text(tb.description)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import MeshtasticProtobufs
|
||||
import OSLog
|
||||
import SwiftUI
|
||||
import DatadogSessionReplay
|
||||
|
||||
struct MessageText: View {
|
||||
static let linkBlue = Color(red: 0.4627, green: 0.8392, blue: 1) /* #76d6ff */
|
||||
|
|
@ -11,6 +12,8 @@ struct MessageText: View {
|
|||
)
|
||||
static let dateFormatString = (localeDateFormat ?? "MM/dd/YY j:mm:ss:a")
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
let message: MessageEntity
|
||||
let tapBackDestination: MessageDestination
|
||||
let isCurrentUser: Bool
|
||||
|
|
@ -20,132 +23,136 @@ struct MessageText: View {
|
|||
@State private var channelSettings: String?
|
||||
@State private var addChannels = false
|
||||
@State private var isShowingDeleteConfirmation = false
|
||||
|
||||
|
||||
var body: some View {
|
||||
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
|
||||
return Text(markdownText)
|
||||
.tint(Self.linkBlue)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 8)
|
||||
.foregroundColor(.white)
|
||||
.background(isCurrentUser ? .accentColor : Color(.gray))
|
||||
.cornerRadius(15)
|
||||
.overlay {
|
||||
/// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages
|
||||
if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted {
|
||||
VStack(alignment: .trailing) {
|
||||
Spacer()
|
||||
HStack {
|
||||
|
||||
SessionReplayPrivacyView(textAndInputPrivacy: .maskAll) {
|
||||
|
||||
let markdownText = LocalizedStringKey(message.messagePayloadMarkdown ?? (message.messagePayload ?? "EMPTY MESSAGE"))
|
||||
return Text(markdownText)
|
||||
.tint(Self.linkBlue)
|
||||
.padding(.vertical, 10)
|
||||
.padding(.horizontal, 8)
|
||||
.foregroundColor(.white)
|
||||
.background(isCurrentUser ? .accentColor : Color(.gray))
|
||||
.cornerRadius(15)
|
||||
.overlay {
|
||||
/// Show the lock if the message is pki encrypted and has a real ack if sent by the current user, or is pki encrypted for incoming messages
|
||||
if message.pkiEncrypted && message.realACK || !isCurrentUser && message.pkiEncrypted {
|
||||
VStack(alignment: .trailing) {
|
||||
Spacer()
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .green)
|
||||
.font(.system(size: 20))
|
||||
.offset(x: 8, y: 8)
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(systemName: "lock.circle.fill")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .green)
|
||||
.font(.system(size: 20))
|
||||
.offset(x: 8, y: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue)
|
||||
let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue)
|
||||
if isStoreAndForward {
|
||||
VStack(alignment: .trailing) {
|
||||
Spacer()
|
||||
HStack {
|
||||
let isStoreAndForward = message.portNum == Int32(PortNum.storeForwardApp.rawValue)
|
||||
let isDetectionSensorMessage = message.portNum == Int32(PortNum.detectionSensorApp.rawValue)
|
||||
if isStoreAndForward {
|
||||
VStack(alignment: .trailing) {
|
||||
Spacer()
|
||||
Image(systemName: "envelope.circle.fill")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .gray)
|
||||
.font(.system(size: 20))
|
||||
.offset(x: 8, y: 8)
|
||||
HStack {
|
||||
Spacer()
|
||||
Image(systemName: "envelope.circle.fill")
|
||||
.symbolRenderingMode(.palette)
|
||||
.foregroundStyle(.white, .gray)
|
||||
.font(.system(size: 20))
|
||||
.offset(x: 8, y: 8)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if tapBackDestination.overlaySensorMessage {
|
||||
VStack {
|
||||
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.foregroundStyle(Color.orange)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.offset(x: 20, y: -20)
|
||||
: nil
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
.contextMenu {
|
||||
MessageContextMenuItems(
|
||||
message: message,
|
||||
tapBackDestination: tapBackDestination,
|
||||
isCurrentUser: isCurrentUser,
|
||||
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
|
||||
onReply: onReply
|
||||
)
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
channelSettings = nil
|
||||
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
|
||||
// Handle contact URL
|
||||
ContactURLHandler.handleContactUrl(url: url, bleManager: BLEManager.shared)
|
||||
return .handled // Prevent default browser opening
|
||||
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
|
||||
// Handle channel URL
|
||||
let components = url.absoluteString.components(separatedBy: "#")
|
||||
guard !components.isEmpty, let lastComponent = components.last else {
|
||||
Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)")
|
||||
return .discarded
|
||||
}
|
||||
self.addChannels = Bool(url.query?.contains("add=true") ?? false)
|
||||
guard let lastComponent = components.last else {
|
||||
Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)")
|
||||
self.channelSettings = nil
|
||||
return .discarded
|
||||
}
|
||||
self.channelSettings = lastComponent.components(separatedBy: "?").first ?? ""
|
||||
Logger.services.debug("Add Channel: \(self.addChannels, privacy: .public)")
|
||||
self.saveChannels = true
|
||||
Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)")
|
||||
return .handled // Prevent default browser opening
|
||||
}
|
||||
return .systemAction // Open other URLs in browser
|
||||
})
|
||||
// Display sheet for channel settings
|
||||
.sheet(isPresented: Binding(
|
||||
get: {
|
||||
saveChannels && !(channelSettings == nil)
|
||||
},
|
||||
set: { newValue in
|
||||
saveChannels = newValue
|
||||
if !newValue {
|
||||
channelSettings = nil
|
||||
if tapBackDestination.overlaySensorMessage {
|
||||
VStack {
|
||||
isDetectionSensorMessage ? Image(systemName: "sensor.fill")
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
||||
.foregroundStyle(Color.orange)
|
||||
.symbolRenderingMode(.multicolor)
|
||||
.symbolEffect(.variableColor.reversing.cumulative, options: .repeat(20).speed(3))
|
||||
.offset(x: 20, y: -20)
|
||||
: nil
|
||||
}
|
||||
} else {
|
||||
EmptyView()
|
||||
}
|
||||
}
|
||||
)) {
|
||||
SaveChannelQRCode(
|
||||
channelSetLink: channelSettings ?? "Empty Channel URL",
|
||||
addChannels: addChannels,
|
||||
bleManager: BLEManager.shared
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Are you sure you want to delete this message?",
|
||||
isPresented: $isShowingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete Message", role: .destructive) {
|
||||
context.delete(message)
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
.contextMenu {
|
||||
MessageContextMenuItems(
|
||||
message: message,
|
||||
tapBackDestination: tapBackDestination,
|
||||
isCurrentUser: isCurrentUser,
|
||||
isShowingDeleteConfirmation: $isShowingDeleteConfirmation,
|
||||
onReply: onReply
|
||||
)
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
.environment(\.openURL, OpenURLAction { url in
|
||||
channelSettings = nil
|
||||
if url.absoluteString.lowercased().contains("meshtastic.org/v/#") {
|
||||
// Handle contact URL
|
||||
ContactURLHandler.handleContactUrl(url: url, accessoryManager: AccessoryManager.shared)
|
||||
return .handled // Prevent default browser opening
|
||||
} else if url.absoluteString.lowercased().contains("meshtastic.org/e/") {
|
||||
// Handle channel URL
|
||||
let components = url.absoluteString.components(separatedBy: "#")
|
||||
guard !components.isEmpty, let lastComponent = components.last else {
|
||||
Logger.services.error("No valid components found in channel URL: \(url.absoluteString, privacy: .public)")
|
||||
return .discarded
|
||||
}
|
||||
self.addChannels = Bool(url.query?.contains("add=true") ?? false)
|
||||
guard let lastComponent = components.last else {
|
||||
Logger.services.error("Channel URL missing fragment component: \(url.absoluteString, privacy: .public)")
|
||||
self.channelSettings = nil
|
||||
return .discarded
|
||||
}
|
||||
self.channelSettings = lastComponent.components(separatedBy: "?").first ?? ""
|
||||
Logger.services.debug("Add Channel: \(self.addChannels, privacy: .public)")
|
||||
self.saveChannels = true
|
||||
Logger.mesh.debug("Opening Channel Settings URL: \(url.absoluteString, privacy: .public)")
|
||||
return .handled // Prevent default browser opening
|
||||
}
|
||||
return .systemAction // Open other URLs in browser
|
||||
})
|
||||
// Display sheet for channel settings
|
||||
.sheet(isPresented: Binding(
|
||||
get: {
|
||||
saveChannels && !(channelSettings == nil)
|
||||
},
|
||||
set: { newValue in
|
||||
saveChannels = newValue
|
||||
if !newValue {
|
||||
channelSettings = nil
|
||||
}
|
||||
}
|
||||
)) {
|
||||
SaveChannelQRCode(
|
||||
channelSetLink: channelSettings ?? "Empty Channel URL",
|
||||
addChannels: addChannels,
|
||||
accessoryManager: accessoryManager
|
||||
)
|
||||
.presentationDetents([.large])
|
||||
.presentationDragIndicator(.visible)
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Are you sure you want to delete this message?",
|
||||
isPresented: $isShowingDeleteConfirmation,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete Message", role: .destructive) {
|
||||
context.delete(message)
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
Logger.data.error("Failed to delete message \(message.messageId, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,6 @@ import TipKit
|
|||
struct Messages: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
|
||||
@ObservedObject
|
||||
var router: Router
|
||||
|
|
@ -46,7 +45,6 @@ struct Messages: View {
|
|||
.font(.title2)
|
||||
.padding()
|
||||
}
|
||||
|
||||
}
|
||||
NavigationLink(value: MessagesNavigationState.directMessages()) {
|
||||
Label {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import OSLog
|
|||
|
||||
struct RetryButton: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
let message: MessageEntity
|
||||
let destination: MessageDestination
|
||||
|
|
@ -24,7 +24,7 @@ struct RetryButton: View {
|
|||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Try Again") {
|
||||
guard bleManager.connectedPeripheral?.peripheral.state == .connected else {
|
||||
guard accessoryManager.isConnected else {
|
||||
return
|
||||
}
|
||||
let messageID = message.messageId
|
||||
|
|
@ -39,25 +39,23 @@ struct RetryButton: View {
|
|||
} catch {
|
||||
Logger.data.error("Failed to delete message \(messageID, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
if !bleManager.sendMessage(
|
||||
message: payload,
|
||||
toUserNum: userNum,
|
||||
channel: channel,
|
||||
isEmoji: isEmoji,
|
||||
replyID: replyID
|
||||
) {
|
||||
// Best effort, unlikely since we already checked BLE state
|
||||
Logger.services.warning("Failed to resend message \(messageID, privacy: .public)")
|
||||
} else {
|
||||
switch destination {
|
||||
case .user:
|
||||
break
|
||||
case let .channel(channel):
|
||||
// We must refresh the channel to trigger a view update since its relationship
|
||||
// to messages is via a weak fetched property which is not updated by
|
||||
// `bleManager.sendMessage` unlike the user entity.
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(message: payload, toUserNum: userNum, channel: channel,
|
||||
isEmoji: isEmoji, replyID: replyID)
|
||||
if case let .channel(channel) = destination {
|
||||
// We must refresh the channel to trigger a view update since its relationship
|
||||
// to messages is via a weak fetched property which is not updated by
|
||||
// `bleManager.sendMessage` unlike the user entity.
|
||||
Task { @MainActor in
|
||||
context.refresh(channel, mergeChanges: true)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Best effort
|
||||
Logger.services.warning("Failed to resend message \(messageID, privacy: .public)")
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import SwiftUI
|
||||
import OSLog
|
||||
import DatadogSessionReplay
|
||||
|
||||
struct TextMessageField: View {
|
||||
static let maxbytes = 200
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
let destination: MessageDestination
|
||||
@Binding var replyMessageId: Int64
|
||||
|
|
@ -15,118 +16,125 @@ struct TextMessageField: View {
|
|||
@State private var sendPositionWithMessage = false
|
||||
|
||||
var body: some View {
|
||||
VStack {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
HStack {
|
||||
if destination.showAlertButton {
|
||||
Spacer()
|
||||
AlertButton { typingMessage += "🔔 Alert Bell! \u{7}" }
|
||||
}
|
||||
Spacer()
|
||||
RequestPositionButton(action: requestPosition)
|
||||
TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes).padding(.trailing)
|
||||
}
|
||||
#endif
|
||||
|
||||
HStack(alignment: .top) {
|
||||
if replyMessageId != 0 {
|
||||
HStack {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
replyMessageId = 0
|
||||
}
|
||||
isFocused = false
|
||||
} label: {
|
||||
Image(systemName: "x.circle.fill")
|
||||
}
|
||||
Text("Reply")
|
||||
SessionReplayPrivacyView(textAndInputPrivacy: .maskAllInputs) {
|
||||
VStack {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
HStack {
|
||||
if destination.showAlertButton {
|
||||
Spacer()
|
||||
AlertButton { typingMessage += "🔔 Alert Bell! \u{7}" }
|
||||
}
|
||||
.padding(.top)
|
||||
Spacer()
|
||||
RequestPositionButton(action: requestPosition)
|
||||
TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes).padding(.trailing)
|
||||
}
|
||||
|
||||
ZStack {
|
||||
TextField("Message", text: $typingMessage, axis: .vertical)
|
||||
.onChange(of: typingMessage) { _, value in
|
||||
totalBytes = value.utf8.count
|
||||
while totalBytes > Self.maxbytes {
|
||||
typingMessage = String(typingMessage.dropLast())
|
||||
totalBytes = typingMessage.utf8.count
|
||||
}
|
||||
}
|
||||
.keyboardType(.default)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Button("Dismiss") {
|
||||
isFocused = false
|
||||
#endif
|
||||
|
||||
HStack(alignment: .top) {
|
||||
if replyMessageId != 0 {
|
||||
HStack {
|
||||
Button {
|
||||
withAnimation(.easeInOut(duration: 0.2)) {
|
||||
replyMessageId = 0
|
||||
}
|
||||
.font(.subheadline)
|
||||
|
||||
if destination.showAlertButton {
|
||||
isFocused = false
|
||||
} label: {
|
||||
Image(systemName: "x.circle.fill")
|
||||
}
|
||||
Text("Reply")
|
||||
}
|
||||
.padding(.top)
|
||||
}
|
||||
|
||||
ZStack {
|
||||
TextField("Message", text: $typingMessage, axis: .vertical)
|
||||
.onChange(of: typingMessage) { _, value in
|
||||
totalBytes = value.utf8.count
|
||||
while totalBytes > Self.maxbytes {
|
||||
typingMessage = String(typingMessage.dropLast())
|
||||
totalBytes = typingMessage.utf8.count
|
||||
}
|
||||
}
|
||||
.keyboardType(.default)
|
||||
.toolbar {
|
||||
ToolbarItemGroup(placement: .keyboard) {
|
||||
Button("Dismiss") {
|
||||
isFocused = false
|
||||
}
|
||||
.font(.subheadline)
|
||||
|
||||
if destination.showAlertButton {
|
||||
Spacer()
|
||||
AlertButton { typingMessage += "🔔 Alert Bell Character! \u{7}" }
|
||||
}
|
||||
|
||||
Spacer()
|
||||
AlertButton { typingMessage += "🔔 Alert Bell Character! \u{7}" }
|
||||
RequestPositionButton(action: requestPosition)
|
||||
TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
RequestPositionButton(action: requestPosition)
|
||||
TextMessageSize(maxbytes: Self.maxbytes, totalBytes: totalBytes)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.focused($isFocused)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(minHeight: 50)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.onSubmit {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
sendMessage()
|
||||
#endif
|
||||
}
|
||||
|
||||
Text(typingMessage)
|
||||
.opacity(0)
|
||||
.padding(.all, 0)
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 1))
|
||||
.padding(.bottom, 15)
|
||||
|
||||
Button(action: sendMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.accentColor)
|
||||
.padding(.horizontal, 8)
|
||||
.focused($isFocused)
|
||||
.multilineTextAlignment(.leading)
|
||||
.frame(minHeight: 50)
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.onSubmit {
|
||||
#if targetEnvironment(macCatalyst)
|
||||
sendMessage()
|
||||
#endif
|
||||
}
|
||||
|
||||
Text(typingMessage)
|
||||
.opacity(0)
|
||||
.padding(.all, 0)
|
||||
}
|
||||
.overlay(RoundedRectangle(cornerRadius: 20).stroke(.tertiary, lineWidth: 1))
|
||||
.padding(.bottom, 15)
|
||||
|
||||
Button(action: sendMessage) {
|
||||
Image(systemName: "arrow.up.circle.fill")
|
||||
.font(.largeTitle)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
}
|
||||
.padding(.all, 15)
|
||||
}
|
||||
.padding(.all, 15)
|
||||
}
|
||||
}
|
||||
|
||||
private func requestPosition() {
|
||||
let userLongName = bleManager.connectedPeripheral != nil ? bleManager.connectedPeripheral.longName : "Unknown"
|
||||
let userLongName = accessoryManager.activeConnection?.device.longName ?? "Unknown"
|
||||
sendPositionWithMessage = true
|
||||
typingMessage = "📍 " + userLongName + " \(destination.positionShareMessage)."
|
||||
}
|
||||
|
||||
private func sendMessage() {
|
||||
let messageSent = bleManager.sendMessage(
|
||||
message: typingMessage,
|
||||
toUserNum: destination.userNum,
|
||||
channel: destination.channelNum,
|
||||
isEmoji: false,
|
||||
replyID: replyMessageId
|
||||
)
|
||||
if messageSent {
|
||||
typingMessage = ""
|
||||
isFocused = false
|
||||
replyMessageId = 0
|
||||
onSubmit()
|
||||
if sendPositionWithMessage {
|
||||
let positionSent = bleManager.sendPosition(
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendMessage(
|
||||
message: typingMessage,
|
||||
toUserNum: destination.userNum,
|
||||
channel: destination.channelNum,
|
||||
destNum: destination.positionDestNum,
|
||||
wantResponse: destination.wantPositionResponse
|
||||
)
|
||||
if positionSent {
|
||||
isEmoji: false,
|
||||
replyID: replyMessageId)
|
||||
|
||||
// If nothing thrown, then successful. Reset for the next message
|
||||
typingMessage = ""
|
||||
isFocused = false
|
||||
replyMessageId = 0
|
||||
onSubmit()
|
||||
|
||||
if sendPositionWithMessage {
|
||||
try await accessoryManager.sendPosition(
|
||||
channel: destination.channelNum,
|
||||
destNum: destination.positionDestNum,
|
||||
wantResponse: destination.wantPositionResponse
|
||||
)
|
||||
// If nothing thrown, then successful.
|
||||
Logger.mesh.info("Location Sent")
|
||||
}
|
||||
} catch {
|
||||
Logger.mesh.info("Error sending message")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import TipKit
|
|||
struct UserList: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State private var searchText = ""
|
||||
@State private var viaLora = true
|
||||
@State private var viaMqtt = true
|
||||
|
|
@ -69,7 +69,7 @@ struct UserList: View {
|
|||
let lastMessageTime = Date(timeIntervalSince1970: TimeInterval(Int64((mostRecent?.messageTimestamp ?? 0 ))))
|
||||
let lastMessageDay = Calendar.current.dateComponents([.day], from: lastMessageTime).day ?? 0
|
||||
let currentDay = Calendar.current.dateComponents([.day], from: Date()).day ?? 0
|
||||
if user.num != bleManager.connectedPeripheral?.num ?? 0 {
|
||||
if user.num != accessoryManager.activeDeviceNum ?? 0 {
|
||||
NavigationLink(value: user) {
|
||||
ZStack {
|
||||
Image(systemName: "circle.fill")
|
||||
|
|
@ -138,15 +138,15 @@ struct UserList: View {
|
|||
.contextMenu {
|
||||
Button {
|
||||
if node != nil && !(user.userNode?.favorite ?? false) {
|
||||
let success = bleManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
if success {
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
|
||||
Task {
|
||||
try await accessoryManager.setFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
Logger.data.info("Favorited a node")
|
||||
}
|
||||
} else {
|
||||
let success = bleManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
if success {
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
|
||||
user.userNode?.favorite = !(user.userNode?.favorite ?? false)
|
||||
Task {
|
||||
try await accessoryManager.removeFavoriteNode(node: user.userNode!, connectedNodeNum: Int64(node!.num))
|
||||
Logger.data.info("Unfavorited a node")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import OSLog
|
|||
struct UserMessageList: View {
|
||||
|
||||
@EnvironmentObject var appState: AppState
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@Environment(\.managedObjectContext) var context
|
||||
// Keyboard State
|
||||
@FocusState var messageFieldFocused: Bool
|
||||
|
|
@ -39,7 +39,7 @@ struct UserMessageList: View {
|
|||
.font(.caption)
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
if user.num != bleManager.connectedPeripheral?.num ?? -1 {
|
||||
if user.num != accessoryManager.activeDeviceNum ?? -1 {
|
||||
let currentUser: Bool = (Int64(UserDefaults.preferredPeripheralNum) == message.fromUser?.num ?? -1 ? true : false)
|
||||
|
||||
if message.replyID > 0 {
|
||||
|
|
@ -255,9 +255,8 @@ struct UserMessageList: View {
|
|||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
ZStack {
|
||||
ConnectedDevice(
|
||||
bluetoothOn: bleManager.isSwitchedOn,
|
||||
deviceConnected: bleManager.connectedPeripheral != nil,
|
||||
name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
deviceConnected: accessoryManager.isConnected,
|
||||
name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import OSLog
|
|||
|
||||
struct DetectionSensorLog: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State private var isPresentingClearLogConfirm: Bool = false
|
||||
@State var isExporting = false
|
||||
@State var exportString = ""
|
||||
|
|
@ -122,7 +122,7 @@ struct DetectionSensorLog: View {
|
|||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
})
|
||||
.fileExporter(
|
||||
isPresented: $isExporting,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import OSLog
|
|||
struct DeviceMetricsLog: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
|
||||
|
||||
@State private var isPresentingClearLogConfirm: Bool = false
|
||||
|
|
@ -244,7 +244,7 @@ struct DeviceMetricsLog: View {
|
|||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
})
|
||||
.fileExporter(
|
||||
isPresented: $isExporting,
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import OSLog
|
|||
struct EnvironmentMetricsLog: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State private var isPresentingClearLogConfirm: Bool = false
|
||||
@State var isExporting = false
|
||||
@State var exportString = ""
|
||||
|
|
@ -164,7 +164,7 @@ struct EnvironmentMetricsLog: View {
|
|||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
})
|
||||
.fileExporter(
|
||||
isPresented: $isExporting,
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
import OSLog
|
||||
struct ClientHistoryButton: View {
|
||||
var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
var connectedNode: NodeInfoEntity
|
||||
|
||||
|
|
@ -12,10 +12,19 @@ struct ClientHistoryButton: View {
|
|||
|
||||
var body: some View {
|
||||
Button {
|
||||
isPresentingAlert = bleManager.requestStoreAndForwardClientHistory(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user!
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.requestStoreAndForwardClientHistory(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user!
|
||||
)
|
||||
Task { @MainActor in
|
||||
isPresentingAlert = true
|
||||
}
|
||||
} catch {
|
||||
Logger.mesh.warning("Failed to send client history request: \(error)")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label(
|
||||
"Client History",
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@ import SwiftUI
|
|||
|
||||
struct DeleteNodeButton: View {
|
||||
|
||||
var bleManager: BLEManager
|
||||
var context: NSManagedObjectContext
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
var connectedNode: NodeInfoEntity
|
||||
var node: NodeInfoEntity
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
|
@ -44,14 +45,19 @@ struct DeleteNodeButton: View {
|
|||
Logger.data.error("Unable to find node info to delete node \(node.num, privacy: .public)")
|
||||
return
|
||||
}
|
||||
let success = bleManager.removeNode(
|
||||
node: deleteNode,
|
||||
connectedNodeNum: connectedNode.num
|
||||
)
|
||||
if !success {
|
||||
Logger.data.error("Failed to delete node \(deleteNode.user?.longName ?? "Unknown".localized, privacy: .public)")
|
||||
} else {
|
||||
dismiss()
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.removeNode(
|
||||
node: deleteNode,
|
||||
connectedNodeNum: connectedNode.num
|
||||
)
|
||||
Task {@MainActor in
|
||||
dismiss()
|
||||
}
|
||||
} catch {
|
||||
Logger.data.error("Failed to delete node \(deleteNode.user?.longName ?? "Unknown".localized, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,29 +2,35 @@ import CoreData
|
|||
import SwiftUI
|
||||
|
||||
struct ExchangePositionsButton: View {
|
||||
var bleManager: BLEManager
|
||||
|
||||
var node: NodeInfoEntity
|
||||
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
@State private var isPresentingPositionSentAlert: Bool = false
|
||||
@State private var isPresentingPositionFailedAlert: Bool = false
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
let positionSent = bleManager.sendPosition(
|
||||
channel: node.channel,
|
||||
destNum: node.num,
|
||||
wantResponse: true
|
||||
)
|
||||
if positionSent {
|
||||
isPresentingPositionSentAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
isPresentingPositionSentAlert = false
|
||||
}
|
||||
} else {
|
||||
isPresentingPositionFailedAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
isPresentingPositionFailedAlert = false
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendPosition(
|
||||
channel: node.channel,
|
||||
destNum: node.num,
|
||||
wantResponse: true
|
||||
)
|
||||
Task { @MainActor in
|
||||
isPresentingPositionSentAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
isPresentingPositionSentAlert = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Task { @MainActor in
|
||||
isPresentingPositionFailedAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
isPresentingPositionFailedAlert = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,35 +3,44 @@ import OSLog
|
|||
import SwiftUI
|
||||
|
||||
struct FavoriteNodeButton: View {
|
||||
var bleManager: BLEManager
|
||||
var context: NSManagedObjectContext
|
||||
|
||||
@ObservedObject
|
||||
var node: NodeInfoEntity
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@Environment(\.managedObjectContext) var context
|
||||
|
||||
@ObservedObject var node: NodeInfoEntity
|
||||
|
||||
var body: some View {
|
||||
Button {
|
||||
guard let connectedNodeNum = bleManager.connectedPeripheral?.num else { return }
|
||||
let success = if node.favorite {
|
||||
bleManager.removeFavoriteNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
} else {
|
||||
bleManager.setFavoriteNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
}
|
||||
if success {
|
||||
node.favorite = !node.favorite
|
||||
guard let connectedNodeNum = accessoryManager.activeDeviceNum else { return }
|
||||
Task {
|
||||
do {
|
||||
try context.save()
|
||||
if node.favorite {
|
||||
try await accessoryManager.removeFavoriteNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
} else {
|
||||
try await accessoryManager.setFavoriteNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
// Update CoreData
|
||||
node.favorite = !node.favorite
|
||||
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("Save Node Favorite Error")
|
||||
}
|
||||
Logger.data.debug("Favorited a node")
|
||||
}
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("Save Node Favorite Error")
|
||||
|
||||
}
|
||||
Logger.data.debug("Favorited a node")
|
||||
}
|
||||
} label: {
|
||||
Label {
|
||||
|
|
|
|||
|
|
@ -3,35 +3,42 @@ import OSLog
|
|||
import SwiftUI
|
||||
|
||||
struct IgnoreNodeButton: View {
|
||||
var bleManager: BLEManager
|
||||
var context: NSManagedObjectContext
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
@ObservedObject
|
||||
var node: NodeInfoEntity
|
||||
|
||||
var body: some View {
|
||||
Button(role: .destructive) {
|
||||
guard let connectedNodeNum = bleManager.connectedPeripheral?.num else { return }
|
||||
let success = if node.ignored {
|
||||
bleManager.removeIgnoredNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
} else {
|
||||
bleManager.setIgnoredNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
}
|
||||
if success {
|
||||
node.ignored = !node.ignored
|
||||
guard let connectedNodeNum = accessoryManager.activeDeviceNum else { return }
|
||||
Task {
|
||||
do {
|
||||
try context.save()
|
||||
if node.ignored {
|
||||
try await accessoryManager.removeIgnoredNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
} else {
|
||||
try await accessoryManager.setIgnoredNode(
|
||||
node: node,
|
||||
connectedNodeNum: Int64(connectedNodeNum)
|
||||
)
|
||||
}
|
||||
Task {@MainActor in
|
||||
// CoreData Stuff
|
||||
node.ignored = !node.ignored
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("Save Ignored Node Error")
|
||||
}
|
||||
}
|
||||
Logger.data.debug("Ignored a node")
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.data.error("Save Ignored Node Error")
|
||||
Logger.mesh.error("Faile to Ignored/Un-ignore a node")
|
||||
}
|
||||
Logger.data.debug("Ignored a node")
|
||||
}
|
||||
} label: {
|
||||
Label {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import SwiftUI
|
||||
|
||||
import OSLog
|
||||
struct TraceRouteButton: View {
|
||||
var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
var node: NodeInfoEntity
|
||||
|
||||
|
|
@ -10,10 +10,19 @@ struct TraceRouteButton: View {
|
|||
|
||||
var body: some View {
|
||||
RateLimitedButton(key: "traceroute", rateLimit: 30.0) {
|
||||
isPresentingTraceRouteSentAlert = bleManager.sendTraceRouteRequest(
|
||||
destNum: node.user?.num ?? 0,
|
||||
wantResponse: true
|
||||
)
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendTraceRouteRequest(
|
||||
destNum: node.user?.num ?? 0,
|
||||
wantResponse: true
|
||||
)
|
||||
Task {
|
||||
isPresentingTraceRouteSentAlert = true
|
||||
}
|
||||
} catch {
|
||||
Logger.mesh.warning("Failed to send traceroute request: \(error)")
|
||||
}
|
||||
}
|
||||
} label: { completion in
|
||||
if let completion, completion.percentComplete > 0.0 {
|
||||
Label {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import OSLog
|
|||
|
||||
struct MapDataFiles: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@ObservedObject private var mapDataManager = MapDataManager.shared
|
||||
|
||||
@State private var isShowingFilePicker = false
|
||||
|
|
@ -58,10 +58,10 @@ struct MapDataFiles: View {
|
|||
let uploadedFiles = mapDataManager.getUploadedFiles()
|
||||
|
||||
if uploadedFiles.isEmpty {
|
||||
ContentUnavailableView ("No files uploaded", systemImage: "doc.text")
|
||||
ContentUnavailableView("No files uploaded", systemImage: "doc.text")
|
||||
} else {
|
||||
ScrollView {
|
||||
LazyVStack() {
|
||||
LazyVStack {
|
||||
ForEach(Array(uploadedFiles.enumerated()), id: \.offset) { index, file in
|
||||
MapDataFileRow(file: file, showDivider: index < uploadedFiles.count - 1) {
|
||||
deleteFile(file)
|
||||
|
|
|
|||
|
|
@ -187,7 +187,7 @@ struct MapSettingsForm: View {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
ContentUnavailableView ("No map data files uploaded", systemImage: "exclamationmark.triangle")
|
||||
ContentUnavailableView("No map data files uploaded", systemImage: "exclamationmark.triangle")
|
||||
}
|
||||
} else if !hasUserData {
|
||||
// Upload prompt when no data available
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import MapKit
|
|||
|
||||
struct NodeMapSwiftUI: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
/// Parameters
|
||||
@ObservedObject var node: NodeInfoEntity
|
||||
@State var showUserLocation: Bool = false
|
||||
|
|
@ -58,9 +58,8 @@ struct NodeMapSwiftUI: View {
|
|||
.navigationBarItems(trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(
|
||||
bluetoothOn: bleManager.isSwitchedOn,
|
||||
deviceConnected: bleManager.connectedPeripheral != nil,
|
||||
name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
deviceConnected: accessoryManager.isConnected,
|
||||
name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ struct PositionPopover: View {
|
|||
|
||||
@ObservedObject var locationsHandler = LocationsHandler.shared
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom }
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
var position: PositionEntity
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import SwiftUI
|
|||
|
||||
struct WaypointForm: View {
|
||||
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
@State var waypoint: WaypointEntity
|
||||
|
|
@ -149,7 +149,11 @@ struct WaypointForm: View {
|
|||
.scrollDismissesKeyboard(.immediately)
|
||||
HStack {
|
||||
Button {
|
||||
if bleManager.isConnected {
|
||||
guard let deviceNum = accessoryManager.activeDeviceNum else {
|
||||
Logger.mesh.warning("Send waypoint failed: No deviceNum")
|
||||
return
|
||||
}
|
||||
if accessoryManager.isConnected {
|
||||
/// Send a new or exiting waypoint
|
||||
var newWaypoint = Waypoint()
|
||||
if waypoint.id == 0 {
|
||||
|
|
@ -169,7 +173,7 @@ struct WaypointForm: View {
|
|||
newWaypoint.icon = unicode
|
||||
if locked {
|
||||
if lockedTo == 0 {
|
||||
newWaypoint.lockedTo = UInt32(bleManager.connectedPeripheral!.num)
|
||||
newWaypoint.lockedTo = UInt32(deviceNum)
|
||||
} else {
|
||||
newWaypoint.lockedTo = UInt32(lockedTo)
|
||||
}
|
||||
|
|
@ -179,11 +183,17 @@ struct WaypointForm: View {
|
|||
} else {
|
||||
newWaypoint.expire = 0
|
||||
}
|
||||
if bleManager.sendWaypoint(waypoint: newWaypoint) {
|
||||
dismiss()
|
||||
} else {
|
||||
Logger.mesh.warning("Send waypoint failed")
|
||||
waypointFailedAlert = true
|
||||
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendWaypoint(waypoint: newWaypoint)
|
||||
dismiss()
|
||||
} catch {
|
||||
Logger.mesh.warning("Send waypoint failed: \(error)")
|
||||
Task { @MainActor in
|
||||
waypointFailedAlert = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Logger.mesh.warning("Send waypoint failed, node not connected")
|
||||
|
|
@ -194,7 +204,7 @@ struct WaypointForm: View {
|
|||
.buttonStyle(.bordered)
|
||||
.buttonBorderShape(.capsule)
|
||||
.controlSize(.regular)
|
||||
.disabled(bleManager.connectedPeripheral == nil)
|
||||
.disabled(!accessoryManager.isConnected)
|
||||
.padding(.bottom)
|
||||
|
||||
Button(role: .cancel) {
|
||||
|
|
@ -207,7 +217,7 @@ struct WaypointForm: View {
|
|||
.controlSize(.regular)
|
||||
.padding(.bottom)
|
||||
|
||||
if waypoint.id > 0 && bleManager.isConnected {
|
||||
if waypoint.id > 0 && accessoryManager.isConnected {
|
||||
|
||||
Menu {
|
||||
Button("For me", action: {
|
||||
|
|
@ -219,6 +229,10 @@ struct WaypointForm: View {
|
|||
}
|
||||
dismiss() })
|
||||
Button("For everyone", action: {
|
||||
guard let deviceNum = accessoryManager.activeDeviceNum else {
|
||||
Logger.mesh.error("Unable to set waypoint: No Device num")
|
||||
return
|
||||
}
|
||||
var newWaypoint = Waypoint()
|
||||
newWaypoint.id = UInt32(waypoint.id)
|
||||
newWaypoint.name = name.count > 0 ? name : "Dropped Pin"
|
||||
|
|
@ -232,24 +246,30 @@ struct WaypointForm: View {
|
|||
newWaypoint.icon = unicode
|
||||
if locked {
|
||||
if lockedTo == 0 {
|
||||
newWaypoint.lockedTo = UInt32(bleManager.connectedPeripheral!.num)
|
||||
newWaypoint.lockedTo = UInt32(deviceNum)
|
||||
} else {
|
||||
newWaypoint.lockedTo = UInt32(lockedTo)
|
||||
}
|
||||
}
|
||||
newWaypoint.expire = UInt32(1)
|
||||
if bleManager.sendWaypoint(waypoint: newWaypoint) {
|
||||
|
||||
context.delete(waypoint)
|
||||
Task {
|
||||
do {
|
||||
try context.save()
|
||||
try await accessoryManager.sendWaypoint(waypoint: newWaypoint)
|
||||
Task { @MainActor in
|
||||
context.delete(waypoint)
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
context.rollback()
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
} catch {
|
||||
context.rollback()
|
||||
Logger.mesh.warning("Send waypoint failed")
|
||||
Task {@MainActor in
|
||||
waypointFailedAlert = true
|
||||
}
|
||||
}
|
||||
dismiss()
|
||||
} else {
|
||||
Logger.mesh.warning("Send waypoint failed")
|
||||
waypointFailedAlert = true
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -271,7 +291,7 @@ struct WaypointForm: View {
|
|||
Text(waypoint.name ?? "?")
|
||||
.font(.largeTitle)
|
||||
Spacer()
|
||||
if waypoint.locked > 0 && waypoint.locked != UInt32(BLEManager.shared.connectedPeripheral?.num ?? 0) {
|
||||
if waypoint.locked > 0 && waypoint.locked != UInt32(accessoryManager.activeDeviceNum ?? 0) {
|
||||
Image(systemName: "lock.fill")
|
||||
.font(.largeTitle)
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ struct NodeDetail: View {
|
|||
rawValue: UserDefaults.modemPreset
|
||||
) ?? ModemPresets.longFast
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@State private var showingShutdownConfirm: Bool = false
|
||||
@State private var showingRebootConfirm: Bool = false
|
||||
@State private var dateFormatRelative: Bool = true
|
||||
|
|
@ -34,7 +34,7 @@ struct NodeDetail: View {
|
|||
NavigationStack {
|
||||
List {
|
||||
let connectedNode = getNodeInfo(
|
||||
id: bleManager.connectedPeripheral?.num ?? -1,
|
||||
id: accessoryManager.activeDeviceNum ?? -1,
|
||||
context: context
|
||||
)
|
||||
Section("Hardware") {
|
||||
|
|
@ -115,7 +115,7 @@ struct NodeDetail: View {
|
|||
.textSelection(.enabled)
|
||||
}
|
||||
.accessibilityElement(children: .combine)
|
||||
let connectedNode = getNodeInfo(id: BLEManager.shared.connectedPeripheral?.num ?? 0, context: context)
|
||||
let connectedNode = getNodeInfo(id: accessoryManager.activeDeviceNum ?? 0, context: context)
|
||||
if let user = node.user, user.keyMatch {
|
||||
let publicKey = node.num == connectedNode?.num
|
||||
? node.securityConfig?.publicKey?.base64EncodedString() ?? ""
|
||||
|
|
@ -445,22 +445,17 @@ struct NodeDetail: View {
|
|||
}
|
||||
if let connectedNode {
|
||||
FavoriteNodeButton(
|
||||
bleManager: bleManager,
|
||||
context: context,
|
||||
node: node
|
||||
)
|
||||
if connectedNode.num != node.num {
|
||||
ExchangePositionsButton(
|
||||
bleManager: bleManager,
|
||||
node: node
|
||||
)
|
||||
TraceRouteButton(
|
||||
bleManager: bleManager,
|
||||
node: node
|
||||
)
|
||||
if node.isStoreForwardRouter {
|
||||
ClientHistoryButton(
|
||||
bleManager: bleManager,
|
||||
connectedNode: connectedNode,
|
||||
node: node
|
||||
)
|
||||
|
|
@ -469,13 +464,9 @@ struct NodeDetail: View {
|
|||
NavigateToButton(node: node)
|
||||
}
|
||||
IgnoreNodeButton(
|
||||
bleManager: bleManager,
|
||||
context: context,
|
||||
node: node
|
||||
)
|
||||
DeleteNodeButton(
|
||||
bleManager: bleManager,
|
||||
context: context,
|
||||
connectedNode: connectedNode,
|
||||
node: node
|
||||
)
|
||||
|
|
@ -484,18 +475,22 @@ struct NodeDetail: View {
|
|||
}
|
||||
if let metadata = node.metadata,
|
||||
let connectedNode,
|
||||
self.bleManager.connectedPeripheral != nil {
|
||||
accessoryManager.isConnected {
|
||||
Section("Administration") {
|
||||
if UserDefaults.enableAdministration {
|
||||
Button {
|
||||
let adminMessageId = bleManager.requestDeviceMetadata(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user!,
|
||||
context: context
|
||||
)
|
||||
if adminMessageId > 0 {
|
||||
Logger.mesh.info("Sent node metadata request from node details")
|
||||
Task {
|
||||
do {
|
||||
_ = try await accessoryManager.requestDeviceMetadata(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user!,
|
||||
)
|
||||
Logger.mesh.info("Sent node metadata request from node details")
|
||||
} catch {
|
||||
Logger.mesh.error("Faild to send node metadata request from node details")
|
||||
}
|
||||
}
|
||||
|
||||
} label: {
|
||||
Label {
|
||||
Text("Refresh device metadata")
|
||||
|
|
@ -514,11 +509,15 @@ struct NodeDetail: View {
|
|||
isPresented: $showingShutdownConfirm
|
||||
) {
|
||||
Button("Shutdown Node?", role: .destructive) {
|
||||
if !bleManager.sendShutdown(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user!
|
||||
) {
|
||||
Logger.mesh.warning("Shutdown Failed")
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendShutdown(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user!
|
||||
)
|
||||
} catch {
|
||||
Logger.mesh.warning("Shutdown Failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -535,11 +534,14 @@ struct NodeDetail: View {
|
|||
isPresented: $showingRebootConfirm
|
||||
) {
|
||||
Button("Reboot node?", role: .destructive) {
|
||||
if !bleManager.sendReboot(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user!
|
||||
) {
|
||||
Logger.mesh.warning("Reboot Failed")
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendReboot(
|
||||
fromUser: connectedNode.user!,
|
||||
toUser: node.user! )
|
||||
} catch {
|
||||
Logger.mesh.warning("Reboot Failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ struct NodeListItem: View {
|
|||
} else {
|
||||
desc = "Unknown".localized + " " + "Node".localized
|
||||
}
|
||||
if connected {
|
||||
if isDirectlyConnected {
|
||||
desc += ", currently connected"
|
||||
}
|
||||
if node.favorite {
|
||||
|
|
@ -57,7 +57,7 @@ struct NodeListItem: View {
|
|||
}
|
||||
}
|
||||
// Add distance and heading/bearing if available, but only for non-connected nodes
|
||||
if !connected, let (lastPosition, myCoord) = locationData {
|
||||
if !isDirectlyConnected, let (lastPosition, myCoord) = locationData {
|
||||
let nodeCoord = CLLocation(latitude: lastPosition.nodeCoordinate!.latitude, longitude: lastPosition.nodeCoordinate!.longitude)
|
||||
let metersAway = nodeCoord.distance(from: myCoord)
|
||||
// Distance information
|
||||
|
|
@ -98,7 +98,7 @@ struct NodeListItem: View {
|
|||
}
|
||||
|
||||
@ObservedObject var node: NodeInfoEntity
|
||||
var connected: Bool
|
||||
var isDirectlyConnected: Bool
|
||||
var connectedNode: Int64
|
||||
var modemPreset: ModemPresets = ModemPresets(rawValue: UserDefaults.modemPreset) ?? ModemPresets.longFast
|
||||
|
||||
|
|
@ -159,7 +159,7 @@ struct NodeListItem: View {
|
|||
.symbolRenderingMode(.multicolor)
|
||||
}
|
||||
}
|
||||
if connected {
|
||||
if isDirectlyConnected {
|
||||
IconAndText(systemName: "antenna.radiowaves.left.and.right.circle.fill",
|
||||
imageColor: .green,
|
||||
text: "Connected".localized)
|
||||
|
|
@ -318,6 +318,6 @@ struct IconAndText: View {
|
|||
user.shortName = "TU"
|
||||
nodeInfo.user = user
|
||||
return nodeInfo
|
||||
}(), connected: true, connectedNode: 0, modemPreset: .longFast)
|
||||
}(), isDirectlyConnected: true, connectedNode: 0, modemPreset: .longFast)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import MapKit
|
|||
struct MeshMap: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
@ObservedObject
|
||||
var router: Router
|
||||
|
|
@ -194,7 +194,7 @@ struct MeshMap: View {
|
|||
}
|
||||
}
|
||||
.navigationBarItems(leading: MeshtasticLogo(), trailing: ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
})
|
||||
.onFirstAppear {
|
||||
UIApplication.shared.isIdleTimerDisabled = true
|
||||
|
|
|
|||
|
|
@ -11,13 +11,11 @@ import OSLog
|
|||
struct NodeList: View {
|
||||
@Environment(\.managedObjectContext)
|
||||
var context
|
||||
|
||||
@EnvironmentObject
|
||||
var bleManager: BLEManager
|
||||
|
||||
@ObservedObject
|
||||
var router: Router
|
||||
|
||||
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
@StateObject var router: Router
|
||||
|
||||
@State private var columnVisibility = NavigationSplitViewVisibility.all
|
||||
@State private var selectedNode: NodeInfoEntity?
|
||||
@State private var searchText = ""
|
||||
|
|
@ -40,8 +38,8 @@ struct NodeList: View {
|
|||
@State private var isPresentingPositionFailedAlert = false
|
||||
@State private var isPresentingDeleteNodeAlert = false
|
||||
@State private var deleteNodeId: Int64 = 0
|
||||
@State private var shareContactNode: NodeInfoEntity?
|
||||
|
||||
@State private var shareContactNode: NodeInfoEntity?
|
||||
|
||||
var boolFilters: [Bool] {[
|
||||
isFavorite,
|
||||
isIgnored,
|
||||
|
|
@ -51,11 +49,11 @@ struct NodeList: View {
|
|||
distanceFilter,
|
||||
roleFilter
|
||||
]}
|
||||
|
||||
|
||||
@State var isEditingFilters = false
|
||||
|
||||
|
||||
@SceneStorage("selectedDetailView") var selectedDetailView: String?
|
||||
|
||||
|
||||
@FetchRequest(
|
||||
sortDescriptors: [
|
||||
NSSortDescriptor(key: "ignored", ascending: true),
|
||||
|
|
@ -66,11 +64,14 @@ struct NodeList: View {
|
|||
animation: .spring
|
||||
)
|
||||
var nodes: FetchedResults<NodeInfoEntity>
|
||||
|
||||
|
||||
var connectedNode: NodeInfoEntity? {
|
||||
getNodeInfo(id: bleManager.connectedPeripheral?.num ?? 0, context: context)
|
||||
if let num = accessoryManager.activeDeviceNum {
|
||||
return getNodeInfo(id: num, context: context)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@ViewBuilder
|
||||
func contextMenuActions(
|
||||
node: NodeInfoEntity,
|
||||
|
|
@ -89,45 +90,43 @@ struct NodeList: View {
|
|||
}
|
||||
if let connectedNode {
|
||||
/// Favoriting a node requires being connected
|
||||
FavoriteNodeButton(bleManager: bleManager, context: context, node: node)
|
||||
FavoriteNodeButton(node: node)
|
||||
/// Don't show message, trace route, position exchange or delete context menu items for the connected node
|
||||
if connectedNode.num != node.num {
|
||||
if !(node.user?.unmessagable ?? true) {
|
||||
Button(action: {
|
||||
if let url = URL(string: "meshtastic:///messages?userNum=\(node.num)") {
|
||||
UIApplication.shared.open(url)
|
||||
UIApplication.shared.open(url)
|
||||
}
|
||||
}) {
|
||||
Label("Message", systemImage: "message")
|
||||
}
|
||||
}
|
||||
TraceRouteButton(
|
||||
bleManager: bleManager,
|
||||
node: node
|
||||
)
|
||||
Button {
|
||||
let positionSent = bleManager.sendPosition(
|
||||
channel: node.channel,
|
||||
destNum: node.num,
|
||||
wantResponse: true
|
||||
)
|
||||
if positionSent {
|
||||
isPresentingPositionSentAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
isPresentingPositionSentAlert = false
|
||||
}
|
||||
} else {
|
||||
isPresentingPositionFailedAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
isPresentingPositionFailedAlert = false
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.sendPosition(
|
||||
channel: node.channel,
|
||||
destNum: node.num,
|
||||
wantResponse: true
|
||||
)
|
||||
Task { @MainActor in
|
||||
isPresentingPositionSentAlert = true
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) {
|
||||
isPresentingPositionSentAlert = false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Logger.mesh.warning("Failed to sendPosition")
|
||||
}
|
||||
}
|
||||
} label: {
|
||||
Label("Exchange Positions", systemImage: "arrow.triangle.2.circlepath")
|
||||
}
|
||||
IgnoreNodeButton(
|
||||
bleManager: bleManager,
|
||||
context: context,
|
||||
node: node
|
||||
)
|
||||
Button(role: .destructive) {
|
||||
|
|
@ -139,15 +138,15 @@ struct NodeList: View {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
var body: some View {
|
||||
// Use forceRefreshID to completely rebuild the view when notifications update the selected node
|
||||
NavigationSplitView(columnVisibility: $columnVisibility) {
|
||||
List(nodes, id: \.self, selection: $selectedNode) { node in
|
||||
NodeListItem(
|
||||
node: node,
|
||||
connected: bleManager.connectedPeripheral?.num ?? -1 == node.num,
|
||||
connectedNode: bleManager.connectedPeripheral?.num ?? -1
|
||||
isDirectlyConnected: node.num == accessoryManager.activeDeviceNum,
|
||||
connectedNode: accessoryManager.activeConnection?.device.num ?? -1
|
||||
)
|
||||
.contextMenu {
|
||||
contextMenuActions(
|
||||
|
|
@ -199,56 +198,58 @@ struct NodeList: View {
|
|||
isPresented: $isPresentingPositionSentAlert) {
|
||||
Button("OK") { }.keyboardShortcut(.defaultAction)
|
||||
} message: {
|
||||
Text("Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned.")
|
||||
}
|
||||
.alert(
|
||||
"Position Exchange Failed",
|
||||
isPresented: $isPresentingPositionFailedAlert) {
|
||||
Button("OK") { }.keyboardShortcut(.defaultAction)
|
||||
} message: {
|
||||
Text("Failed to get a valid position to exchange")
|
||||
}
|
||||
.alert(
|
||||
"Trace Route Sent",
|
||||
isPresented: $isPresentingTraceRouteSentAlert) {
|
||||
Button("OK") { }.keyboardShortcut(.defaultAction)
|
||||
} message: {
|
||||
Text("This could take a while, response will appear in the trace route log for the node it was sent to.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Are you sure?",
|
||||
isPresented: $isPresentingDeleteNodeAlert,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete Node") {
|
||||
let deleteNode = getNodeInfo(id: deleteNodeId, context: context)
|
||||
if connectedNode != nil {
|
||||
if deleteNode != nil {
|
||||
let success = bleManager.removeNode(node: deleteNode!, connectedNodeNum: Int64(bleManager.connectedPeripheral?.num ?? -1))
|
||||
if !success {
|
||||
Logger.data.error("Failed to delete node \(deleteNode?.user?.longName ?? "Unknown".localized, privacy: .public)")
|
||||
Text("Your position has been sent with a request for a response with their position. You will receive a notification when a position is returned.")
|
||||
}
|
||||
.alert(
|
||||
"Position Exchange Failed",
|
||||
isPresented: $isPresentingPositionFailedAlert) {
|
||||
Button("OK") { }.keyboardShortcut(.defaultAction)
|
||||
} message: {
|
||||
Text("Failed to get a valid position to exchange")
|
||||
}
|
||||
.alert(
|
||||
"Trace Route Sent",
|
||||
isPresented: $isPresentingTraceRouteSentAlert) {
|
||||
Button("OK") { }.keyboardShortcut(.defaultAction)
|
||||
} message: {
|
||||
Text("This could take a while, response will appear in the trace route log for the node it was sent to.")
|
||||
}
|
||||
.confirmationDialog(
|
||||
"Are you sure?",
|
||||
isPresented: $isPresentingDeleteNodeAlert,
|
||||
titleVisibility: .visible
|
||||
) {
|
||||
Button("Delete Node") {
|
||||
let deleteNode = getNodeInfo(id: deleteNodeId, context: context)
|
||||
if connectedNode != nil {
|
||||
if deleteNode != nil {
|
||||
Task {
|
||||
do {
|
||||
try await accessoryManager.removeNode(node: deleteNode!, connectedNodeNum: Int64(accessoryManager.activeDeviceNum ?? -1))
|
||||
} catch {
|
||||
Logger.data.error("Failed to delete node \(deleteNode?.user?.longName ?? "Unknown".localized, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(item: $shareContactNode) { selectedNode in
|
||||
ShareContactQRDialog(node: selectedNode.toProto())
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500)
|
||||
.navigationBarItems(
|
||||
leading: MeshtasticLogo(),
|
||||
trailing: ZStack {
|
||||
ConnectedDevice(
|
||||
bluetoothOn: bleManager.isSwitchedOn,
|
||||
deviceConnected: bleManager.connectedPeripheral != nil,
|
||||
name: bleManager.connectedPeripheral?.shortName ?? "?",
|
||||
phoneOnly: true
|
||||
)
|
||||
}
|
||||
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
|
||||
.accessibilityElement(children: .contain)
|
||||
)
|
||||
.sheet(item: $shareContactNode) { selectedNode in
|
||||
ShareContactQRDialog(node: selectedNode.toProto())
|
||||
}
|
||||
.navigationSplitViewColumnWidth(min: 100, ideal: 250, max: 500)
|
||||
.navigationBarItems(
|
||||
leading: MeshtasticLogo(),
|
||||
trailing: ZStack {
|
||||
ConnectedDevice(
|
||||
deviceConnected: accessoryManager.isConnected,
|
||||
name: accessoryManager.activeConnection?.device.shortName ?? "?",
|
||||
phoneOnly: true
|
||||
)
|
||||
}
|
||||
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
|
||||
.accessibilityElement(children: .contain)
|
||||
)
|
||||
} content: {
|
||||
if let node = selectedNode {
|
||||
NavigationStack {
|
||||
|
|
@ -259,29 +260,21 @@ struct NodeList: View {
|
|||
)
|
||||
.edgesIgnoringSafeArea([.leading, .trailing])
|
||||
.navigationBarItems(
|
||||
leading: MeshtasticLogo(),
|
||||
trailing: ZStack {
|
||||
if UIDevice.current.userInterfaceIdiom != .phone {
|
||||
Button {
|
||||
columnVisibility = .detailOnly
|
||||
} label: {
|
||||
Image(systemName: "rectangle")
|
||||
}
|
||||
.accessibilityLabel("Hide sidebar")
|
||||
}
|
||||
ConnectedDevice(
|
||||
bluetoothOn: bleManager.isSwitchedOn,
|
||||
deviceConnected: bleManager.connectedPeripheral != nil,
|
||||
name: bleManager.connectedPeripheral?.shortName ?? "?",
|
||||
deviceConnected: accessoryManager.isConnected,
|
||||
name: accessoryManager.activeConnection?.device.shortName ?? "?",
|
||||
phoneOnly: true
|
||||
)
|
||||
}
|
||||
// Make sure the ZStack passes through accessibility to the ConnectedDevice component
|
||||
.accessibilityElement(children: .contain)
|
||||
.accessibilityElement(children: .contain)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
} else {
|
||||
ContentUnavailableView("Select Node", systemImage: "flipphone")
|
||||
}
|
||||
}
|
||||
} detail: {
|
||||
ContentUnavailableView("", systemImage: "line.3.horizontal")
|
||||
}
|
||||
|
|
@ -343,7 +336,7 @@ struct NodeList: View {
|
|||
.onChange(of: router.navigationState) {
|
||||
if let selected = router.navigationState.nodeListSelectedNodeNum {
|
||||
// Force a complete view rebuild by generating a new UUID
|
||||
Logger.services.info("Forcing view rebuild with new ID: \(self.forceRefreshID)")
|
||||
Logger.services.info("👷♂️ [App] Forcing view rebuild with new ID: \(self.forceRefreshID, privacy: .public)")
|
||||
// First clear selection
|
||||
self.forceRefreshID = UUID()
|
||||
self.selectedNode = nil
|
||||
|
|
@ -352,7 +345,7 @@ struct NodeList: View {
|
|||
// Generate another UUID to ensure view gets rebuilt
|
||||
self.forceRefreshID = UUID()
|
||||
self.selectedNode = getNodeInfo(id: selected, context: context)
|
||||
Logger.services.info("Complete view refresh with node: \(selected, privacy: .public)")
|
||||
Logger.services.info("👷♂️ [App] Complete view refresh with node: \(selected, privacy: .public)")
|
||||
}
|
||||
} else {
|
||||
self.selectedNode = nil
|
||||
|
|
@ -377,7 +370,7 @@ struct NodeList: View {
|
|||
NotificationCenter.default.removeObserver(self, name: NSNotification.Name("ForceNavigationRefresh"), object: nil)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private func searchNodeList() async {
|
||||
/// Case Insensitive Search Text Predicates
|
||||
let searchPredicates = ["user.userId", "user.numString", "user.hwModel", "user.hwDisplayName", "user.longName", "user.shortName"].map { property in
|
||||
|
|
@ -446,7 +439,7 @@ struct NodeList: View {
|
|||
/// Distance
|
||||
if distanceFilter {
|
||||
let pointOfInterest = LocationsHandler.currentLocation
|
||||
|
||||
|
||||
if pointOfInterest.latitude != LocationsHandler.DefaultLocation.latitude && pointOfInterest.longitude != LocationsHandler.DefaultLocation.longitude {
|
||||
let d: Double = maxDistance * 1.1
|
||||
let r: Double = 6371009
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import OSLog
|
|||
struct PaxCounterLog: View {
|
||||
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
|
||||
@State private var isPresentingClearLogConfirm: Bool = false
|
||||
@State var isExporting = false
|
||||
|
|
@ -203,7 +203,7 @@ struct PaxCounterLog: View {
|
|||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarItems(trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
})
|
||||
.fileExporter(
|
||||
isPresented: $isExporting,
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import OSLog
|
|||
|
||||
struct PositionLog: View {
|
||||
@Environment(\.managedObjectContext) var context
|
||||
@EnvironmentObject var bleManager: BLEManager
|
||||
@EnvironmentObject var accessoryManager: AccessoryManager
|
||||
@Environment(\.verticalSizeClass) var verticalSizeClass: UserInterfaceSizeClass?
|
||||
@Environment(\.horizontalSizeClass) var horizontalSizeClass: UserInterfaceSizeClass?
|
||||
var useGrid: Bool {
|
||||
|
|
@ -174,7 +174,8 @@ struct PositionLog: View {
|
|||
.navigationBarItems(
|
||||
trailing:
|
||||
ZStack {
|
||||
ConnectedDevice(bluetoothOn: bleManager.isSwitchedOn, deviceConnected: bleManager.connectedPeripheral != nil, name: (bleManager.connectedPeripheral != nil) ? bleManager.connectedPeripheral.shortName : "?")
|
||||
ConnectedDevice(deviceConnected: accessoryManager.isConnected, name: accessoryManager.activeConnection?.device.shortName ?? "?")
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue