Meshtastic-Apple/Meshtastic/Accessory/Transports/TCP/TCPConnection.swift
Garth Vander Houwen 026bb80fba
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>
2025-08-27 08:09:02 -07:00

227 lines
6.3 KiB
Swift

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