Die IceCube-Boxen von Plug-in Festivals sind umgelabelte Alpicool-Boxen – der Hersteller verweist selbst auf die App "Alpicool T-Series". Bestätigt am Gerät: die Charakteristiken 00001235 und 00001236 tauchen im GATT-Baum auf. Dasselbe Protokoll sprechen BrassMonkey und Ocean Comfort. AlpicoolProtocol baut und liest die Rahmen (FE FE, Länge, Kommando, Daten, Bytesummen-Prüfsumme) und wertet die Statusantwort aus: Ist- und Solltemperatur je Zone, Betriebsart, Kompressorstatus, Bedienfeldsperre, Bordspannung und Batterieanzeige. Zweizonen-Boxen werden an der Nutzlastlänge erkannt. Auf Stellbefehle antwortet die Box mit Echo und Status in einer einzigen Benachrichtigung, weshalb der Rahmenleser weitersucht statt nach dem ersten Treffer abzubrechen; das Echo läuft mangels Statuslänge ins Leere. Als bisher einziges Gerät ist die Box auch stellbar: Ein/Aus, Eco/Max, Solltemperatur je Zone und Bedienfeldsperre. Nach jedem Stellbefehl wird der Zustand neu abgefragt, sodass die Schalter dem Gerät folgen und nicht der Eingabe. Ausschalten fragt einmal nach, weil dabei die Kühlung stoppt. Ein/Aus und Betriebsart brauchen den kompletten Einstellungsblock: er wird aus dem letzten Status neu aufgebaut und nur im betroffenen Byte geändert, sonst überschriebe die Box eigene Einstellungen mit Nullen. Ohne bekannten Status entsteht gar kein Stellbefehl. Die Namensprüfung in der Geräteliste hiess bisher looksLikeDaly und hätte Kühlboxen aus der Vorauswahl geworfen; sie deckt jetzt alle unterstützten Geräte ab und belegt die Art beim Einrichten vor. Protokoll und Feldbelegung nach Gruni22/alpicool_ha_ble. Die festen Anmelde- und Abfragepakete der Referenz sind byteweise abgesichert und belegen zugleich, dass die allgemeine Prüfsummenregel auch für sie gilt. 139 Prüfungen laufen durch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
498 lines
20 KiB
Swift
498 lines
20 KiB
Swift
import CoreBluetooth
|
||
import Foundation
|
||
import Observation
|
||
|
||
/// Ein während des Einrichtens gefundenes Gerät.
|
||
struct Discovery: Identifiable, Hashable {
|
||
let id: UUID
|
||
var name: String?
|
||
/// Geglättet, damit die Signalanzeige nicht flackert.
|
||
var rssi: Int
|
||
/// Sortierschlüssel: die Reihenfolge des Auftauchens ist stabil,
|
||
/// die Signalstärke wäre es nicht.
|
||
var firstSeen: Date
|
||
var lastSeen: Date
|
||
var victronRecordType: UInt8?
|
||
var victronProductID: UInt16?
|
||
var looksLikeSupported: Bool
|
||
|
||
var isVictron: Bool { victronRecordType != nil }
|
||
|
||
var displayName: String {
|
||
if let name, !name.isEmpty { return name }
|
||
return isVictron ? "Victron-Gerät" : "Unbenanntes Gerät"
|
||
}
|
||
|
||
var subtitle: String {
|
||
if let type = victronRecordType {
|
||
return "Victron · " + Self.victronRecordName(type)
|
||
}
|
||
if looksLikeSupported { return "Sieht nach BMS oder Kühlbox aus" }
|
||
return "Bluetooth-Gerät"
|
||
}
|
||
|
||
static func victronRecordName(_ type: UInt8) -> String {
|
||
switch VictronAdvertisement.RecordType(rawValue: type) {
|
||
case .solarCharger: return "Solarladeregler"
|
||
case .batteryMonitor: return "Batteriewächter"
|
||
case .inverter: return "Wechselrichter"
|
||
case .dcdcConverter: return "DC/DC-Lader (Orion-TR)"
|
||
case .orionXS: return "Orion XS"
|
||
case .acCharger: return "AC-Ladegerät"
|
||
case .smartLithium: return "Smart Lithium"
|
||
case .smartBatteryProtect: return "Battery Protect"
|
||
case .lynxSmartBMS: return "Lynx Smart BMS"
|
||
case .dcEnergyMeter: return "DC-Energiezähler"
|
||
default: return String(format: "Typ 0x%02X", type)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Was ein Victron-Gerät unverschlüsselt mitsendet. Wird unabhängig vom
|
||
/// Schlüssel gefüllt und hilft, eine falsche Eingabe einzugrenzen.
|
||
struct VictronDiagnostics: Hashable {
|
||
var productID: UInt16
|
||
var recordType: UInt8
|
||
var expectedKeyFirstByte: UInt8
|
||
var nonce: UInt16
|
||
var payloadLength: Int
|
||
var rawHex: String
|
||
var updated: Date
|
||
|
||
var recordName: String { Discovery.victronRecordName(recordType) }
|
||
var productIDText: String { String(format: "0x%04X", productID) }
|
||
var expectedKeyText: String { String(format: "0x%02X", expectedKeyFirstByte) }
|
||
}
|
||
|
||
/// Was bei der BMS-Verbindung erkannt wurde. Zeigt, ob und welches Protokoll
|
||
/// greift, und gibt die letzte Rohantwort zum Nachsehen aus.
|
||
struct BMSDiagnostics: Hashable {
|
||
var dialect: String
|
||
/// Welche Schreib-/Notify-Kombination gerade versucht wird.
|
||
var endpointLabel: String?
|
||
/// Der wievielte von wie vielen Kandidaten das ist.
|
||
var endpointPosition: Pair?
|
||
var serviceUUID: String?
|
||
var isConnected: Bool
|
||
/// Ob das Gerät das Abonnieren der Notify-Charakteristik bestätigt hat.
|
||
var isNotifyActive: Bool
|
||
/// Vollständiger Dienst-/Merkmalsbaum des Geräts.
|
||
var gattSummary: [String]
|
||
var sentFrames: Int
|
||
var receivedBytes: Int
|
||
var lastSendAt: Date?
|
||
var lastResponseHex: String?
|
||
var updated: Date
|
||
|
||
struct Pair: Hashable {
|
||
var index: Int
|
||
var total: Int
|
||
init(_ index: Int, _ total: Int) { self.index = index; self.total = total }
|
||
}
|
||
}
|
||
|
||
|
||
/// Ein Messpunkt für die Verlaufsgrafik.
|
||
struct HistorySample: Identifiable, Hashable {
|
||
let id = UUID()
|
||
let time: Date
|
||
let value: Double
|
||
}
|
||
|
||
/// Zentrale Bluetooth-Schicht: scannt dauerhaft nach Victron-Werbedaten und
|
||
/// hält parallel die Verbindung zum Daly-BMS.
|
||
@Observable
|
||
final class BluetoothManager: NSObject {
|
||
|
||
private(set) var snapshots: [UUID: DeviceSnapshot] = [:]
|
||
private(set) var linkStates: [UUID: DeviceLinkState] = [:]
|
||
private(set) var discoveries: [UUID: Discovery] = [:]
|
||
private(set) var history: [UUID: [HistorySample]] = [:]
|
||
private(set) var diagnostics: [UUID: VictronDiagnostics] = [:]
|
||
private(set) var bmsDiagnostics: [UUID: BMSDiagnostics] = [:]
|
||
/// Bedienzustand der Kühlboxen, damit die Schalter dem Gerät folgen.
|
||
private(set) var fridgeStates: [UUID: AlpicoolState] = [:]
|
||
private(set) var isBluetoothReady = false
|
||
private(set) var bluetoothStatusText = "Bluetooth wird gestartet…"
|
||
|
||
/// Solange true, werden alle gefundenen Peripherals gesammelt.
|
||
var isDiscovering = false {
|
||
didSet {
|
||
restartScan()
|
||
isDiscovering ? startDiscoveryFlush() : stopDiscoveryFlush()
|
||
}
|
||
}
|
||
|
||
private let store: DeviceStore
|
||
private var central: CBCentralManager?
|
||
private var bmsSessions: [UUID: BMSSession] = [:]
|
||
private var connectedPeripherals: [UUID: CBPeripheral] = [:]
|
||
private var reconnectTimer: Timer?
|
||
|
||
/// Advertisements treffen mehrmals pro Sekunde und Gerät ein. Sie werden
|
||
/// hier gesammelt und nur im Sekundentakt an die Ansicht durchgereicht –
|
||
/// sonst baut sich die Liste unter dem Finger ständig neu auf.
|
||
private var pendingDiscoveries: [UUID: Discovery] = [:]
|
||
private var discoveryFlushTimer: Timer?
|
||
|
||
/// Wieviele Messpunkte je Gerät im Verlauf behalten werden.
|
||
private let historyLimit = 720
|
||
|
||
/// Im Demo-Modus wird nichts gefunkt, die Werte kommen aus `DemoData`.
|
||
private let isDemo = DemoData.isEnabled
|
||
|
||
init(store: DeviceStore) {
|
||
self.store = store
|
||
super.init()
|
||
if isDemo {
|
||
loadDemoData()
|
||
return
|
||
}
|
||
central = CBCentralManager(delegate: self, queue: .main)
|
||
}
|
||
|
||
private func loadDemoData() {
|
||
isBluetoothReady = true
|
||
bluetoothStatusText = "Demo-Modus"
|
||
fridgeStates[DemoData.fridge.id] = DemoData.fridgeState
|
||
for snapshot in DemoData.snapshots() {
|
||
snapshots[snapshot.deviceID] = snapshot
|
||
linkStates[snapshot.deviceID] = .live
|
||
if let value = snapshot.primaryMetric?.value {
|
||
history[snapshot.deviceID] = DemoData.history(for: snapshot.deviceID, around: value)
|
||
}
|
||
}
|
||
}
|
||
|
||
// MARK: - Steuerung
|
||
|
||
func start() {
|
||
guard !isDemo, central?.state == .poweredOn else { return }
|
||
restartScan()
|
||
connectManagedPeripherals()
|
||
scheduleReconnects()
|
||
}
|
||
|
||
func stop() {
|
||
guard !isDemo else { return }
|
||
central?.stopScan()
|
||
reconnectTimer?.invalidate()
|
||
reconnectTimer = nil
|
||
stopDiscoveryFlush()
|
||
for (_, session) in bmsSessions { session.stop() }
|
||
for (_, peripheral) in connectedPeripherals { central?.cancelPeripheralConnection(peripheral) }
|
||
bmsSessions.removeAll()
|
||
connectedPeripherals.removeAll()
|
||
}
|
||
|
||
/// Nach Änderungen an der Geräteliste aufrufen.
|
||
func refreshConfiguration() {
|
||
guard !isDemo else { return }
|
||
let managed = Set(store.activeDevices.filter { $0.role.transport == .connect }.map(\.peripheralID))
|
||
for (peripheralID, session) in bmsSessions where !managed.contains(peripheralID) {
|
||
session.stop()
|
||
bmsSessions[peripheralID] = nil
|
||
if let peripheral = connectedPeripherals[peripheralID] {
|
||
central?.cancelPeripheralConnection(peripheral)
|
||
connectedPeripherals[peripheralID] = nil
|
||
}
|
||
}
|
||
let known = Set(store.activeDevices.map(\.id))
|
||
snapshots = snapshots.filter { known.contains($0.key) }
|
||
linkStates = linkStates.filter { known.contains($0.key) }
|
||
history = history.filter { known.contains($0.key) }
|
||
diagnostics = diagnostics.filter { known.contains($0.key) }
|
||
bmsDiagnostics = bmsDiagnostics.filter { known.contains($0.key) }
|
||
fridgeStates = fridgeStates.filter { known.contains($0.key) }
|
||
connectManagedPeripherals()
|
||
restartScan()
|
||
}
|
||
|
||
func clearDiscoveries() {
|
||
discoveries.removeAll()
|
||
pendingDiscoveries.removeAll()
|
||
}
|
||
|
||
private func startDiscoveryFlush() {
|
||
discoveryFlushTimer?.invalidate()
|
||
discoveryFlushTimer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { [weak self] _ in
|
||
guard let self else { return }
|
||
// Verschwundene Geräte erst nach einer Weile fallen lassen, damit
|
||
// ein Eintrag nicht wegen eines verpassten Pakets verschwindet.
|
||
let cutoff = Date().addingTimeInterval(-20)
|
||
self.pendingDiscoveries = self.pendingDiscoveries.filter { $0.value.lastSeen > cutoff }
|
||
self.discoveries = self.pendingDiscoveries
|
||
}
|
||
}
|
||
|
||
private func stopDiscoveryFlush() {
|
||
discoveryFlushTimer?.invalidate()
|
||
discoveryFlushTimer = nil
|
||
}
|
||
|
||
// MARK: - Scannen
|
||
|
||
private func restartScan() {
|
||
guard !isDemo, central?.state == .poweredOn else { return }
|
||
central?.stopScan()
|
||
// Victron sendet seine Werte im Advertisement, also müssen auch
|
||
// Wiederholungen durchgereicht werden.
|
||
central?.scanForPeripherals(
|
||
withServices: nil,
|
||
options: [CBCentralManagerScanOptionAllowDuplicatesKey: true]
|
||
)
|
||
}
|
||
|
||
private func connectManagedPeripherals() {
|
||
guard !isDemo, central?.state == .poweredOn else { return }
|
||
for device in store.activeDevices where device.role.transport == .connect {
|
||
connectIfNeeded(device)
|
||
}
|
||
}
|
||
|
||
private func connectIfNeeded(_ device: ConfiguredDevice) {
|
||
let peripheralID = device.peripheralID
|
||
if let existing = connectedPeripherals[peripheralID],
|
||
existing.state == .connected || existing.state == .connecting {
|
||
return
|
||
}
|
||
guard let peripheral = central?.retrievePeripherals(withIdentifiers: [peripheralID]).first else {
|
||
linkStates[device.id] = .searching
|
||
return
|
||
}
|
||
connectedPeripherals[peripheralID] = peripheral
|
||
linkStates[device.id] = .connecting
|
||
central?.connect(peripheral, options: nil)
|
||
}
|
||
|
||
/// Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig
|
||
/// nachfassen statt nur auf das Disconnect-Ereignis zu reagieren.
|
||
private func scheduleReconnects() {
|
||
reconnectTimer?.invalidate()
|
||
reconnectTimer = Timer.scheduledTimer(withTimeInterval: 15, repeats: true) { [weak self] _ in
|
||
self?.connectManagedPeripherals()
|
||
}
|
||
}
|
||
|
||
// MARK: - Kühlbox steuern
|
||
|
||
private func fridgeSession(for deviceID: UUID) -> BMSSession? {
|
||
guard let device = store.devices.first(where: { $0.id == deviceID }) else { return nil }
|
||
return bmsSessions[device.peripheralID]
|
||
}
|
||
|
||
/// Solltemperatur einer Zone setzen.
|
||
func setFridgeTarget(_ celsius: Int, zone: AlpicoolState.Zone, for deviceID: UUID) {
|
||
fridgeSession(for: deviceID)?
|
||
.sendControl(AlpicoolState.setTarget(zone: zone, to: celsius))
|
||
}
|
||
|
||
/// Kühlbox ein- oder ausschalten.
|
||
func setFridgePower(_ on: Bool, for deviceID: UUID) {
|
||
guard let session = fridgeSession(for: deviceID),
|
||
let packet = session.alpicoolState.settingsCommand(poweredOn: on) else { return }
|
||
session.sendControl(packet)
|
||
}
|
||
|
||
/// Zwischen sparsamem und schnellem Kühlen umschalten.
|
||
func setFridgeEco(_ eco: Bool, for deviceID: UUID) {
|
||
guard let session = fridgeSession(for: deviceID),
|
||
let packet = session.alpicoolState.settingsCommand(eco: eco) else { return }
|
||
session.sendControl(packet)
|
||
}
|
||
|
||
/// Bedienfeld der Box sperren oder freigeben.
|
||
func setFridgeLock(_ locked: Bool, for deviceID: UUID) {
|
||
guard let session = fridgeSession(for: deviceID),
|
||
let packet = session.alpicoolState.settingsCommand(locked: locked) else { return }
|
||
session.sendControl(packet)
|
||
}
|
||
|
||
// MARK: - Auswertung
|
||
|
||
private func record(_ snapshot: DeviceSnapshot) {
|
||
snapshots[snapshot.deviceID] = snapshot
|
||
guard let primary = snapshot.primaryMetric, let value = primary.value else { return }
|
||
var samples = history[snapshot.deviceID] ?? []
|
||
// Höchstens alle fünf Sekunden einen Punkt aufnehmen.
|
||
if let last = samples.last, snapshot.timestamp.timeIntervalSince(last.time) < 5 { return }
|
||
samples.append(HistorySample(time: snapshot.timestamp, value: value))
|
||
if samples.count > historyLimit { samples.removeFirst(samples.count - historyLimit) }
|
||
history[snapshot.deviceID] = samples
|
||
}
|
||
|
||
private func handleVictronAdvertisement(_ manufacturerData: Data,
|
||
device: ConfiguredDevice,
|
||
rssi: Int) {
|
||
// Zuerst den unverschlüsselten Rahmen festhalten – gerade wenn der
|
||
// Schlüssel nicht passt, ist das die einzige verwertbare Information.
|
||
if let envelope = VictronAdvertisement.envelope(from: manufacturerData) {
|
||
diagnostics[device.id] = VictronDiagnostics(
|
||
productID: envelope.productID,
|
||
recordType: envelope.recordType,
|
||
expectedKeyFirstByte: envelope.keyCheckByte,
|
||
nonce: envelope.nonce,
|
||
payloadLength: envelope.ciphertext.count,
|
||
rawHex: manufacturerData.map { String(format: "%02X", $0) }.joined(separator: " "),
|
||
updated: Date()
|
||
)
|
||
}
|
||
|
||
guard let key = store.victronKeyBytes(for: device.id) else {
|
||
linkStates[device.id] = .needsKey
|
||
return
|
||
}
|
||
do {
|
||
let snapshot = try VictronAdvertisement.decode(manufacturerData: manufacturerData,
|
||
key: key,
|
||
deviceID: device.id,
|
||
rssi: rssi)
|
||
linkStates[device.id] = .live
|
||
record(snapshot)
|
||
} catch let error as VictronAdvertisement.DecodeError {
|
||
switch error {
|
||
case .notVictron:
|
||
break // Fremdes Advertisement desselben Peripherals, ignorieren.
|
||
default:
|
||
linkStates[device.id] = .failed(error.localizedDescription)
|
||
}
|
||
} catch {
|
||
linkStates[device.id] = .failed(error.localizedDescription)
|
||
}
|
||
}
|
||
|
||
private func updateDiscovery(peripheral: CBPeripheral,
|
||
advertisementData: [String: Any],
|
||
rssi: Int) {
|
||
let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||
let envelope = manufacturerData.flatMap { VictronAdvertisement.envelope(from: $0) }
|
||
let name = (advertisementData[CBAdvertisementDataLocalNameKey] as? String) ?? peripheral.name
|
||
|
||
let existing = pendingDiscoveries[peripheral.identifier]
|
||
var entry = existing ?? Discovery(
|
||
id: peripheral.identifier,
|
||
name: name,
|
||
rssi: rssi,
|
||
firstSeen: Date(),
|
||
lastSeen: Date(),
|
||
victronRecordType: nil,
|
||
victronProductID: nil,
|
||
looksLikeSupported: false
|
||
)
|
||
// Gleitender Mittelwert über die letzten Messungen.
|
||
entry.rssi = existing.map { ($0.rssi * 3 + rssi) / 4 } ?? rssi
|
||
entry.lastSeen = Date()
|
||
if let name, !name.isEmpty { entry.name = name }
|
||
if let envelope {
|
||
entry.victronRecordType = envelope.recordType
|
||
entry.victronProductID = envelope.productID
|
||
}
|
||
entry.looksLikeSupported = Self.looksLikeSupported(name: entry.name)
|
||
pendingDiscoveries[peripheral.identifier] = entry
|
||
}
|
||
|
||
/// Grobe Namensprüfung für die Vorauswahl in der Geräteliste. Nur ein
|
||
/// Hinweis – auswählbar ist über "Alle" weiterhin jedes Gerät.
|
||
private static func looksLikeSupported(name: String?) -> Bool {
|
||
guard let name = name?.lowercased() else { return false }
|
||
let needles = [
|
||
"daly", "bms", "bulltron", "wattcycle", // Batterien
|
||
"alpicool", "icecube", "ice cube", "fridge", "cool", // Kühlboxen
|
||
]
|
||
return name.hasPrefix("dl-") || name.hasPrefix("wt")
|
||
|| needles.contains { name.contains($0) }
|
||
}
|
||
}
|
||
|
||
// MARK: - CBCentralManagerDelegate
|
||
|
||
extension BluetoothManager: CBCentralManagerDelegate {
|
||
|
||
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
||
switch central.state {
|
||
case .poweredOn:
|
||
isBluetoothReady = true
|
||
bluetoothStatusText = "Bereit"
|
||
start()
|
||
case .poweredOff:
|
||
isBluetoothReady = false
|
||
bluetoothStatusText = "Bluetooth ist ausgeschaltet"
|
||
case .unauthorized:
|
||
isBluetoothReady = false
|
||
bluetoothStatusText = "Bluetooth-Zugriff wurde nicht erlaubt"
|
||
case .unsupported:
|
||
isBluetoothReady = false
|
||
bluetoothStatusText = "Dieses Gerät unterstützt kein Bluetooth LE"
|
||
default:
|
||
isBluetoothReady = false
|
||
bluetoothStatusText = "Bluetooth wird gestartet…"
|
||
}
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didDiscover peripheral: CBPeripheral,
|
||
advertisementData: [String: Any],
|
||
rssi RSSI: NSNumber) {
|
||
let rssi = RSSI.intValue
|
||
|
||
if isDiscovering {
|
||
updateDiscovery(peripheral: peripheral, advertisementData: advertisementData, rssi: rssi)
|
||
}
|
||
|
||
guard let device = store.device(withPeripheralID: peripheral.identifier) else { return }
|
||
|
||
switch device.role.transport {
|
||
case .advertisement:
|
||
guard let manufacturerData = advertisementData[CBAdvertisementDataManufacturerDataKey] as? Data
|
||
else { return }
|
||
handleVictronAdvertisement(manufacturerData, device: device, rssi: rssi)
|
||
|
||
case .connect:
|
||
// Das BMS wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen.
|
||
if connectedPeripherals[peripheral.identifier] == nil {
|
||
connectIfNeeded(device)
|
||
}
|
||
}
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
||
guard let device = store.device(withPeripheralID: peripheral.identifier) else { return }
|
||
connectedPeripherals[peripheral.identifier] = peripheral
|
||
|
||
let session = BMSSession(
|
||
deviceID: device.id,
|
||
peripheral: peripheral,
|
||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||
onStateChange: { [weak self] state in self?.linkStates[device.id] = state },
|
||
onDiagnostics: { [weak self] info in self?.bmsDiagnostics[device.id] = info }
|
||
)
|
||
session.onFridgeState = { [weak self] state in self?.fridgeStates[device.id] = state }
|
||
bmsSessions[peripheral.identifier] = session
|
||
session.start()
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didFailToConnect peripheral: CBPeripheral,
|
||
error: Error?) {
|
||
if let device = store.device(withPeripheralID: peripheral.identifier) {
|
||
linkStates[device.id] = .failed(error?.localizedDescription ?? "Verbindung fehlgeschlagen")
|
||
}
|
||
connectedPeripherals[peripheral.identifier] = nil
|
||
}
|
||
|
||
func centralManager(_ central: CBCentralManager,
|
||
didDisconnectPeripheral peripheral: CBPeripheral,
|
||
error: Error?) {
|
||
bmsSessions[peripheral.identifier]?.handleDisconnect()
|
||
bmsSessions[peripheral.identifier] = nil
|
||
connectedPeripherals[peripheral.identifier] = nil
|
||
|
||
if let device = store.device(withPeripheralID: peripheral.identifier) {
|
||
linkStates[device.id] = .searching
|
||
// Direkt wieder anfragen; iOS stellt die Verbindung her, sobald das
|
||
// Gerät wieder in Reichweite ist.
|
||
connectIfNeeded(device)
|
||
}
|
||
}
|
||
}
|