BMS: alle Schreib-/Empfangs-Kombinationen durchprobieren
Die WattCycle-Batterie meldete sich am FFF0-Dienst, nahm aber keine Kommandos an. Grund: dort ist FFF1 die erste beschreibbare Charakteristik, sie sieht schreibbar aus und bleibt trotzdem stumm – Kommandos gehören auf FFF2. Statt die richtige Charakteristik zu raten, stellt BMSSession jetzt alle Paare aus schreibbarer und benachrichtigender Charakteristik zusammen, jeweils mit und ohne Schreibbestätigung, und arbeitet sie ab, bis eines antwortet. Bekannte Paare (FFF2/FFF1, FF02/FF01, Nordic UART) kommen zuerst dran; ein Kandidat, der sich nicht abonnieren lässt, wird sofort übersprungen. Auf jedem Weg werden weiterhin Daly klassisch, Daly Modbus und JBD angefragt. Die Diagnose zeigt dazu den vollständigen GATT-Baum des Geräts, den gerade versuchten Weg samt Position in der Kandidatenliste sowie Zähler für gesendete Anfragen und empfangene Bytes. Damit lässt sich unterscheiden, ob das BMS die Kommandos gar nicht annimmt oder ein unbekanntes Protokoll spricht. Nebenbei: "caravan" existiert nicht als SF-Symbol und ließ SwiftUI stillschweigend auf Text zurückfallen – in den Demodaten ersetzt. README auf Profile und WattCycle/JBD nachgezogen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,22 +4,27 @@ import Foundation
|
|||||||
/// Hält die GATT-Verbindung zu einem BMS, pollt die Werte und meldet fertige
|
/// Hält die GATT-Verbindung zu einem BMS, pollt die Werte und meldet fertige
|
||||||
/// Snapshots zurück.
|
/// Snapshots zurück.
|
||||||
///
|
///
|
||||||
/// Unterstützt drei Dialekte und erkennt selbst, welchen das Gerät spricht:
|
/// Zwei Dinge sind bei diesen Geräten nicht vorhersehbar und werden deshalb
|
||||||
|
/// ausprobiert statt vorausgesetzt:
|
||||||
///
|
///
|
||||||
/// * **Daly klassisch** – 13-Byte-Rahmen, beginnend mit `A5`
|
/// 1. **Über welche Charakteristiken gesprochen wird.** Im selben Dienst sehen
|
||||||
/// * **Daly Modbus** – `D2 03 …`, neuere Daly-Firmware
|
/// oft mehrere Charakteristiken beschreibbar aus, nur eine nimmt aber
|
||||||
/// * **JBD / Xiaoxiang** – `DD A5 …`, u.a. in WattCycle-Akkus
|
/// wirklich Kommandos an. Die Session stellt alle sinnvollen Paare aus
|
||||||
///
|
/// Schreib- und Benachrichtigungs-Charakteristik zusammen und arbeitet sie
|
||||||
/// Auch die BLE-Charakteristiken werden gesucht statt vorausgesetzt: die Module
|
/// der Reihe nach ab, bis eines antwortet.
|
||||||
/// unterscheiden sich zwischen Herstellern und Fertigungschargen.
|
/// 2. **Welches Protokoll gesprochen wird.** Auf jedem Paar werden Daly
|
||||||
|
/// (klassisch und Modbus) und JBD/Xiaoxiang angefragt; der erste gültige
|
||||||
|
/// Rahmen legt den Dialekt fest.
|
||||||
final class BMSSession: NSObject {
|
final class BMSSession: NSObject {
|
||||||
|
|
||||||
/// Bekannte Dienste, in Reihenfolge der Wahrscheinlichkeit.
|
/// Bekannte Paare, die zuerst versucht werden.
|
||||||
private static let preferredServices: [CBUUID] = [
|
private static let knownPairs: [(service: String, write: String, notify: String)] = [
|
||||||
CBUUID(string: "FFF0"), // Daly
|
("FFF0", "FFF2", "FFF1"), // Daly und viele baugleiche Module
|
||||||
CBUUID(string: "FF00"), // JBD
|
("FF00", "FF02", "FF01"), // JBD / Xiaoxiang
|
||||||
CBUUID(string: "FFE0"),
|
("FFE0", "FFE1", "FFE1"),
|
||||||
CBUUID(string: "6E400001-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
|
("6E400001-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||||
|
"6E400002-B5A3-F393-E0A9-E50E24DCCA9E",
|
||||||
|
"6E400003-B5A3-F393-E0A9-E50E24DCCA9E"), // Nordic UART
|
||||||
]
|
]
|
||||||
|
|
||||||
enum Dialect: String {
|
enum Dialect: String {
|
||||||
@@ -29,25 +34,51 @@ final class BMSSession: NSObject {
|
|||||||
case jbd = "JBD / Xiaoxiang"
|
case jbd = "JBD / Xiaoxiang"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Ein Kandidat: worüber geschrieben, worüber gelauscht und wie geschrieben
|
||||||
|
/// wird. Der Schreibmodus gehört dazu, weil manche Module nur die eine oder
|
||||||
|
/// nur die andere Variante annehmen.
|
||||||
|
private struct Endpoint {
|
||||||
|
let write: CBCharacteristic
|
||||||
|
let notify: CBCharacteristic
|
||||||
|
let writeType: CBCharacteristicWriteType
|
||||||
|
let isKnownPair: Bool
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
let mode = writeType == .withoutResponse ? "ohne Bestätigung" : "mit Bestätigung"
|
||||||
|
return "\(write.uuid.uuidString) → \(notify.uuid.uuidString), \(mode)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let deviceID: UUID
|
let deviceID: UUID
|
||||||
private let peripheral: CBPeripheral
|
private let peripheral: CBPeripheral
|
||||||
private let onUpdate: (DeviceSnapshot) -> Void
|
private let onUpdate: (DeviceSnapshot) -> Void
|
||||||
private let onStateChange: (DeviceLinkState) -> Void
|
private let onStateChange: (DeviceLinkState) -> Void
|
||||||
private let onDiagnostics: (BMSDiagnostics) -> Void
|
private let onDiagnostics: (BMSDiagnostics) -> Void
|
||||||
|
|
||||||
private var writeCharacteristic: CBCharacteristic?
|
private var endpoints: [Endpoint] = []
|
||||||
private var notifyCharacteristic: CBCharacteristic?
|
private var endpointIndex = 0
|
||||||
|
private var pendingServices = 0
|
||||||
|
|
||||||
private(set) var dialect: Dialect = .unknown
|
private(set) var dialect: Dialect = .unknown
|
||||||
private var dalyState = DalyState()
|
private var dalyState = DalyState()
|
||||||
private var jbdState = JBDState()
|
private var jbdState = JBDState()
|
||||||
private var buffer: [UInt8] = []
|
private var buffer: [UInt8] = []
|
||||||
private var pollTimer: Timer?
|
private var pollTimer: Timer?
|
||||||
private var silentRounds = 0
|
|
||||||
private var lastResponse: Data?
|
private var lastResponse: Data?
|
||||||
|
private var receivedByteCount = 0
|
||||||
|
private var sentFrameCount = 0
|
||||||
|
private var gattSummary: [String] = []
|
||||||
|
/// Runden ohne verwertbare Antwort auf dem aktuellen Kandidaten.
|
||||||
|
private var silentRounds = 0
|
||||||
|
|
||||||
/// Abstand zwischen zwei Abfragerunden.
|
/// Abstand zwischen zwei Abfragerunden im Normalbetrieb.
|
||||||
var pollInterval: TimeInterval = 5
|
var pollInterval: TimeInterval = 5
|
||||||
|
/// Kürzer, solange noch gesucht wird – sonst dauert das Durchprobieren lang.
|
||||||
|
private var searchInterval: TimeInterval = 8
|
||||||
|
|
||||||
|
private var currentEndpoint: Endpoint? {
|
||||||
|
endpoints.indices.contains(endpointIndex) ? endpoints[endpointIndex] : nil
|
||||||
|
}
|
||||||
|
|
||||||
init(deviceID: UUID,
|
init(deviceID: UUID,
|
||||||
peripheral: CBPeripheral,
|
peripheral: CBPeripheral,
|
||||||
@@ -73,11 +104,11 @@ final class BMSSession: NSObject {
|
|||||||
func stop() {
|
func stop() {
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
pollTimer = nil
|
pollTimer = nil
|
||||||
if let notifyCharacteristic, peripheral.state == .connected {
|
if let notify = currentEndpoint?.notify, peripheral.state == .connected {
|
||||||
peripheral.setNotifyValue(false, for: notifyCharacteristic)
|
peripheral.setNotifyValue(false, for: notify)
|
||||||
}
|
}
|
||||||
writeCharacteristic = nil
|
endpoints.removeAll()
|
||||||
notifyCharacteristic = nil
|
endpointIndex = 0
|
||||||
dialect = .unknown
|
dialect = .unknown
|
||||||
buffer.removeAll()
|
buffer.removeAll()
|
||||||
}
|
}
|
||||||
@@ -85,80 +116,148 @@ final class BMSSession: NSObject {
|
|||||||
func handleDisconnect() {
|
func handleDisconnect() {
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
pollTimer = nil
|
pollTimer = nil
|
||||||
writeCharacteristic = nil
|
endpoints.removeAll()
|
||||||
notifyCharacteristic = nil
|
endpointIndex = 0
|
||||||
buffer.removeAll()
|
buffer.removeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Kandidaten
|
||||||
|
|
||||||
|
/// Stellt nach der Dienstsuche alle Paare zusammen: bekannte Kombinationen
|
||||||
|
/// zuerst, danach jede andere Schreib-/Notify-Kombination im selben Dienst.
|
||||||
|
private func buildEndpoints() {
|
||||||
|
var candidates: [Endpoint] = []
|
||||||
|
|
||||||
|
for service in peripheral.services ?? [] {
|
||||||
|
let characteristics = service.characteristics ?? []
|
||||||
|
let writable = characteristics.filter {
|
||||||
|
$0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse)
|
||||||
|
}
|
||||||
|
let notifying = characteristics.filter {
|
||||||
|
$0.properties.contains(.notify) || $0.properties.contains(.indicate)
|
||||||
|
}
|
||||||
|
guard !writable.isEmpty, !notifying.isEmpty else { continue }
|
||||||
|
|
||||||
|
for write in writable {
|
||||||
|
for notify in notifying {
|
||||||
|
let known = Self.knownPairs.contains {
|
||||||
|
CBUUID(string: $0.service) == service.uuid
|
||||||
|
&& CBUUID(string: $0.write) == write.uuid
|
||||||
|
&& CBUUID(string: $0.notify) == notify.uuid
|
||||||
|
}
|
||||||
|
// Beide Schreibarten anbieten, sofern das Gerät sie kann.
|
||||||
|
if write.properties.contains(.writeWithoutResponse) {
|
||||||
|
candidates.append(Endpoint(write: write, notify: notify,
|
||||||
|
writeType: .withoutResponse, isKnownPair: known))
|
||||||
|
}
|
||||||
|
if write.properties.contains(.write) {
|
||||||
|
candidates.append(Endpoint(write: write, notify: notify,
|
||||||
|
writeType: .withResponse, isKnownPair: known))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bekannte Paare nach vorn, der Rest in Fundreihenfolge.
|
||||||
|
endpoints = candidates.sorted { lhs, rhs in
|
||||||
|
lhs.isKnownPair && !rhs.isKnownPair
|
||||||
|
}
|
||||||
|
endpointIndex = 0
|
||||||
|
|
||||||
|
guard !endpoints.isEmpty else {
|
||||||
|
onStateChange(.failed("Keine passenden Bluetooth-Merkmale gefunden"))
|
||||||
|
publishDiagnostics()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
activateCurrentEndpoint()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func activateCurrentEndpoint() {
|
||||||
|
guard let endpoint = currentEndpoint else { return }
|
||||||
|
silentRounds = 0
|
||||||
|
buffer.removeAll()
|
||||||
|
peripheral.setNotifyValue(true, for: endpoint.notify)
|
||||||
|
publishDiagnostics()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wechselt auf den nächsten Kandidaten. Sind alle durch, wird von vorn
|
||||||
|
/// begonnen – das Gerät kann zwischenzeitlich aufgewacht sein.
|
||||||
|
private func advanceEndpoint() {
|
||||||
|
guard let previous = currentEndpoint else { return }
|
||||||
|
if peripheral.state == .connected {
|
||||||
|
peripheral.setNotifyValue(false, for: previous.notify)
|
||||||
|
}
|
||||||
|
endpointIndex = (endpointIndex + 1) % endpoints.count
|
||||||
|
onStateChange(.connecting)
|
||||||
|
activateCurrentEndpoint()
|
||||||
|
beginPolling()
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Abfrage
|
// MARK: - Abfrage
|
||||||
|
|
||||||
private func beginPolling() {
|
private func beginPolling() {
|
||||||
pollTimer?.invalidate()
|
pollTimer?.invalidate()
|
||||||
onStateChange(.live)
|
|
||||||
poll()
|
poll()
|
||||||
pollTimer = Timer.scheduledTimer(withTimeInterval: pollInterval, repeats: true) { [weak self] _ in
|
let interval = dialect == .unknown ? searchInterval : pollInterval
|
||||||
|
pollTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
||||||
self?.poll()
|
self?.poll()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func poll() {
|
private func poll() {
|
||||||
guard peripheral.state == .connected, writeCharacteristic != nil else { return }
|
guard peripheral.state == .connected, currentEndpoint != nil else { return }
|
||||||
|
|
||||||
switch dialect {
|
switch dialect {
|
||||||
case .unknown:
|
case .unknown:
|
||||||
probeDialects()
|
// Alle drei Protokolle anfragen; was antwortet, gewinnt.
|
||||||
|
sendSequence([
|
||||||
|
DalyProtocol.requestFrame(.soc),
|
||||||
|
JBDProtocol.requestFrame(.basicInfo),
|
||||||
|
DalyProtocol.modbusReadFrame(),
|
||||||
|
], spacing: 1.2, thenGiveUpAfter: 5)
|
||||||
case .dalyClassic:
|
case .dalyClassic:
|
||||||
sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) })
|
sendSequence(DalyProtocol.Command.allCases.map { DalyProtocol.requestFrame($0) },
|
||||||
|
spacing: 0.25, thenGiveUpAfter: 2)
|
||||||
case .dalyModbus:
|
case .dalyModbus:
|
||||||
sendSequence([DalyProtocol.modbusReadFrame()])
|
sendSequence([DalyProtocol.modbusReadFrame()], spacing: 0.25, thenGiveUpAfter: 2)
|
||||||
case .jbd:
|
case .jbd:
|
||||||
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) })
|
sendSequence(JBDProtocol.Command.allCases.map { JBDProtocol.requestFrame($0) },
|
||||||
|
spacing: 0.25, thenGiveUpAfter: 2)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Nacheinander alle bekannten Anfragen schicken. Der erste gültige Rahmen
|
|
||||||
/// in der Antwort legt den Dialekt fest.
|
|
||||||
private func probeDialects() {
|
|
||||||
let probes: [Data] = [
|
|
||||||
DalyProtocol.requestFrame(.soc),
|
|
||||||
JBDProtocol.requestFrame(.basicInfo),
|
|
||||||
DalyProtocol.modbusReadFrame(),
|
|
||||||
]
|
|
||||||
for (index, probe) in probes.enumerated() {
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 1.5) { [weak self] in
|
|
||||||
guard let self, self.dialect == .unknown else { return }
|
|
||||||
self.send(probe)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
checkForSilence(after: Double(probes.count) * 1.5 + 1.5)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Kommandos leicht versetzt senden – manche Module verschlucken Anfragen,
|
/// Kommandos leicht versetzt senden – manche Module verschlucken Anfragen,
|
||||||
/// die zu dicht aufeinander folgen.
|
/// die zu dicht aufeinander folgen.
|
||||||
private func sendSequence(_ frames: [Data]) {
|
private func sendSequence(_ frames: [Data], spacing: TimeInterval, thenGiveUpAfter grace: TimeInterval) {
|
||||||
for (index, frame) in frames.enumerated() {
|
for (index, frame) in frames.enumerated() {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.25) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * spacing) { [weak self] in
|
||||||
self?.send(frame)
|
self?.send(frame)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
checkForSilence(after: Double(frames.count) * 0.25 + 2)
|
checkForSilence(after: Double(frames.count) * spacing + grace)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Kommt mehrere Runden nichts Brauchbares zurück, wird der erkannte
|
/// Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht.
|
||||||
/// Dialekt verworfen und neu gesucht.
|
|
||||||
private func checkForSilence(after delay: TimeInterval) {
|
private func checkForSilence(after delay: TimeInterval) {
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self, self.peripheral.state == .connected else { return }
|
||||||
guard !self.hasUsableData else {
|
guard !self.hasUsableData else {
|
||||||
self.silentRounds = 0
|
self.silentRounds = 0
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
self.silentRounds += 1
|
self.silentRounds += 1
|
||||||
if self.silentRounds >= 3 {
|
self.publishDiagnostics()
|
||||||
self.silentRounds = 0
|
|
||||||
|
// Solange noch kein Protokoll steht, zügig weiterprobieren.
|
||||||
|
let limit = self.dialect == .unknown ? 1 : 3
|
||||||
|
guard self.silentRounds > limit else { return }
|
||||||
|
|
||||||
|
self.silentRounds = 0
|
||||||
|
if self.endpoints.count > 1 {
|
||||||
|
self.advanceEndpoint()
|
||||||
|
} else {
|
||||||
self.dialect = .unknown
|
self.dialect = .unknown
|
||||||
self.onStateChange(.failed("Keine verwertbare Antwort vom BMS"))
|
self.onStateChange(.failed("Keine Antwort vom BMS"))
|
||||||
self.publishDiagnostics()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -168,26 +267,24 @@ final class BMSSession: NSObject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func send(_ data: Data) {
|
private func send(_ data: Data) {
|
||||||
guard let characteristic = writeCharacteristic else { return }
|
guard let endpoint = currentEndpoint, peripheral.state == .connected else { return }
|
||||||
let type: CBCharacteristicWriteType =
|
sentFrameCount += 1
|
||||||
characteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse
|
peripheral.writeValue(data, for: endpoint.write, type: endpoint.writeType)
|
||||||
peripheral.writeValue(data, for: characteristic, type: type)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Auswertung
|
// MARK: - Auswertung
|
||||||
|
|
||||||
private func consume(_ data: Data) {
|
private func consume(_ data: Data) {
|
||||||
lastResponse = data
|
lastResponse = data
|
||||||
|
receivedByteCount += data.count
|
||||||
buffer.append(contentsOf: [UInt8](data))
|
buffer.append(contentsOf: [UInt8](data))
|
||||||
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
if buffer.count > 512 { buffer.removeFirst(buffer.count - 512) }
|
||||||
|
|
||||||
// JBD zuerst: der Rahmen ist durch Start-, Endbyte und Prüfsumme
|
// JBD zuerst: Start-, Endbyte und Prüfsumme machen den Rahmen eindeutig.
|
||||||
// eindeutig und kann nicht mit den Daly-Rahmen verwechselt werden.
|
|
||||||
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
|
let (jbdFrames, jbdRemainder) = JBDProtocol.extractFrames(from: buffer)
|
||||||
if !jbdFrames.isEmpty {
|
if !jbdFrames.isEmpty {
|
||||||
buffer = jbdRemainder
|
buffer = jbdRemainder
|
||||||
dialect = .jbd
|
adopt(.jbd)
|
||||||
silentRounds = 0
|
|
||||||
for frame in jbdFrames { jbdState.apply(frame) }
|
for frame in jbdFrames { jbdState.apply(frame) }
|
||||||
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
|
publish(jbdState.snapshot(deviceID: deviceID, rssi: nil), usable: jbdState.hasUsableData)
|
||||||
return
|
return
|
||||||
@@ -196,8 +293,7 @@ final class BMSSession: NSObject {
|
|||||||
if let start = buffer.firstIndex(where: { $0 == 0xD2 }),
|
if let start = buffer.firstIndex(where: { $0 == 0xD2 }),
|
||||||
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) {
|
let registers = DalyProtocol.parseModbusResponse(Array(buffer[start...])) {
|
||||||
buffer.removeAll()
|
buffer.removeAll()
|
||||||
dialect = .dalyModbus
|
adopt(.dalyModbus)
|
||||||
silentRounds = 0
|
|
||||||
dalyState.apply(registers: registers)
|
dalyState.apply(registers: registers)
|
||||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||||
return
|
return
|
||||||
@@ -206,17 +302,26 @@ final class BMSSession: NSObject {
|
|||||||
let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer)
|
let (dalyFrames, dalyRemainder) = DalyProtocol.extractA5Frames(from: buffer)
|
||||||
if !dalyFrames.isEmpty {
|
if !dalyFrames.isEmpty {
|
||||||
buffer = dalyRemainder
|
buffer = dalyRemainder
|
||||||
dialect = .dalyClassic
|
adopt(.dalyClassic)
|
||||||
silentRounds = 0
|
|
||||||
for frame in dalyFrames { dalyState.apply(frame) }
|
for frame in dalyFrames { dalyState.apply(frame) }
|
||||||
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
publish(dalyState.snapshot(deviceID: deviceID, rssi: nil), usable: dalyState.hasUsableData)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nichts erkannt – trotzdem melden, damit die Diagnose etwas zeigt.
|
// Etwas kam an, ließ sich aber nicht zuordnen: für die Diagnose sichtbar
|
||||||
|
// machen, damit sich das Protokoll nachträglich bestimmen lässt.
|
||||||
publishDiagnostics()
|
publishDiagnostics()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt
|
||||||
|
/// im normalen Takt abfragen.
|
||||||
|
private func adopt(_ newDialect: Dialect) {
|
||||||
|
silentRounds = 0
|
||||||
|
guard dialect != newDialect else { return }
|
||||||
|
dialect = newDialect
|
||||||
|
beginPolling()
|
||||||
|
}
|
||||||
|
|
||||||
private func publish(_ snapshot: DeviceSnapshot, usable: Bool) {
|
private func publish(_ snapshot: DeviceSnapshot, usable: Bool) {
|
||||||
publishDiagnostics()
|
publishDiagnostics()
|
||||||
guard usable else { return }
|
guard usable else { return }
|
||||||
@@ -227,13 +332,30 @@ final class BMSSession: NSObject {
|
|||||||
private func publishDiagnostics() {
|
private func publishDiagnostics() {
|
||||||
onDiagnostics(BMSDiagnostics(
|
onDiagnostics(BMSDiagnostics(
|
||||||
dialect: dialect.rawValue,
|
dialect: dialect.rawValue,
|
||||||
serviceUUID: writeCharacteristic?.service?.uuid.uuidString,
|
endpointLabel: currentEndpoint?.label,
|
||||||
writeUUID: writeCharacteristic?.uuid.uuidString,
|
endpointPosition: endpoints.isEmpty ? nil : .init(endpointIndex + 1, endpoints.count),
|
||||||
notifyUUID: notifyCharacteristic?.uuid.uuidString,
|
serviceUUID: currentEndpoint?.write.service?.uuid.uuidString,
|
||||||
|
gattSummary: gattSummary,
|
||||||
|
sentFrames: sentFrameCount,
|
||||||
|
receivedBytes: receivedByteCount,
|
||||||
lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") },
|
lastResponseHex: lastResponse.map { $0.map { String(format: "%02X", $0) }.joined(separator: " ") },
|
||||||
updated: Date()
|
updated: Date()
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Menschenlesbarer GATT-Baum für die Diagnose.
|
||||||
|
private func appendToSummary(_ service: CBService) {
|
||||||
|
gattSummary.append("Dienst \(service.uuid.uuidString)")
|
||||||
|
for characteristic in service.characteristics ?? [] {
|
||||||
|
var traits: [String] = []
|
||||||
|
if characteristic.properties.contains(.read) { traits.append("read") }
|
||||||
|
if characteristic.properties.contains(.write) { traits.append("write") }
|
||||||
|
if characteristic.properties.contains(.writeWithoutResponse) { traits.append("write-nr") }
|
||||||
|
if characteristic.properties.contains(.notify) { traits.append("notify") }
|
||||||
|
if characteristic.properties.contains(.indicate) { traits.append("indicate") }
|
||||||
|
gattSummary.append(" \(characteristic.uuid.uuidString) \(traits.joined(separator: ", "))")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - CBPeripheralDelegate
|
// MARK: - CBPeripheralDelegate
|
||||||
@@ -245,7 +367,14 @@ extension BMSSession: CBPeripheralDelegate {
|
|||||||
onStateChange(.failed(error.localizedDescription))
|
onStateChange(.failed(error.localizedDescription))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
for service in peripheral.services ?? [] {
|
let services = peripheral.services ?? []
|
||||||
|
gattSummary.removeAll()
|
||||||
|
pendingServices = services.count
|
||||||
|
guard pendingServices > 0 else {
|
||||||
|
onStateChange(.failed("Gerät bietet keine Bluetooth-Dienste an"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for service in services {
|
||||||
peripheral.discoverCharacteristics(nil, for: service)
|
peripheral.discoverCharacteristics(nil, for: service)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -253,37 +382,26 @@ extension BMSSession: CBPeripheralDelegate {
|
|||||||
func peripheral(_ peripheral: CBPeripheral,
|
func peripheral(_ peripheral: CBPeripheral,
|
||||||
didDiscoverCharacteristicsFor service: CBService,
|
didDiscoverCharacteristicsFor service: CBService,
|
||||||
error: Error?) {
|
error: Error?) {
|
||||||
guard error == nil, let characteristics = service.characteristics else { return }
|
appendToSummary(service)
|
||||||
|
pendingServices -= 1
|
||||||
let writable = characteristics.first {
|
// Erst wenn alle Dienste durch sind, steht die Kandidatenliste fest.
|
||||||
$0.properties.contains(.write) || $0.properties.contains(.writeWithoutResponse)
|
guard pendingServices <= 0 else { return }
|
||||||
}
|
buildEndpoints()
|
||||||
let notifying = characteristics.first {
|
|
||||||
$0.properties.contains(.notify) || $0.properties.contains(.indicate)
|
|
||||||
}
|
|
||||||
guard let writable, let notifying else { return }
|
|
||||||
|
|
||||||
// Einen bekannten Dienst immer bevorzugen, sonst den erstbesten nehmen.
|
|
||||||
let isPreferred = Self.preferredServices.contains(service.uuid)
|
|
||||||
let alreadyPreferred = writeCharacteristic
|
|
||||||
.flatMap { $0.service?.uuid }
|
|
||||||
.map { Self.preferredServices.contains($0) } ?? false
|
|
||||||
guard writeCharacteristic == nil || (isPreferred && !alreadyPreferred) else { return }
|
|
||||||
|
|
||||||
writeCharacteristic = writable
|
|
||||||
notifyCharacteristic = notifying
|
|
||||||
peripheral.setNotifyValue(true, for: notifying)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func peripheral(_ peripheral: CBPeripheral,
|
func peripheral(_ peripheral: CBPeripheral,
|
||||||
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
didUpdateNotificationStateFor characteristic: CBCharacteristic,
|
||||||
error: Error?) {
|
error: Error?) {
|
||||||
if let error {
|
if let error {
|
||||||
onStateChange(.failed(error.localizedDescription))
|
// Dieser Kandidat lässt sich nicht abonnieren – nächsten versuchen.
|
||||||
|
if endpoints.count > 1 {
|
||||||
|
advanceEndpoint()
|
||||||
|
} else {
|
||||||
|
onStateChange(.failed(error.localizedDescription))
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if characteristic.isNotifying, characteristic == notifyCharacteristic {
|
if characteristic.isNotifying, characteristic == currentEndpoint?.notify {
|
||||||
publishDiagnostics()
|
|
||||||
beginPolling()
|
beginPolling()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -291,7 +409,7 @@ extension BMSSession: CBPeripheralDelegate {
|
|||||||
func peripheral(_ peripheral: CBPeripheral,
|
func peripheral(_ peripheral: CBPeripheral,
|
||||||
didUpdateValueFor characteristic: CBCharacteristic,
|
didUpdateValueFor characteristic: CBCharacteristic,
|
||||||
error: Error?) {
|
error: Error?) {
|
||||||
guard error == nil, let value = characteristic.value else { return }
|
guard error == nil, let value = characteristic.value, !value.isEmpty else { return }
|
||||||
consume(value)
|
consume(value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,13 +68,26 @@ struct VictronDiagnostics: Hashable {
|
|||||||
/// greift, und gibt die letzte Rohantwort zum Nachsehen aus.
|
/// greift, und gibt die letzte Rohantwort zum Nachsehen aus.
|
||||||
struct BMSDiagnostics: Hashable {
|
struct BMSDiagnostics: Hashable {
|
||||||
var dialect: String
|
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 serviceUUID: String?
|
||||||
var writeUUID: String?
|
/// Vollständiger Dienst-/Merkmalsbaum des Geräts.
|
||||||
var notifyUUID: String?
|
var gattSummary: [String]
|
||||||
|
var sentFrames: Int
|
||||||
|
var receivedBytes: Int
|
||||||
var lastResponseHex: String?
|
var lastResponseHex: String?
|
||||||
var updated: Date
|
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.
|
/// Ein Messpunkt für die Verlaufsgrafik.
|
||||||
struct HistorySample: Identifiable, Hashable {
|
struct HistorySample: Identifiable, Hashable {
|
||||||
let id = UUID()
|
let id = UUID()
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ enum DemoData {
|
|||||||
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", symbol: "box.truck")
|
static let mainProfile = Profile(id: Profile.defaultID, name: "Kastenwagen", symbol: "box.truck")
|
||||||
static let secondProfile = Profile(
|
static let secondProfile = Profile(
|
||||||
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!,
|
id: UUID(uuidString: "00000000-0000-0000-0000-0000000000C2")!,
|
||||||
name: "Wohnwagen", symbol: "caravan")
|
name: "Wohnwagen", symbol: "car.side")
|
||||||
|
|
||||||
static var profiles: [Profile] { [mainProfile, secondProfile] }
|
static var profiles: [Profile] { [mainProfile, secondProfile] }
|
||||||
|
|
||||||
|
|||||||
@@ -240,11 +240,20 @@ struct DeviceDetailView: View {
|
|||||||
if let info = bluetooth.bmsDiagnostics[device.id] {
|
if let info = bluetooth.bmsDiagnostics[device.id] {
|
||||||
Section {
|
Section {
|
||||||
LabeledContent("Erkanntes Protokoll", value: info.dialect)
|
LabeledContent("Erkanntes Protokoll", value: info.dialect)
|
||||||
if let service = info.serviceUUID {
|
if let position = info.endpointPosition {
|
||||||
LabeledContent("Dienst") {
|
LabeledContent("Verbindungsweg",
|
||||||
Text(service).font(.caption.monospaced()).foregroundStyle(.secondary)
|
value: "\(position.index) von \(position.total)")
|
||||||
|
}
|
||||||
|
if let endpoint = info.endpointLabel {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("Aktueller Weg")
|
||||||
|
Text(endpoint)
|
||||||
|
.font(.caption.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
LabeledContent("Gesendet / empfangen",
|
||||||
|
value: "\(info.sentFrames) Anfragen / \(info.receivedBytes) Byte")
|
||||||
if let hex = info.lastResponseHex {
|
if let hex = info.lastResponseHex {
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
Text("Letzte Antwort")
|
Text("Letzte Antwort")
|
||||||
@@ -257,9 +266,21 @@ struct DeviceDetailView: View {
|
|||||||
} header: {
|
} header: {
|
||||||
Text("Diagnose")
|
Text("Diagnose")
|
||||||
} footer: {
|
} footer: {
|
||||||
Text("Die App probiert Daly (klassisch und Modbus) sowie JBD/Xiaoxiang "
|
Text("Die App probiert alle Schreib-/Empfangs-Kombinationen des Geräts "
|
||||||
+ "durch und übernimmt, was antwortet. Bleibt es bei „wird ermittelt“, "
|
+ "durch und fragt auf jeder Daly (klassisch und Modbus) sowie "
|
||||||
+ "spricht das BMS ein anderes Protokoll – dann hilft die Rohantwort weiter.")
|
+ "JBD/Xiaoxiang an. Bleibt „empfangen“ bei 0 Byte, nimmt das BMS "
|
||||||
|
+ "die Kommandos nicht an.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !info.gattSummary.isEmpty {
|
||||||
|
Section {
|
||||||
|
Text(info.gattSummary.joined(separator: "\n"))
|
||||||
|
.font(.caption2.monospaced())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.textSelection(.enabled)
|
||||||
|
} header: {
|
||||||
|
Text("Bluetooth-Merkmale des Geräts")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ iOS-App, die per Bluetooth LE die Energieanlage im Wohnmobil ausliest:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Victron Ladebooster (Orion-TR Smart / Orion XS) | Instant Readout im Advertisement | Ein-/Ausgangsspannung, beim XS auch Ströme und Ladeleistung, Zustand, Abschaltgrund |
|
| Victron Ladebooster (Orion-TR Smart / Orion XS) | Instant Readout im Advertisement | Ein-/Ausgangsspannung, beim XS auch Ströme und Ladeleistung, Zustand, Abschaltgrund |
|
||||||
| Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) |
|
| Victron Solarladeregler (SmartSolar MPPT) | Instant Readout im Advertisement | PV-Leistung, Batteriespannung, Ladestrom, Tagesertrag, Laststrom, Ladezustand (Bulk/Absorption/Float) |
|
||||||
| Daly BMS (Bulltron) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
|
| Batterie-BMS (Bulltron/Daly, WattCycle/JBD) | GATT-Verbindung, alle 5 s abgefragt | SoC, Spannung, Strom, Restkapazität, alle Einzelzellspannungen, Zelldifferenz, Temperaturen, Zyklen, MOSFET-Status |
|
||||||
|
|
||||||
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
Ein Victron SmartShunt/BMV wird ebenfalls unterstützt, falls später einer dazukommt.
|
||||||
|
|
||||||
@@ -63,18 +63,35 @@ Schlüssel angezeigt wurde.
|
|||||||
Die Diagnose zeigt außerdem Produkt-ID, Datensatztyp und die Rohdaten des
|
Die Diagnose zeigt außerdem Produkt-ID, Datensatztyp und die Rohdaten des
|
||||||
Advertisements – letztere lassen sich durch langes Antippen kopieren.
|
Advertisements – letztere lassen sich durch langes Antippen kopieren.
|
||||||
|
|
||||||
### Daly BMS
|
### Batterie / BMS
|
||||||
|
|
||||||
Kein Schlüssel nötig. In der Liste erscheint das BLE-Modul meist als `DL-…`.
|
Kein Schlüssel nötig. Auswählen, Art auf **Batterie / BMS** stellen, Sichern.
|
||||||
Auswählen, Art auf **Batterie / BMS** stellen, Sichern.
|
Bulltron-Akkus melden sich meist als `DL-…`, WattCycle je nach Charge unter
|
||||||
|
eigenem Namen – findest du nichts, in der Geräteliste auf **Alle** umschalten.
|
||||||
|
|
||||||
Die App erkennt selbst, ob das BMS das klassische `A5`-Protokoll oder das
|
Die App probiert drei Protokolle durch und übernimmt, was antwortet:
|
||||||
neuere Modbus-Protokoll (`D2`) spricht, und sucht die passenden GATT-
|
|
||||||
Charakteristiken automatisch – die BLE-Module unterscheiden sich zwischen
|
| Dialekt | Verbreitung |
|
||||||
Fertigungschargen.
|
|---|---|
|
||||||
|
| Daly klassisch (`A5`) | Bulltron und viele Daly-BMS |
|
||||||
|
| Daly Modbus (`D2`) | neuere Daly-Firmware |
|
||||||
|
| JBD / Xiaoxiang (`DD A5`) | WattCycle und viele andere LiFePO4-Akkus |
|
||||||
|
|
||||||
|
Auch die GATT-Charakteristiken werden gesucht statt vorausgesetzt, weil sich
|
||||||
|
die BLE-Module zwischen Herstellern und Chargen unterscheiden. Welches
|
||||||
|
Protokoll erkannt wurde, steht in der Detailansicht unter **Diagnose** –
|
||||||
|
zusammen mit der letzten Rohantwort.
|
||||||
|
|
||||||
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
|
Nur **eine** App gleichzeitig kann mit dem BMS verbunden sein. Wenn die
|
||||||
Bulltron-/Daly-App offen ist, bekommt Camper Monitor keine Verbindung.
|
Hersteller-App offen ist, bekommt Camper Monitor keine Verbindung.
|
||||||
|
|
||||||
|
## Mehrere Fahrzeuge
|
||||||
|
|
||||||
|
Oben links im Dashboard sitzt der Fahrzeugwechsel. Jedes Profil hat seinen
|
||||||
|
eigenen Gerätesatz; die App scannt und verbindet immer nur für das gewählte
|
||||||
|
Fahrzeug. Über *Fahrzeuge verwalten…* lassen sich Profile anlegen, umbenennen,
|
||||||
|
mit einem Symbol versehen und löschen. Geräte, die vor der Profilverwaltung
|
||||||
|
eingerichtet wurden, wandern beim Update automatisch ins erste Profil.
|
||||||
|
|
||||||
## Ohne Fahrzeug ansehen
|
## Ohne Fahrzeug ansehen
|
||||||
|
|
||||||
@@ -98,6 +115,7 @@ und die Prüfsummen beider Daly-Dialekte.
|
|||||||
```
|
```
|
||||||
CamperMonitor/
|
CamperMonitor/
|
||||||
├── Models/
|
├── Models/
|
||||||
|
│ ├── Profile.swift Fahrzeug
|
||||||
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
|
│ ├── ConfiguredDevice.swift Eingerichtetes Gerät, Rolle, Transportart
|
||||||
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
|
│ ├── DeviceSnapshot.swift Messwerte in Anzeigeform
|
||||||
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
│ └── VictronCodes.swift Klartexte für Zustands-/Fehlercodes
|
||||||
@@ -106,16 +124,18 @@ CamperMonitor/
|
|||||||
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
|
│ ├── VictronAdvertisement.swift Advertisement entschlüsseln und auswerten
|
||||||
│ ├── AESCounterMode.swift AES-128-CTR (CommonCrypto)
|
│ ├── AESCounterMode.swift AES-128-CTR (CommonCrypto)
|
||||||
│ ├── BitReader.swift Bitweises Lesen der gepackten Felder
|
│ ├── BitReader.swift Bitweises Lesen der gepackten Felder
|
||||||
│ ├── DalyProtocol.swift Rahmenbau und Prüfsummen beider Dialekte
|
│ ├── DalyProtocol.swift Daly-Rahmen und Prüfsummen
|
||||||
│ ├── DalyState.swift Sammelt Antworten zu einem Gesamtbild
|
│ ├── DalyState.swift Sammelt Daly-Antworten zu einem Gesamtbild
|
||||||
│ └── DalySession.swift GATT-Verbindung und Abfragezyklus
|
│ ├── JBDProtocol.swift JBD/Xiaoxiang (WattCycle), Rahmen und Auswertung
|
||||||
|
│ └── BMSSession.swift GATT-Verbindung, Protokollerkennung, Abfrage
|
||||||
├── Store/
|
├── Store/
|
||||||
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
│ ├── DeviceStore.swift Geräteliste, Persistenz
|
||||||
│ └── KeychainStore.swift Victron-Schlüssel
|
│ └── KeychainStore.swift Victron-Schlüssel
|
||||||
└── Views/
|
└── Views/
|
||||||
├── DashboardView.swift Kachelübersicht
|
├── DashboardView.swift Kachelübersicht
|
||||||
├── DeviceCard.swift Eine Kachel
|
├── DeviceCard.swift Eine Kachel
|
||||||
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen
|
├── DeviceDetailView.swift Alle Werte, Verlauf, Zellspannungen, Diagnose
|
||||||
|
├── ProfilesView.swift Fahrzeuge anlegen und verwalten
|
||||||
└── AddDeviceView.swift Scannen und Einrichten
|
└── AddDeviceView.swift Scannen und Einrichten
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -125,10 +145,14 @@ CamperMonitor/
|
|||||||
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den
|
Advertisements nur im Vordergrund. Die App pausiert, sobald sie in den
|
||||||
Hintergrund geht, und nimmt beim Zurückkommen wieder auf.
|
Hintergrund geht, und nimmt beim Zurückkommen wieder auf.
|
||||||
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
|
* **Das Modbus-Registerlayout des neuen Daly-Protokolls variiert zwischen
|
||||||
Firmwareständen.** Das klassische `A5`-Protokoll ist gut dokumentiert und
|
Firmwareständen.** Das klassische `A5`-Protokoll und das JBD-Protokoll sind
|
||||||
sicher; falls dein BMS Modbus spricht und Werte unplausibel aussehen, muss
|
gut dokumentiert; falls dein BMS Modbus spricht und Werte unplausibel
|
||||||
das Mapping in `DalyState.apply(registers:)` am realen Gerät nachgezogen
|
aussehen, muss das Mapping in `DalyState.apply(registers:)` am realen Gerät
|
||||||
werden.
|
nachgezogen werden.
|
||||||
|
* **Welches BMS in einem WattCycle-Akku steckt, ist nicht garantiert.** Die
|
||||||
|
Unterstützung ist auf JBD/Xiaoxiang ausgelegt, das dort üblich ist. Meldet
|
||||||
|
die Diagnose dauerhaft „wird ermittelt“, spricht der Akku etwas anderes –
|
||||||
|
die dort angezeigte Rohantwort ist dann der Ansatzpunkt.
|
||||||
* Die Feldbelegungen der Victron-Datensätze stammen aus Victrons
|
* Die Feldbelegungen der Victron-Datensätze stammen aus Victrons
|
||||||
„Extra Manufacturer Data“-Beschreibung. Solarladeregler und DC/DC-Wandler
|
„Extra Manufacturer Data“-Beschreibung. Solarladeregler und DC/DC-Wandler
|
||||||
sind die am besten belegten Typen.
|
sind die am besten belegten Typen.
|
||||||
|
|||||||
Reference in New Issue
Block a user