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:
Garth Vander Houwen 2025-08-27 08:09:02 -07:00 committed by GitHub
parent 85c6c1f58a
commit 026bb80fba
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
138 changed files with 9463 additions and 6381 deletions

60
.swiftlint-precommit.yml Normal file
View 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

View file

@ -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

View file

@ -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" */ = {

View file

@ -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"
}
},
{

View file

@ -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
}
}
}

View file

@ -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
}
}

View file

@ -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)")
}
}
}

View file

@ -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)")
}
}

View file

@ -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

View 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()
}
}
}
}

View 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())
}
}
}

View file

@ -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
}
}

View 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
}
}

View 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)
}

View 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
}
}
}

View 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]
))
}
}

View file

@ -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()
}
}

View file

@ -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) }
}
}

View file

@ -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"
}
}

View 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

View 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

View 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() {
}
}

View 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)
}
}
}

View file

@ -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)")
}
}
}

View file

@ -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")
}

View file

@ -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 {

View file

@ -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")
}

View file

@ -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")
}

View file

@ -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")

View file

@ -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 {

View file

@ -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.")

View file

@ -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()

View file

@ -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 {

View file

@ -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) {

View file

@ -0,0 +1,11 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"idiom" : "universal"
}
]
}

View file

@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"filename" : "custom.bluetooth.svg",
"idiom" : "universal"
}
]
}

View file

@ -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

View file

@ -0,0 +1,12 @@
{
"info" : {
"author" : "xcode",
"version" : 1
},
"symbols" : [
{
"filename" : "custom.link.slash.svg",
"idiom" : "universal"
}
]
}

View file

@ -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

View file

@ -10,7 +10,7 @@ import UniformTypeIdentifiers
struct CsvDocument: FileDocument {
static var readableContentTypes = [UTType.commaSeparatedText]
static let readableContentTypes = [UTType.commaSeparatedText]
@State var csvData: String

View file

@ -71,7 +71,7 @@ extension UserEntity {
return "TLORAC6"
case "TLORAT3S3EPAPER":
return "TLORAT3S3EPAPER"
case "TLORAT3S3V1", "TLORAT3S3" :
case "TLORAT3S3V1", "TLORAT3S3":
return "TLORAT3S3V1"
case "TLORAV211P6":
return "TLORAV211P6"

View file

@ -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] {

View file

@ -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

View file

@ -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(

View file

@ -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.

View file

@ -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)

View file

@ -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&apos;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>

View file

@ -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>

View file

@ -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)
}
}

View file

@ -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

View file

@ -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()

View file

@ -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"
}
]

View file

@ -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?

View file

@ -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)")
}
}

View file

View 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")

View file

@ -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)
}
}

View 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)
}
}
})
}
}
}

View file

@ -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()
}

View file

@ -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)
}
}
}

View file

@ -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")
}

View file

@ -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)
}

View file

@ -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))
}
}

View file

@ -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)
})

View file

@ -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
}
}

View 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
}
}
}
}

View 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)
}
}
}

View file

@ -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")
}

View file

@ -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
)
}
}
}
}
}

View file

@ -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)

View file

@ -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) {}
}
}
}
}

View file

@ -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 {

View file

@ -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) {}

View file

@ -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")
}
}
}

View file

@ -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")
}
}

View file

@ -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 ?? "?")
}
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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,

View file

@ -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",

View file

@ -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)")
}
}
}
}

View file

@ -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
}
}
}
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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 {

View file

@ -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)

View 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

View file

@ -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 ?? "?")
})
}

View file

@ -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

View file

@ -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 {

View file

@ -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")
}
}
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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

View file

@ -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,

View file

@ -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