From 866fce9711921d4dae875719282dd5d8ceeb44ba Mon Sep 17 00:00:00 2001 From: BiasF Date: Mon, 31 Aug 2026 10:28:53 +0200 Subject: [PATCH] Android: Bluetooth-Schicht und Ablage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Damit steht der erste Teil vollständig: Protokoll, Funk und Ablage übersetzen sich, die 50 Prüfungen laufen. Die Funkschicht folgt der iOS-Fassung samt allem, was wir uns dort erst erarbeiten mussten. Ein eigener Thread für alles, was Bluetooth anfasst; Android ruft Scan- und GATT-Rückmeldungen auf Binder-Threads auf. Die Wartesperre nach Fehlschlägen, die zwischen einem Abbruch einer stehenden Verbindung und einem echten Fehlschlag unterscheidet. Stellbefehle, die aufgehoben statt verworfen werden, wenn die Kühlbox gerade weg ist. Die Zwanzig-Byte-Grenze beim Schreiben an die Kühlbox. Die stabile Reihenfolge der Kandidaten, damit nicht der Zufall die Schreibart bestimmt. Vier Dinge sind auf Android anders und im Quelltext vermerkt: Die Herstellerdaten kommen ohne die zwei Bytes der Company-ID - die steht im Schlüssel der Tabelle. Sie werden wieder davorgesetzt, damit derselbe Rahmen ankommt wie unter iOS und dieselben Prüfungen gelten. Notify muss über einen Deskriptor eingeschaltet werden; das Einschalten auf unserer Seite allein genügt nicht. Geräte werden über ihre MAC-Adresse angesprochen statt über eine systemvergebene Kennung - iOS gibt die Adresse gar nicht heraus. Die Victron-Schlüssel liegen in app-privater Ablage statt im Schlüsselbund. Das schützt gegen andere Apps, ist aber nicht hardwaregestützt. Ohne zusätzliche Bibliothek gibt es unter Android kein gleichwertiges Gegenstück. Als Nächstes die Oberfläche. Co-Authored-By: Claude Opus 5 --- Android/app/build.gradle.kts | 60 ++ Android/app/src/main/AndroidManifest.xml | 46 + .../de/fritob/campermonitor/MainActivity.kt | 14 + .../bluetooth/BluetoothManager.kt | 632 +++++++++++++ .../campermonitor/bluetooth/BmsSession.kt | 859 ++++++++++++++++++ .../campermonitor/bluetooth/LevelSession.kt | 274 ++++++ .../fritob/campermonitor/store/DeviceStore.kt | 231 +++++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 8843 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 4499 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 14463 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 28741 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 47257 bytes Android/app/src/main/res/values/strings.xml | 4 + Android/app/src/main/res/values/themes.xml | 4 + .../protocol/ConfiguredDevice.kt | 85 ++ 15 files changed, 2209 insertions(+) create mode 100644 Android/app/build.gradle.kts create mode 100644 Android/app/src/main/AndroidManifest.xml create mode 100644 Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt create mode 100644 Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt create mode 100644 Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BmsSession.kt create mode 100644 Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt create mode 100644 Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt create mode 100644 Android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 Android/app/src/main/res/values/strings.xml create mode 100644 Android/app/src/main/res/values/themes.xml create mode 100644 Android/protocol/src/main/kotlin/de/fritob/campermonitor/protocol/ConfiguredDevice.kt diff --git a/Android/app/build.gradle.kts b/Android/app/build.gradle.kts new file mode 100644 index 0000000..1d5dae8 --- /dev/null +++ b/Android/app/build.gradle.kts @@ -0,0 +1,60 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.android") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "de.fritob.campermonitor" + compileSdk = 35 + + defaultConfig { + applicationId = "de.fritob.campermonitor" + // Android 8: älter lohnt nicht, dort fehlt zu viel an Bluetooth LE. + minSdk = 26 + targetSdk = 35 + versionCode = 1 + versionName = "1.0" + } + + buildTypes { + release { + isMinifyEnabled = false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = "17" + } + + buildFeatures { + compose = true + } + + sourceSets["main"].java.srcDirs("src/main/kotlin") +} + +dependencies { + implementation(project(":protocol")) + + val composeBom = platform("androidx.compose:compose-bom:2024.12.01") + implementation(composeBom) + implementation("androidx.compose.material3:material3") + implementation("androidx.compose.material:material-icons-extended") + implementation("androidx.compose.ui:ui") + implementation("androidx.compose.ui:ui-tooling-preview") + debugImplementation("androidx.compose.ui:ui-tooling") + + implementation("androidx.core:core-ktx:1.15.0") + implementation("androidx.activity:activity-compose:1.9.3") + implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") + implementation("androidx.navigation:navigation-compose:2.8.5") + + // Die Ablage der Geräte und Profile nutzt org.json aus Android selbst - + // eine weitere Bibliothek lohnt für eine Handvoll Felder nicht. +} diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..455765a --- /dev/null +++ b/Android/app/src/main/AndroidManifest.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt new file mode 100644 index 0000000..1d10601 --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/MainActivity.kt @@ -0,0 +1,14 @@ +package de.fritob.campermonitor + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text + +class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { MaterialTheme { Text("Camper Monitor") } } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt new file mode 100644 index 0000000..e019cdf --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BluetoothManager.kt @@ -0,0 +1,632 @@ +package de.fritob.campermonitor.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Handler +import android.os.HandlerThread +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import de.fritob.campermonitor.protocol.AlpicoolProtocol +import de.fritob.campermonitor.protocol.AlpicoolState +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.protocol.DeviceSnapshot +import de.fritob.campermonitor.protocol.DeviceTransport +import de.fritob.campermonitor.protocol.FridgeZone +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.SensorOrientation +import de.fritob.campermonitor.protocol.VictronAdvertisement +import de.fritob.campermonitor.store.DeviceStore +import java.util.UUID +import kotlin.math.min +import kotlin.math.pow + +/** Was beim Einrichten in der Geräteliste steht. */ +data class Discovery( + val address: String, + val name: String?, + val rssi: Int, + val firstSeen: Long, + val lastSeen: Long, + val isVictron: Boolean, + val victronRecord: String? = null, + val serviceUUIDs: List = emptyList(), +) { + /** + * Ob das Gerät nach einem der unterstützten aussieht. Nur für die + * Sortierung – ausschliessen tut die Liste nichts. + */ + val looksLikeSupported: Boolean + get() { + if (isVictron) return true + val n = name?.lowercase() ?: return false + return listOf("dl-", "daly", "jbd", "xiaoxiang", "wattcycle", "hilink", + "vanalign", "alpicool", "icecube").any { n.contains(it) } + } +} + +/** Was die Diagnoseansicht über ein Victron-Advertisement zeigt. */ +data class VictronDiagnostics( + val expectedKeyFirstByte: Int?, + val recordName: String, + val productIDText: String, + val rawHex: String, +) { + val expectedKeyText: String + get() = expectedKeyFirstByte?.let { "0x%02X".format(it) } ?: "–" +} + +/** + * Die Funkschicht der App. + * + * **Alles, was Bluetooth anfasst, läuft auf einem eigenen Thread.** Android + * ruft Scan- und GATT-Rückmeldungen auf Binder-Threads auf; würden sie direkt + * den Zustand der Oberfläche anfassen, entstünde ein Datenwettlauf und die + * Oberfläche würde bei jedem Advertisement neu zeichnen. Ergebnisse gehen + * deshalb über [publish] zurück auf den Hauptthread. + */ +@SuppressLint("MissingPermission") +class BluetoothManager( + private val context: Context, + private val store: DeviceStore, +) { + // MARK: - Was die Oberfläche liest (nur Hauptthread) + + var isBluetoothReady by mutableStateOf(false) + private set + var bluetoothStatusText by mutableStateOf("Bluetooth wird gestartet…") + private set + + val snapshots = mutableStateMapOf() + val linkStates = mutableStateMapOf() + val fridgeStates = mutableStateMapOf() + val levelStates = mutableStateMapOf() + val bmsDiagnostics = mutableStateMapOf() + val diagnostics = mutableStateMapOf() + val discoveries = mutableStateMapOf() + val history = mutableStateMapOf>() + + data class HistorySample(val time: Long, val value: Double) + + // MARK: - Funk-Thread + + private val thread = HandlerThread("de.fritob.CamperMonitor.bluetooth").apply { start() } + private val handler = Handler(thread.looper) + private val mainHandler = Handler(context.mainLooper) + + private val adapter: BluetoothAdapter? = + (context.getSystemService(Context.BLUETOOTH_SERVICE) as? android.bluetooth.BluetoothManager) + ?.adapter + + private fun publish(work: () -> Unit) = mainHandler.post(work) + + // MARK: - Zustand auf dem Funk-Thread + + private class ManagedDevice( + val id: UUID, + val address: String, + val transport: DeviceTransport, + val role: DeviceRole, + val fridgeZoneMode: FridgeZoneMode, + val sensorOrientation: SensorOrientation, + val victronKey: ByteArray?, + ) + + private var managed: Map = emptyMap() + private val bmsSessions = mutableMapOf() + private val levelSessions = mutableMapOf() + private val connecting = mutableSetOf() + + /** + * Frühester Zeitpunkt für den nächsten Verbindungsversuch je Gerät. + * + * Ohne diese Sperre entsteht ein Verbindungssturm: schlägt ein Versuch + * fehl, ist der Eintrag wieder frei, und das nächste Advertisement löst + * sofort den nächsten aus. Geräte quittieren jeden Versuch, Kühlboxen etwa + * mit einem Piepton. + */ + private val nextConnectAttempt = mutableMapOf() + private val connectFailures = mutableMapOf() + private val connectedSince = mutableMapOf() + + /** + * Stellbefehle, die kamen, während die Verbindung weg war. Ohne das + * verschwindet ein Tippen lautlos. + */ + private val pendingControls = mutableMapOf>>() + + /** Letzter bekannter Kühlbox-Zustand, auch über eine Trennung hinweg. */ + private val fridgeStateCache = mutableMapOf() + + private val lastHandledAdvertisement = mutableMapOf() + + private var discovering = false + private var scanning = false + + private companion object { + const val FIRST_RETRY_DELAY_MS = 5_000L + const val LONGEST_RETRY_DELAY_MS = 60_000L + + /** + * Nach dem Abbruch einer Verbindung, die stand, wird zügig nachgefasst – + * das ist der Normalfall im Fahrzeug und kein Fehler. + */ + const val QUICK_RETRY_DELAY_MS = 2_000L + + /** + * Ab dieser Dauer gilt eine Verbindung als zustandegekommen. Was sofort + * wieder abbricht, zählt als Fehlschlag. + */ + const val STABLE_CONNECTION_MS = 3_000L + + /** + * Advertisements treffen mehrmals je Sekunde ein. Öfter als hier + * festgelegt wird nichts ausgewertet. + */ + const val MINIMUM_ADVERTISEMENT_INTERVAL_MS = 900L + + const val RECONNECT_INTERVAL_MS = 15_000L + const val CONTROL_LIFETIME_MS = 30_000L + const val HISTORY_LIMIT = 720 + } + + // MARK: - Start + + fun start() { + handler.post { + refreshAdapterState() + applyConfiguration(currentDevices()) + } + } + + fun refreshConfiguration() { + handler.post { applyConfiguration(currentDevices()) } + } + + private fun currentDevices(): List = store.activeDevices().map { + ManagedDevice( + id = it.id, + address = it.address, + transport = it.role.transport, + role = it.role, + fridgeZoneMode = it.fridgeZoneMode, + sensorOrientation = it.sensorOrientation, + victronKey = store.victronKey(it.id), + ) + } + + private fun refreshAdapterState() { + val a = adapter + val ready = a != null && a.isEnabled + val text = when { + a == null -> "Dieses Gerät unterstützt kein Bluetooth LE" + !a.isEnabled -> "Bluetooth ist ausgeschaltet" + else -> "Bereit" + } + publish { + isBluetoothReady = ready + bluetoothStatusText = text + } + } + + /** Schaltet die Anzeige der gefundenen Geräte beim Einrichten ein und aus. */ + fun setDiscovering(wanted: Boolean) { + handler.post { + if (discovering == wanted) return@post + discovering = wanted + if (!wanted) publish { discoveries.clear() } + } + } + + // MARK: - Ablauf auf dem Funk-Thread + + private fun applyConfiguration(devices: List) { + managed = devices.associateBy { it.address } + + // Verbindungen zu Geräten lösen, die nicht mehr dazugehören. + val wanted = devices.filter { it.transport == DeviceTransport.CONNECT } + .map { it.address }.toSet() + for (address in bmsSessions.keys.toList()) { + if (address !in wanted) { + bmsSessions.remove(address)?.stop() + } + } + for (address in levelSessions.keys.toList()) { + if (address !in wanted) { + levelSessions.remove(address)?.stop() + } + } + // Einstellungen an bestehende Sitzungen weiterreichen. + for (device in devices) { + bmsSessions[device.address]?.fridgeZoneMode = device.fridgeZoneMode + levelSessions[device.address]?.orientation = device.sensorOrientation + } + lastHandledAdvertisement.clear() + nextConnectAttempt.clear() + connectFailures.clear() + connectedSince.clear() + + startScanning() + } + + private fun startScanning() { + val a = adapter ?: return + if (!a.isEnabled) return + restartScan() + connectManagedDevices() + scheduleReconnects() + } + + private fun restartScan() { + val scanner = adapter?.bluetoothLeScanner ?: return + if (scanning) scanner.stopScan(scanCallback) + val settings = ScanSettings.Builder() + .setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY) + // Victron sendet seine Werte im Advertisement, also müssen auch + // Wiederholungen durchgereicht werden. + .setCallbackType(ScanSettings.CALLBACK_TYPE_ALL_MATCHES) + .setReportDelay(0) + .build() + scanner.startScan(null, settings, scanCallback) + scanning = true + } + + fun stopEverything() { + handler.post { + if (scanning) adapter?.bluetoothLeScanner?.stopScan(scanCallback) + scanning = false + reconnectRunnable?.let { handler.removeCallbacks(it) } + bmsSessions.values.forEach { it.stop() } + levelSessions.values.forEach { it.stop() } + bmsSessions.clear() + levelSessions.clear() + connectedSince.clear() + pendingControls.clear() + } + } + + private var reconnectRunnable: Runnable? = null + + /** + * Verbindungen fallen im Fahrzeug regelmässig weg – deshalb regelmässig + * nachfassen statt nur auf das Trennen zu reagieren. + */ + private fun scheduleReconnects() { + reconnectRunnable?.let { handler.removeCallbacks(it) } + val runnable = object : Runnable { + override fun run() { + connectManagedDevices() + handler.postDelayed(this, RECONNECT_INTERVAL_MS) + } + } + reconnectRunnable = runnable + handler.postDelayed(runnable, RECONNECT_INTERVAL_MS) + } + + private fun connectManagedDevices() { + managed.values + .filter { it.transport == DeviceTransport.CONNECT } + .forEach { connectIfNeeded(it) } + } + + private fun connectIfNeeded(device: ManagedDevice) { + if (device.address in bmsSessions || device.address in levelSessions) return + if (device.address in connecting) return + // Nach einem Fehlschlag eine Weile Ruhe geben, sonst wird das Gerät im + // Sekundentakt angeklopft. + nextConnectAttempt[device.address]?.let { + if (System.currentTimeMillis() < it) return + } + val remote = adapter?.getRemoteDevice(device.address) ?: return + connecting.add(device.address) + publish { linkStates[device.id] = DeviceLinkState.Connecting } + + val provider: (BluetoothGattCallback) -> android.bluetooth.BluetoothGatt? = { cb -> + remote.connectGatt(context, false, cb, BluetoothDevice.TRANSPORT_LE) + } + + if (device.role == DeviceRole.LEVELING) { + val session = LevelSession( + deviceID = device.id, + gattProvider = provider, + handler = handler, + onUpdate = { record(it) }, + onStateChange = { handleLinkState(device, it) }, + onLevelState = { state -> publish { levelStates[device.id] = state } }, + ) + session.orientation = device.sensorOrientation + levelSessions[device.address] = session + session.start() + return + } + + val session = BmsSession( + deviceID = device.id, + gattProvider = provider, + handler = handler, + onUpdate = { record(it) }, + onStateChange = { handleLinkState(device, it) }, + onDiagnostics = { info -> publish { bmsDiagnostics[device.id] = info } }, + ) + session.onFridgeState = { state -> + fridgeStateCache[device.address] = state + publish { fridgeStates[device.id] = state } + } + session.fridgeZoneMode = device.fridgeZoneMode + bmsSessions[device.address] = session + session.start() + flushPendingControls(device.address, session) + } + + /** + * Wertet aus, was eine Sitzung über ihren Verbindungszustand meldet, und + * pflegt daraus die Wartesperren. + */ + private fun handleLinkState(device: ManagedDevice, state: DeviceLinkState) { + when (state) { + is DeviceLinkState.Live -> { + if (device.address !in connectedSince) { + connectedSince[device.address] = System.currentTimeMillis() + } + connecting.remove(device.address) + } + + is DeviceLinkState.Searching -> { + // Getrennt. Wie lange die Verbindung stand, entscheidet, wie + // lange gewartet wird. + val lifetime = connectedSince.remove(device.address) + ?.let { System.currentTimeMillis() - it } ?: 0 + connecting.remove(device.address) + bmsSessions.remove(device.address) + levelSessions.remove(device.address) + if (lifetime >= STABLE_CONNECTION_MS) { + // Die Verbindung stand und ist weggefallen – im Fahrzeug + // der Normalfall. Kurz durchatmen, dann wieder ran, sonst + // wäre das Gerät minutenlang nicht bedienbar. + clearBackOff(device.address) + nextConnectAttempt[device.address] = + System.currentTimeMillis() + QUICK_RETRY_DELAY_MS + } else { + // Sofort wieder abgebrochen: das zählt wie ein Fehlschlag. + backOff(device.address) + } + } + + is DeviceLinkState.Failed -> { + connecting.remove(device.address) + bmsSessions.remove(device.address) + levelSessions.remove(device.address) + backOff(device.address) + } + + else -> Unit + } + publish { linkStates[device.id] = state } + } + + /** + * Sperrt weitere Versuche für eine Weile. Der Abstand wächst mit jedem + * Fehlschlag, damit ein dauerhaft unerreichbares Gerät nicht endlos + * angeklopft wird. + */ + private fun backOff(address: String) { + val failures = (connectFailures[address] ?: 0) + 1 + connectFailures[address] = failures + val delay = min( + FIRST_RETRY_DELAY_MS * 2.0.pow(failures - 1), + LONGEST_RETRY_DELAY_MS.toDouble(), + ) + nextConnectAttempt[address] = System.currentTimeMillis() + delay.toLong() + } + + private fun clearBackOff(address: String) { + connectFailures.remove(address) + nextConnectAttempt.remove(address) + } + + // MARK: - Scan + + private val scanCallback = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) { + handler.post { handleScanResult(result) } + } + + override fun onBatchScanResults(results: MutableList) { + handler.post { results.forEach { handleScanResult(it) } } + } + + override fun onScanFailed(errorCode: Int) { + publish { bluetoothStatusText = "Suche fehlgeschlagen (Code $errorCode)" } + } + } + + private fun handleScanResult(result: ScanResult) { + val address = result.device.address + val now = System.currentTimeMillis() + val record = result.scanRecord + + // Android liefert die Herstellerdaten ohne die zwei Bytes der + // Company-ID; die steht im Schlüssel der Tabelle. Wir setzen sie wieder + // davor, damit der Rahmen genauso aussieht wie unter iOS. + val manufacturerData = record?.manufacturerSpecificData?.let { table -> + val index = table.indexOfKey(VictronAdvertisement.COMPANY_IDENTIFIER) + if (index >= 0) { + byteArrayOf( + (VictronAdvertisement.COMPANY_IDENTIFIER and 0xFF).toByte(), + (VictronAdvertisement.COMPANY_IDENTIFIER shr 8).toByte(), + ) + table.valueAt(index) + } else { + null + } + } + + if (discovering) { + updateDiscovery(result, manufacturerData, now) + } + + val device = managed[address] ?: return + val last = lastHandledAdvertisement[address] ?: 0 + if (now - last < MINIMUM_ADVERTISEMENT_INTERVAL_MS) return + lastHandledAdvertisement[address] = now + + when (device.transport) { + DeviceTransport.ADVERTISEMENT -> + manufacturerData?.let { handleVictron(it, device, result.rssi) } + + DeviceTransport.CONNECT -> + // Das Gerät wurde gesehen – falls die Verbindung fehlt, jetzt aufbauen. + connectIfNeeded(device) + } + } + + private fun updateDiscovery(result: ScanResult, manufacturerData: ByteArray?, now: Long) { + val address = result.device.address + val envelope = manufacturerData?.let { VictronAdvertisement.envelope(it) } + val existing = discoveries[address] + val discovery = Discovery( + address = address, + name = result.scanRecord?.deviceName ?: result.device.name, + // Geglättet, sonst springt die Liste beim Lesen. + rssi = existing?.let { (it.rssi * 2 + result.rssi) / 3 } ?: result.rssi, + firstSeen = existing?.firstSeen ?: now, + lastSeen = now, + isVictron = envelope != null, + victronRecord = envelope?.knownRecord?.name, + serviceUUIDs = result.scanRecord?.serviceUuids?.map { it.uuid.toString() } ?: emptyList(), + ) + publish { discoveries[address] = discovery } + } + + private fun handleVictron(data: ByteArray, device: ManagedDevice, rssi: Int) { + val envelope = VictronAdvertisement.envelope(data) ?: return + publish { + diagnostics[device.id] = VictronDiagnostics( + expectedKeyFirstByte = envelope.keyCheckByte, + recordName = envelope.knownRecord?.name ?: "unbekannt (0x%02X)".format(envelope.recordType), + productIDText = envelope.productIDText, + rawHex = data.toHexText(), + ) + } + + val key = device.victronKey + if (key == null || key.size != 16) { + publish { linkStates[device.id] = DeviceLinkState.NeedsKey } + return + } + try { + val snapshot = VictronAdvertisement.decode(data, key, device.id, rssi) + publish { linkStates[device.id] = DeviceLinkState.Live } + record(snapshot) + } catch (error: VictronAdvertisement.DecodeError) { + publish { linkStates[device.id] = DeviceLinkState.Failed(error.describe) } + } + } + + // MARK: - Verlauf + + private fun record(snapshot: DeviceSnapshot) { + publish { + snapshots[snapshot.deviceID] = snapshot + snapshot.primaryMetric?.value?.let { value -> + val samples = history.getOrPut(snapshot.deviceID) { mutableListOf() } + samples.add(HistorySample(snapshot.timestamp, value)) + while (samples.size > HISTORY_LIMIT) samples.removeAt(0) + } + } + } + + // MARK: - Kühlbox steuern + + fun setFridgeTarget(celsius: Int, zone: FridgeZone, deviceID: UUID) { + sendFridgeSettings(deviceID) { AlpicoolState.setTarget(zone, celsius) } + } + + fun setFridgePower(on: Boolean, deviceID: UUID) { + sendFridgeSettings(deviceID) { it.settingsCommand(poweredOn = on) } + } + + fun setFridgeEco(eco: Boolean, deviceID: UUID) { + sendFridgeSettings(deviceID) { it.settingsCommand(eco = eco) } + } + + fun setFridgeLock(locked: Boolean, deviceID: UUID) { + sendFridgeSettings(deviceID) { it.settingsCommand(locked = locked) } + } + + /** + * Der Einstellungsblock wird aus dem zuletzt empfangenen Zustand gebaut – + * und zwar auf dem Funk-Thread, wo dieser Zustand lebt. + * + * Fehlt die Verbindung gerade, wird der Befehl aufgehoben und ein Versuch + * angestossen. Ein Tippen darf nicht daran scheitern, dass die Box sich + * zwei Sekunden vorher abgemeldet hat. + */ + private fun sendFridgeSettings(deviceID: UUID, build: (AlpicoolState) -> ByteArray?) { + handler.post { + val device = managed.values.firstOrNull { it.id == deviceID } ?: return@post + val session = bmsSessions[device.address] + if (session != null) { + build(session.alpicoolState)?.let { session.sendControl(it) } + return@post + } + val known = fridgeStateCache[device.address] ?: return@post + val packet = build(known) ?: return@post + val waiting = pendingControls.getOrPut(device.address) { mutableListOf() } + waiting.add(packet to System.currentTimeMillis()) + // Wer mehrfach tippt, meint den letzten Stand. + while (waiting.size > 4) waiting.removeAt(0) + + // Der Wunsch des Benutzers hebt die Wartesperre auf. + clearBackOff(device.address) + connectIfNeeded(device) + } + } + + /** Gibt weiter, was während der Trennung aufgelaufen ist. */ + private fun flushPendingControls(address: String, session: BmsSession) { + val waiting = pendingControls.remove(address) ?: return + val now = System.currentTimeMillis() + waiting.filter { now - it.second < CONTROL_LIFETIME_MS } + .forEachIndexed { index, entry -> + handler.postDelayed({ session.sendControl(entry.first) }, index * 400L) + } + } + + fun updateFridgeZoneMode(device: ConfiguredDevice) { + handler.post { + val session = bmsSessions[device.address] ?: return@post + session.fridgeZoneMode = device.fridgeZoneMode + val state = session.alpicoolState + publish { fridgeStates[device.id] = state } + } + } + + // MARK: - Neigungsmesser + + fun updateSensorOrientation(device: ConfiguredDevice) { + handler.post { levelSessions[device.address]?.orientation = device.sensorOrientation } + } + + fun calibrateLevel(deviceID: UUID) { + handler.post { levelSession(deviceID)?.calibrate() } + } + + fun resetLevelCalibration(deviceID: UUID) { + handler.post { levelSession(deviceID)?.resetCalibration() } + } + + private fun levelSession(deviceID: UUID): LevelSession? { + val device = managed.values.firstOrNull { it.id == deviceID } ?: return null + return levelSessions[device.address] + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BmsSession.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BmsSession.kt new file mode 100644 index 0000000..563081e --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/BmsSession.kt @@ -0,0 +1,859 @@ +package de.fritob.campermonitor.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.os.Handler +import de.fritob.campermonitor.protocol.AlpicoolProtocol +import de.fritob.campermonitor.protocol.AlpicoolState +import de.fritob.campermonitor.protocol.DalyProtocol +import de.fritob.campermonitor.protocol.DalyState +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceSnapshot +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.JbdProtocol +import de.fritob.campermonitor.protocol.JbdState +import de.fritob.campermonitor.protocol.WattCycleProtocol +import de.fritob.campermonitor.protocol.WattCycleState +import java.util.UUID + +/** Was die Diagnoseansicht über eine laufende Sitzung zeigt. */ +data class BmsDiagnostics( + val dialect: String, + val endpointLabel: String? = null, + val endpointPosition: Pair? = null, + val serviceUUID: String? = null, + val isConnected: Boolean = false, + val isNotifyActive: Boolean = false, + /** Nur bei Kühlboxen: ob die Box die Anmeldung beantwortet hat. */ + val isBound: Boolean? = null, + val confirmedWrites: Int = 0, + val lastWriteError: String? = null, + val gattSummary: List = emptyList(), + val sentFrames: Int = 0, + val receivedBytes: Int = 0, + val lastSendAt: Long? = null, + val lastResponseHex: String? = null, + val lastCommandHex: String? = null, + val lastCommandAt: Long? = null, + /** Nur bei Kühlboxen: die vollständige Nutzlast der letzten Statusantwort. */ + val fridgePayloadHex: String? = null, + val updated: Long = System.currentTimeMillis(), +) + +/** + * Hält die GATT-Verbindung zu einem BMS oder einer Kühlbox, fragt die Werte ab + * und meldet fertige Snapshots zurück. + * + * Zwei Dinge sind bei diesen Geräten nicht vorhersehbar und werden deshalb + * ausprobiert statt vorausgesetzt: + * + * 1. **Über welche Charakteristiken gesprochen wird.** Im selben Dienst sehen + * oft mehrere Charakteristiken beschreibbar aus, nur eine nimmt aber + * wirklich Kommandos an. + * 2. **Welches Protokoll gesprochen wird.** Der erste gültige Rahmen legt den + * Dialekt fest. + * + * Alles läuft auf dem übergebenen [Handler] – demselben, auf dem der + * BluetoothManager arbeitet. Android ruft die GATT-Rückmeldungen auf einem + * eigenen Binder-Thread auf; sie werden deshalb konsequent auf diesen Handler + * geschoben, damit der Zustand dieser Klasse nur von einem Thread aus + * angefasst wird. + */ +@SuppressLint("MissingPermission") +class BmsSession( + private val deviceID: UUID, + private val gattProvider: (BluetoothGattCallback) -> BluetoothGatt?, + private val handler: Handler, + private val onUpdate: (DeviceSnapshot) -> Unit, + private val onStateChange: (DeviceLinkState) -> Unit, + private val onDiagnostics: (BmsDiagnostics) -> Unit, +) { + /** Meldet Änderungen am Kühlbox-Zustand, damit die Bedienelemente folgen. */ + var onFridgeState: ((AlpicoolState) -> Unit)? = null + + enum class Dialect(val label: String) { + UNKNOWN("wird ermittelt"), + DALY_CLASSIC("Daly (klassisch)"), + DALY_MODBUS("Daly (Modbus)"), + JBD("JBD / Xiaoxiang"), + WATT_CYCLE("WattCycle"), + ALPICOOL("Alpicool-Kühlbox"), + } + + private companion object { + /** Bekannte Paare, die zuerst versucht werden. */ + val KNOWN_PAIRS = listOf( + Triple("FFF0", "FFF2", "FFF1"), // Daly und viele baugleiche Module + Triple("FF00", "FF02", "FF01"), // JBD / Xiaoxiang + Triple("FFE0", "FFE1", "FFE1"), + Triple( + "6E400001-B5A3-F393-E0A9-E50E24DCCA9E", + "6E400002-B5A3-F393-E0A9-E50E24DCCA9E", + "6E400003-B5A3-F393-E0A9-E50E24DCCA9E", + ), // Nordic UART + ) + + val ALPICOOL_WRITE = shortUuid("1235") + val ALPICOOL_NOTIFY = shortUuid("1236") + + /** + * Freischalt-Charakteristik der WattCycle-Akkus. Liegt im selben Dienst + * wie Schreiben und Empfangen und muss vor der ersten Abfrage + * beschrieben werden, sonst bleibt der Akku stumm. + */ + val WATTCYCLE_AUTH = shortUuid("FFFA") + + val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") + + const val SEARCH_INTERVAL_MS = 3_000L + const val POLL_INTERVAL_MS = 5_000L + + /** So lange darf ein aufgehobener Stellbefehl warten. */ + const val CONTROL_LIFETIME_MS = 30_000L + + fun shortUuid(short: String): UUID = + UUID.fromString("0000${short.padStart(4, '0')}-0000-1000-8000-00805F9B34FB") + + fun uuidOf(text: String): UUID = + if (text.length == 4) shortUuid(text) else UUID.fromString(text) + } + + /** + * 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 class Endpoint( + val write: BluetoothGattCharacteristic, + val notify: BluetoothGattCharacteristic, + /** Falls vorhanden, wird hierauf vor der ersten Abfrage freigeschaltet. */ + val auth: BluetoothGattCharacteristic?, + val writeType: Int, + val isKnownPair: Boolean, + ) { + val label: String + get() { + val mode = if (writeType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) { + "ohne Bestätigung" + } else { + "mit Bestätigung" + } + val unlock = auth?.let { ", Freischaltung über ${it.uuid}" } ?: "" + return "${write.uuid} → ${notify.uuid}, $mode$unlock" + } + } + + private var gatt: BluetoothGatt? = null + private var endpoints: List = emptyList() + private var endpointIndex = 0 + private val currentEndpoint: Endpoint? get() = endpoints.getOrNull(endpointIndex) + + var dialect: Dialect = Dialect.UNKNOWN + private set + + private val dalyState = DalyState() + private val jbdState = JbdState() + private val wattCycleState = WattCycleState() + val alpicoolState = AlpicoolState() + + /** Aus den Geräteeinstellungen; übersteuert die automatische Erkennung. */ + var fridgeZoneMode: FridgeZoneMode = FridgeZoneMode.AUTOMATIC + set(value) { + field = value + alpicoolState.zoneMode = value + } + + private var didBind = false + private var bindAcknowledged = false + + /** + * Ob vor einem Stellbefehl schon einmal nachgemeldet wurde. Jedes Mal + * anzumelden lässt die Box bei jedem Tastendruck erneut piepen. + */ + private var didRebindForControl = false + + private var buffer = ByteArray(0) + private var lastResponse: ByteArray? = null + private var lastCommand: ByteArray? = null + private var lastCommandAt: Long? = null + private var receivedByteCount = 0 + private var sentFrameCount = 0 + private var gattSummary = listOf() + private var silentRounds = 0 + + /** + * Zählt hoch, sobald ein Kandidat aktiviert wird. Späte Rückmeldungen eines + * bereits verworfenen Kandidaten lassen sich so ignorieren. + */ + private var activationToken = 0 + private var isNotifyActive = false + private var didUnlock = false + private var lastSendAt: Long? = null + private var confirmedWrites = 0 + private var lastWriteError: String? = null + + /** + * Stellbefehle, die kamen, bevor der Kanal stand. Sie jetzt schon zu senden + * hiesse, sie an einen womöglich falschen Kandidaten zu schicken; sie + * fallen zu lassen hiesse, ein Tippen zu verschlucken. + */ + private val waitingControls = mutableListOf>() + + private var pollRunnable: Runnable? = null + + // MARK: - Lebenszyklus + + fun start() { + onStateChange(DeviceLinkState.Connecting) + gatt = gattProvider(callback) + } + + fun stop() { + cancelPoll() + val endpoint = currentEndpoint + val g = gatt + if (endpoint != null && g != null) { + g.setCharacteristicNotification(endpoint.notify, false) + } + endpoints = emptyList() + endpointIndex = 0 + dialect = Dialect.UNKNOWN + buffer = ByteArray(0) + gatt?.close() + gatt = null + } + + fun handleDisconnect() { + cancelPoll() + endpoints = emptyList() + endpointIndex = 0 + buffer = ByteArray(0) + gatt?.close() + gatt = null + } + + // MARK: - Kandidaten + + /** + * Stellt nach der Dienstsuche alle Paare zusammen: bekannte Kombinationen + * zuerst, danach jede andere Schreib-/Notify-Kombination im selben Dienst. + */ + private fun buildEndpoints(g: BluetoothGatt) { + val candidates = mutableListOf() + val summary = mutableListOf() + + for (service in g.services) { + summary.add("Dienst ${service.uuid}") + val characteristics = service.characteristics + for (c in characteristics) { + summary.add(" ${c.uuid} ${propertyText(c)}") + } + + val writable = characteristics.filter { + it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 || + it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0 + } + val notifying = characteristics.filter { + it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 || + it.properties and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0 + } + if (writable.isEmpty() || notifying.isEmpty()) continue + + val auth = characteristics.firstOrNull { + it.uuid == WATTCYCLE_AUTH && + (it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0 || + it.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) + } + + for (write in writable) { + if (write.uuid == WATTCYCLE_AUTH) continue + for (notify in notifying) { + val isFridgePair = write.uuid == ALPICOOL_WRITE && notify.uuid == ALPICOOL_NOTIFY + val known = isFridgePair || KNOWN_PAIRS.any { + uuidOf(it.first) == service.uuid && + uuidOf(it.second) == write.uuid && + uuidOf(it.third) == notify.uuid + } + // Beide Schreibarten anbieten, sofern das Gerät sie kann. + if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) { + candidates.add( + Endpoint(write, notify, auth, + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE, known) + ) + } + if (write.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) { + candidates.add( + Endpoint(write, notify, auth, + BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT, known) + ) + } + } + } + } + gattSummary = summary + + // Bekannte Paare nach vorn, der Rest in Fundreihenfolge. Die + // Fundreihenfolge muss dabei erhalten bleiben, sonst entschiede der + // Zufall, ob mit oder ohne Bestätigung geschrieben wird - `sortedBy` + // ist in Kotlin stabil und genau dafür da. + endpoints = candidates.sortedBy { if (it.isKnownPair) 0 else 1 } + endpointIndex = 0 + + if (endpoints.isEmpty()) { + onStateChange(DeviceLinkState.Failed("Keine passenden Bluetooth-Merkmale gefunden")) + publishDiagnostics() + return + } + activateCurrentEndpoint() + } + + private fun propertyText(c: BluetoothGattCharacteristic): String { + val parts = mutableListOf() + val p = c.properties + if (p and BluetoothGattCharacteristic.PROPERTY_READ != 0) parts.add("read") + if (p and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) parts.add("write") + if (p and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) parts.add("write-nr") + if (p and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) parts.add("notify") + if (p and BluetoothGattCharacteristic.PROPERTY_INDICATE != 0) parts.add("indicate") + return parts.joinToString(", ") + } + + private fun activateCurrentEndpoint() { + val endpoint = currentEndpoint ?: return + val g = gatt ?: return + silentRounds = 0 + isNotifyActive = false + didUnlock = false + didBind = false + bindAcknowledged = false + didRebindForControl = false + buffer = ByteArray(0) + activationToken += 1 + val token = activationToken + + g.setCharacteristicNotification(endpoint.notify, true) + // Ohne geschriebenen Deskriptor schickt das Gerät nichts – anders als + // unter iOS reicht das Einschalten auf unserer Seite nicht. + endpoint.notify.getDescriptor(CLIENT_CONFIG)?.let { descriptor -> + descriptor.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(descriptor) + } + publishDiagnostics() + + // Manche Module bestätigen das Abonnieren nie. Ohne Zeitlimit bliebe + // die Suche hier für immer stehen, ohne je etwas zu senden. + handler.postDelayed({ + if (activationToken != token || isNotifyActive) return@postDelayed + if (endpoints.size > 1) advanceEndpoint() else beginPolling() + }, 4_000) + } + + /** + * Wechselt auf den nächsten Kandidaten. Sind alle durch, wird von vorn + * begonnen – das Gerät kann zwischenzeitlich aufgewacht sein. + */ + private fun advanceEndpoint() { + val previous = currentEndpoint ?: return + gatt?.setCharacteristicNotification(previous.notify, false) + endpointIndex = (endpointIndex + 1) % endpoints.size + onStateChange(DeviceLinkState.Connecting) + activateCurrentEndpoint() + beginPolling() + } + + /** Derselbe Kanal, nur mit der anderen Schreibart. */ + private fun alternateWriteTypeIndex(): Int? { + val current = currentEndpoint ?: return null + val index = endpoints.indexOfFirst { + it.write.uuid == current.write.uuid && + it.notify.uuid == current.notify.uuid && + it.writeType != current.writeType + } + return if (index >= 0) index else null + } + + private fun unlockThenPoll() { + val endpoint = currentEndpoint ?: return + val auth = endpoint.auth + val token = activationToken + if (auth == null || didUnlock) { + beginPolling() + return + } + // WattCycle verlangt den Text "HiLink", bevor es überhaupt antwortet. + writeRaw(auth, WattCycleProtocol.authPayload, endpoint.writeType) + didUnlock = true + publishDiagnostics() + handler.postDelayed({ + if (activationToken == token) beginPolling() + }, 300) + } + + // MARK: - Abfragen + + private fun cancelPoll() { + pollRunnable?.let { handler.removeCallbacks(it) } + pollRunnable = null + } + + private fun beginPolling() { + cancelPoll() + flushWaitingControls() + poll() + val interval = if (dialect == Dialect.UNKNOWN) SEARCH_INTERVAL_MS else POLL_INTERVAL_MS + val runnable = object : Runnable { + override fun run() { + poll() + handler.postDelayed(this, interval) + } + } + pollRunnable = runnable + handler.postDelayed(runnable, interval) + } + + private fun poll() { + if (currentEndpoint == null) return + when (dialect) { + Dialect.UNKNOWN -> + // Alle Protokolle anfragen; was antwortet, gewinnt. + sendSequence( + listOf( + AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND), + AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY), + WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG), + DalyProtocol.requestFrame(DalyProtocol.Command.SOC), + JbdProtocol.requestFrame(JbdProtocol.Command.BASIC_INFO), + DalyProtocol.modbusReadFrame(), + ), + spacingMs = 600, graceMs = 3_000, + ) + + Dialect.DALY_CLASSIC -> + sendSequence( + DalyProtocol.Command.entries.map { DalyProtocol.requestFrame(it) }, + spacingMs = 250, graceMs = 2_000, + ) + + Dialect.DALY_MODBUS -> + sendSequence(listOf(DalyProtocol.modbusReadFrame()), spacingMs = 250, graceMs = 2_000) + + Dialect.JBD -> + sendSequence( + JbdProtocol.Command.entries.map { JbdProtocol.requestFrame(it) }, + spacingMs = 250, graceMs = 2_000, + ) + + Dialect.WATT_CYCLE -> { + // Modell und Seriennummer ändern sich nie – nur einmal abfragen. + val frames = mutableListOf( + WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.ANALOG) + ) + if (!wattCycleState.hasProductInfo) { + frames.add(WattCycleProtocol.requestFrame(WattCycleProtocol.Datapoint.PRODUCT)) + } + sendSequence(frames, spacingMs = 300, graceMs = 2_000) + } + + Dialect.ALPICOOL -> { + // Die Anmeldung gilt für die Dauer der Verbindung. + val frames = mutableListOf() + if (!didBind) { + frames.add(AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND)) + didBind = true + } + frames.add(AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY)) + sendSequence(frames, spacingMs = 300, graceMs = 2_000) + } + } + } + + /** + * Kommandos leicht versetzt senden – manche Module verschlucken Anfragen, + * die zu dicht aufeinander folgen. + */ + private fun sendSequence(frames: List, spacingMs: Long, graceMs: Long) { + frames.forEachIndexed { index, frame -> + handler.postDelayed({ send(frame) }, index * spacingMs) + } + checkForSilence(frames.size * spacingMs + graceMs) + } + + /** Kommt nichts Brauchbares zurück, wird der nächste Kandidat versucht. */ + private fun checkForSilence(delayMs: Long) { + handler.postDelayed({ + if (gatt == null) return@postDelayed + if (hasUsableData) { + silentRounds = 0 + return@postDelayed + } + silentRounds += 1 + publishDiagnostics() + + // Solange noch kein Protokoll steht, zügig weiterprobieren. + val limit = if (dialect == Dialect.UNKNOWN) 1 else 3 + if (silentRounds <= limit) return@postDelayed + + silentRounds = 0 + if (endpoints.size > 1) { + advanceEndpoint() + } else { + dialect = Dialect.UNKNOWN + onStateChange(DeviceLinkState.Failed("Keine Antwort vom Gerät")) + } + }, delayMs) + } + + private val hasUsableData: Boolean + get() = dalyState.hasUsableData || jbdState.hasUsableData || + wattCycleState.hasUsableData || alpicoolState.hasStatus + + // MARK: - Senden + + private fun send(data: ByteArray) { + val endpoint = currentEndpoint ?: return + val g = gatt ?: return + sentFrameCount += 1 + lastSendAt = System.currentTimeMillis() + + val pieces = AlpicoolProtocol.chunks(data, writeLimit(endpoint)) + pieces.forEachIndexed { index, piece -> + if (index == 0) { + writeRaw(endpoint.write, piece, endpoint.writeType) + } else { + handler.postDelayed( + { currentEndpoint?.let { writeRaw(it.write, piece, it.writeType) } }, + index * AlpicoolProtocol.CHUNK_DELAY_MS, + ) + } + } + publishDiagnostics() + } + + @Suppress("DEPRECATION") + private fun writeRaw( + characteristic: BluetoothGattCharacteristic, + data: ByteArray, + writeType: Int, + ) { + val g = gatt ?: return + characteristic.writeType = writeType + characteristic.value = data + g.writeCharacteristic(characteristic) + } + + /** + * Wieviel je Schreibvorgang rausgeht. + * + * Grundsätzlich das, was die Verbindung hergibt. Die Kühlboxen nehmen aber + * nur die 20 Byte der Standard-MTU an, auch wenn eine grössere ausgehandelt + * wurde. Ohne diese Grenze ginge der Einstellungsblock als ein + * Schreibvorgang raus – und die Box würde ihn ablehnen, während die kurzen + * Befehle durchgehen. Belegt an einer IceCube Dual. + */ + private fun writeLimit(endpoint: Endpoint): Int = + if (dialect == Dialect.ALPICOOL) AlpicoolProtocol.MAX_WRITE_SIZE else negotiatedWriteLimit + + /** Was die ausgehandelte MTU je Schreibvorgang zulässt. */ + private var negotiatedWriteLimit = 20 + + // MARK: - Steuern + + /** + * Schickt einen Stellbefehl und fragt kurz darauf den Zustand ab, damit die + * Anzeige dem Gerät folgt statt der Vermutung. + */ + fun sendControl(packet: ByteArray) { + lastCommand = packet + lastCommandAt = System.currentTimeMillis() + + // Erst wenn der Dialekt steht, ist auch der richtige Kanal bekannt. + if (dialect != Dialect.ALPICOOL || currentEndpoint == null || gatt == null) { + waitingControls.add(packet to System.currentTimeMillis()) + while (waitingControls.size > 4) waitingControls.removeAt(0) + publishDiagnostics() + return + } + + // Hat die Box die Anmeldung nie beantwortet, wird sie einmal je + // Verbindung nachgeholt. Jedes Mal anzumelden lässt die Box bei jedem + // Tastendruck zusätzlich piepen. + if (!bindAcknowledged && !didRebindForControl) { + didRebindForControl = true + send(AlpicoolProtocol.packet(AlpicoolProtocol.Command.BIND)) + handler.postDelayed({ deliverControl(packet, attempt = 0) }, 400) + return + } + deliverControl(packet, attempt = 0) + } + + /** + * Schickt den Befehl und prüft, ob er gewirkt hat. + * + * Manche Module nehmen nur eine der beiden Schreibarten an und melden das + * nicht – der Befehl verschwindet dann lautlos. Bleiben die Einstellungen + * der Box unverändert, wird deshalb einmal mit der anderen Art nachgesetzt. + */ + private fun deliverControl(packet: ByteArray, attempt: Int) { + val before = alpicoolState.settingsFingerprint + val token = activationToken + send(packet) + + // Genug Abstand, damit ein aufgeteiltes Paket vollständig draussen ist. + handler.postDelayed({ + if (dialect == Dialect.ALPICOOL) { + send(AlpicoolProtocol.packet(AlpicoolProtocol.Command.QUERY)) + } + }, 1_000) + + if (attempt > 0) return + handler.postDelayed({ + if (activationToken != token || dialect != Dialect.ALPICOOL || gatt == null) { + return@postDelayed + } + if (alpicoolState.settingsFingerprint != before) return@postDelayed + val index = alternateWriteTypeIndex() ?: return@postDelayed + endpointIndex = index + publishDiagnostics() + deliverControl(packet, attempt = 1) + }, 3_500) + } + + /** Schickt raus, was während des Verbindungsaufbaus aufgelaufen ist. */ + private fun flushWaitingControls() { + if (dialect != Dialect.ALPICOOL || waitingControls.isEmpty()) return + val now = System.currentTimeMillis() + val due = waitingControls.filter { now - it.second < CONTROL_LIFETIME_MS } + waitingControls.clear() + due.forEachIndexed { index, entry -> + handler.postDelayed({ sendControl(entry.first) }, index * 400L) + } + } + + // MARK: - Auswertung + + private fun consume(data: ByteArray) { + lastResponse = data + receivedByteCount += data.size + buffer += data + if (buffer.size > 512) buffer = buffer.copyOfRange(buffer.size - 512, buffer.size) + + // Steht der Dialekt fest, nur noch diesen prüfen. Bei jeder Antwort + // alle fünf Parser durchzugehen belastet ohne Nutzen. + when (dialect) { + Dialect.ALPICOOL -> { consumeAlpicool(); return } + Dialect.WATT_CYCLE -> { consumeWattCycle(); return } + Dialect.JBD -> { consumeJbd(); return } + Dialect.DALY_MODBUS -> { consumeDalyModbus(); return } + Dialect.DALY_CLASSIC -> { consumeDalyClassic(); return } + Dialect.UNKNOWN -> Unit + } + + if (consumeAlpicool()) return + if (consumeWattCycle()) return + if (consumeJbd()) return + if (consumeDalyModbus()) return + if (consumeDalyClassic()) return + + // Etwas kam an, liess sich aber nicht zuordnen: für die Diagnose + // sichtbar machen, damit sich das Protokoll bestimmen lässt. + publishDiagnostics() + } + + private fun consumeAlpicool(): Boolean { + val (frames, remainder) = AlpicoolProtocol.extractFrames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.ALPICOOL) + if (frames.any { it.command == AlpicoolProtocol.Command.BIND.raw }) { + bindAcknowledged = true + } + frames.forEach { alpicoolState.apply(it) } + alpicoolState.zoneMode = fridgeZoneMode + onFridgeState?.invoke(alpicoolState) + publish(alpicoolState.snapshot(deviceID, null), alpicoolState.hasStatus) + return true + } + + private fun consumeWattCycle(): Boolean { + val (frames, remainder) = WattCycleProtocol.extractFrames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.WATT_CYCLE) + frames.forEach { wattCycleState.apply(it) } + publish(wattCycleState.snapshot(deviceID, null), wattCycleState.hasUsableData) + return true + } + + private fun consumeJbd(): Boolean { + val (frames, remainder) = JbdProtocol.extractFrames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.JBD) + frames.forEach { jbdState.apply(it) } + publish(jbdState.snapshot(deviceID, null), jbdState.hasUsableData) + return true + } + + private fun consumeDalyModbus(): Boolean { + val start = buffer.indexOfFirst { (it.toInt() and 0xFF) == 0xD2 } + if (start < 0) return false + val registers = DalyProtocol.parseModbusResponse( + buffer.copyOfRange(start, buffer.size) + ) ?: return false + buffer = ByteArray(0) + adopt(Dialect.DALY_MODBUS) + dalyState.apply(registers) + publish(dalyState.snapshot(deviceID, null), dalyState.hasUsableData) + return true + } + + private fun consumeDalyClassic(): Boolean { + val (frames, remainder) = DalyProtocol.extractA5Frames(buffer) + if (frames.isEmpty()) return false + buffer = remainder + adopt(Dialect.DALY_CLASSIC) + frames.forEach { dalyState.apply(it) } + publish(dalyState.snapshot(deviceID, null), dalyState.hasUsableData) + return true + } + + /** + * Erster verwertbarer Rahmen: Kandidat und Dialekt stehen fest, ab jetzt im + * normalen Takt abfragen. + */ + private fun adopt(newDialect: Dialect) { + silentRounds = 0 + if (dialect == newDialect) return + dialect = newDialect + beginPolling() + } + + private fun publish(snapshot: DeviceSnapshot, usable: Boolean) { + publishDiagnostics() + if (!usable) return + onStateChange(DeviceLinkState.Live) + onUpdate(snapshot) + } + + private fun publishDiagnostics() { + onDiagnostics( + BmsDiagnostics( + dialect = dialect.label, + endpointLabel = currentEndpoint?.label, + endpointPosition = if (endpoints.isEmpty()) null else (endpointIndex + 1) to endpoints.size, + serviceUUID = currentEndpoint?.write?.service?.uuid?.toString(), + isConnected = gatt != null, + isNotifyActive = isNotifyActive, + isBound = if (dialect == Dialect.ALPICOOL) bindAcknowledged else null, + confirmedWrites = confirmedWrites, + lastWriteError = lastWriteError, + gattSummary = gattSummary, + sentFrames = sentFrameCount, + receivedBytes = receivedByteCount, + lastSendAt = lastSendAt, + lastResponseHex = lastResponse?.toHexText(), + lastCommandHex = lastCommand?.toHexText(), + lastCommandAt = lastCommandAt, + fridgePayloadHex = alpicoolState.lastPayload + .takeIf { it.isNotEmpty() }?.toHexText(), + ) + ) + } + + // MARK: - GATT-Rückmeldungen + // + // Android ruft diese auf einem eigenen Thread auf. Alles wird deshalb auf + // unseren Handler geschoben, bevor es Zustand anfasst. + + private val callback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + handler.post { + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt = g + // Eine grössere MTU aushandeln; für die Kühlbox gilt + // trotzdem ihre eigene Grenze von 20 Byte. + g.requestMtu(247) + } else { + onStateChange(DeviceLinkState.Searching) + handleDisconnect() + } + } + } + + override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + handler.post { + negotiatedWriteLimit = (mtu - 3).coerceAtLeast(20) + g.discoverServices() + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + handler.post { + if (status != BluetoothGatt.GATT_SUCCESS) { + onStateChange(DeviceLinkState.Failed("Dienste nicht lesbar")) + return@post + } + buildEndpoints(g) + } + } + + override fun onDescriptorWrite( + g: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + ) { + handler.post { + if (descriptor.characteristic.uuid != currentEndpoint?.notify?.uuid) return@post + isNotifyActive = status == BluetoothGatt.GATT_SUCCESS + publishDiagnostics() + if (isNotifyActive) unlockThenPoll() else advanceEndpoint() + } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + val value = characteristic.value ?: return + if (value.isEmpty()) return + handler.post { consume(value) } + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + if (value.isEmpty()) return + handler.post { consume(value) } + } + + /** + * Nur bei Schreibvorgängen mit Bestätigung. Ohne Bestätigung meldet + * Android nichts zurück – auch keinen Fehler. + */ + override fun onCharacteristicWrite( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + handler.post { + if (status == BluetoothGatt.GATT_SUCCESS) { + lastWriteError = null + confirmedWrites += 1 + } else { + lastWriteError = "GATT-Fehler $status" + } + publishDiagnostics() + } + } + } +} + +internal fun ByteArray.toHexText(): String = joinToString(" ") { "%02X".format(it) } diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt new file mode 100644 index 0000000..9f97f1f --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/bluetooth/LevelSession.kt @@ -0,0 +1,274 @@ +package de.fritob.campermonitor.bluetooth + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.os.Handler +import de.fritob.campermonitor.protocol.DeviceLinkState +import de.fritob.campermonitor.protocol.DeviceSnapshot +import de.fritob.campermonitor.protocol.LevelState +import de.fritob.campermonitor.protocol.SensorOrientation +import de.fritob.campermonitor.protocol.VanAlignProtocol +import java.util.UUID + +/** + * Verbindung zum Neigungsmesser „VanAlign Pro". + * + * Anders als beim BMS gibt es hier kein Rahmenprotokoll: Längs- und + * Querneigung liegen in je einer eigenen Charakteristik als 32-Bit-Float. + * Meldet die Firmware sie per Notify, wird abonniert; sonst wird in Abständen + * gelesen. Ältere Stände können beides nicht gleichzeitig. + */ +@SuppressLint("MissingPermission") +class LevelSession( + private val deviceID: UUID, + private val gattProvider: (BluetoothGattCallback) -> BluetoothGatt?, + private val handler: Handler, + private val onUpdate: (DeviceSnapshot) -> Unit, + private val onStateChange: (DeviceLinkState) -> Unit, + private val onLevelState: (LevelState) -> Unit, +) { + private companion object { + val SERVICE: UUID = UUID.fromString(VanAlignProtocol.SERVICE_UUID) + val PITCH: UUID = UUID.fromString(VanAlignProtocol.PITCH_UUID) + val ROLL: UUID = UUID.fromString(VanAlignProtocol.ROLL_UUID) + val OFFSETS: UUID = UUID.fromString(VanAlignProtocol.OFFSETS_UUID) + val CALIBRATE: UUID = UUID.fromString(VanAlignProtocol.CALIBRATE_UUID) + val CLIENT_CONFIG: UUID = UUID.fromString("00002902-0000-1000-8000-00805F9B34FB") + const val POLL_INTERVAL_MS = 1_000L + } + + /** Die Einbaulage des Sensors; wird auf jede Messung angewandt. */ + var orientation: SensorOrientation = SensorOrientation.IDENTITY + set(value) { + field = value + applyOrientation() + } + + private var gatt: BluetoothGatt? = null + private var pitchCharacteristic: BluetoothGattCharacteristic? = null + private var rollCharacteristic: BluetoothGattCharacteristic? = null + private var offsetsCharacteristic: BluetoothGattCharacteristic? = null + private var calibrateCharacteristic: BluetoothGattCharacteristic? = null + + /** Ob die Firmware die Werte von sich aus meldet. */ + private var needsPolling = true + private var pollRunnable: Runnable? = null + + private var state = LevelState() + + fun start() { + onStateChange(DeviceLinkState.Connecting) + gatt = gattProvider(callback) + } + + fun stop() { + cancelPoll() + gatt?.close() + gatt = null + clearCharacteristics() + } + + fun handleDisconnect() { + cancelPoll() + gatt?.close() + gatt = null + clearCharacteristics() + } + + private fun clearCharacteristics() { + pitchCharacteristic = null + rollCharacteristic = null + offsetsCharacteristic = null + calibrateCharacteristic = null + } + + /** Setzt die aktuelle Lage als neue Null. */ + fun calibrate() { + write(VanAlignProtocol.calibrateCommand) + } + + /** Verwirft die Kalibrierung. */ + fun resetCalibration() { + write(VanAlignProtocol.resetCommand) + } + + @Suppress("DEPRECATION") + private fun write(data: ByteArray) { + val characteristic = calibrateCharacteristic ?: return + val g = gatt ?: return + characteristic.writeType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + characteristic.value = data + g.writeCharacteristic(characteristic) + // Die Offsets ändern sich dadurch – kurz darauf neu lesen. + handler.postDelayed({ readOffsets() }, 400) + } + + private fun readOffsets() { + val characteristic = offsetsCharacteristic ?: return + gatt?.readCharacteristic(characteristic) + } + + private fun cancelPoll() { + pollRunnable?.let { handler.removeCallbacks(it) } + pollRunnable = null + } + + private fun startPollingIfNeeded() { + if (!needsPolling || pollRunnable != null) return + val runnable = object : Runnable { + override fun run() { + // Android verträgt nur eine ausstehende GATT-Anfrage; deshalb + // erst die eine lesen, die andere folgt in der Rückmeldung. + pitchCharacteristic?.let { gatt?.readCharacteristic(it) } + handler.postDelayed(this, POLL_INTERVAL_MS) + } + } + pollRunnable = runnable + handler.post(runnable) + } + + private fun applyOrientation() { + val (pitch, roll) = orientation.apply(state.rawPitch, state.rawRoll) + state = state.copy(pitch = pitch, roll = roll) + } + + private fun publish() { + onLevelState(state) + if (state.hasReading) { + onStateChange(DeviceLinkState.Live) + onUpdate(state.snapshot(deviceID, null)) + } + } + + private val callback = object : BluetoothGattCallback() { + + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + handler.post { + if (newState == BluetoothProfile.STATE_CONNECTED) { + gatt = g + g.discoverServices() + } else { + onStateChange(DeviceLinkState.Searching) + handleDisconnect() + } + } + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + handler.post { + val service = g.getService(SERVICE) + if (service == null) { + onStateChange(DeviceLinkState.Failed("Kein VanAlign-Dienst gefunden")) + return@post + } + pitchCharacteristic = service.getCharacteristic(PITCH) + rollCharacteristic = service.getCharacteristic(ROLL) + offsetsCharacteristic = service.getCharacteristic(OFFSETS) + calibrateCharacteristic = service.getCharacteristic(CALIBRATE) + + val canNotify = pitchCharacteristic?.let { + it.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0 + } ?: false + + if (canNotify) { + needsPolling = false + subscribe(g, pitchCharacteristic) + } else { + // Ältere Firmware meldet nichts von sich aus. + needsPolling = true + readOffsets() + } + } + } + + private fun subscribe(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic?) { + val c = characteristic ?: return + g.setCharacteristicNotification(c, true) + c.getDescriptor(CLIENT_CONFIG)?.let { + it.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE + g.writeDescriptor(it) + } + } + + override fun onDescriptorWrite( + g: BluetoothGatt, + descriptor: BluetoothGattDescriptor, + status: Int, + ) { + handler.post { + when (descriptor.characteristic.uuid) { + PITCH -> subscribe(g, rollCharacteristic) + ROLL -> readOffsets() + } + } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicRead( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + status: Int, + ) { + val value = characteristic.value ?: return + handler.post { handleValue(characteristic.uuid, value, wasRead = true) } + } + + override fun onCharacteristicRead( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + status: Int, + ) { + handler.post { handleValue(characteristic.uuid, value, wasRead = true) } + } + + @Suppress("DEPRECATION") + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + ) { + val value = characteristic.value ?: return + handler.post { handleValue(characteristic.uuid, value, wasRead = false) } + } + + override fun onCharacteristicChanged( + g: BluetoothGatt, + characteristic: BluetoothGattCharacteristic, + value: ByteArray, + ) { + handler.post { handleValue(characteristic.uuid, value, wasRead = false) } + } + } + + private fun handleValue(uuid: UUID, value: ByteArray, wasRead: Boolean) { + when (uuid) { + PITCH -> { + state = state.copy(rawPitch = VanAlignProtocol.angle(value)) + applyOrientation() + publish() + // Beim Abfragen folgt jetzt die zweite Achse. + if (wasRead && needsPolling) { + rollCharacteristic?.let { gatt?.readCharacteristic(it) } + } + } + + ROLL -> { + state = state.copy(rawRoll = VanAlignProtocol.angle(value)) + applyOrientation() + publish() + } + + OFFSETS -> { + VanAlignProtocol.offsets(value)?.let { (pitch, roll) -> + state = state.copy(pitchOffset = pitch, rollOffset = roll) + } + publish() + startPollingIfNeeded() + } + } + } +} diff --git a/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt new file mode 100644 index 0000000..d6dc56e --- /dev/null +++ b/Android/app/src/main/kotlin/de/fritob/campermonitor/store/DeviceStore.kt @@ -0,0 +1,231 @@ +package de.fritob.campermonitor.store + +import android.content.Context +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import de.fritob.campermonitor.protocol.ConfiguredDevice +import de.fritob.campermonitor.protocol.DeviceRole +import de.fritob.campermonitor.protocol.FridgeZoneMode +import de.fritob.campermonitor.protocol.Profile +import de.fritob.campermonitor.protocol.SensorOrientation +import org.json.JSONArray +import org.json.JSONObject +import java.util.UUID + +/** + * Hält Fahrzeuge und Geräte und legt sie ab. + * + * **Unterschied zur iOS-Fassung:** Dort liegen die Victron-Schlüssel im + * Schlüsselbund. Android hat kein direktes Gegenstück, das ohne zusätzliche + * Bibliothek auskommt; sie liegen deshalb in einer eigenen, app-privaten + * Ablage. Das schützt gegen andere Apps, aber anders als der Schlüsselbund + * nicht hardwaregestützt. + */ +class DeviceStore(context: Context) { + + private val prefs = context.getSharedPreferences("camper", Context.MODE_PRIVATE) + private val keyPrefs = context.getSharedPreferences("camper_keys", Context.MODE_PRIVATE) + + val profiles = mutableStateListOf() + val devices = mutableStateListOf() + + var activeProfileID: UUID by mutableStateOf(Profile.DEFAULT_ID) + private set + + private var diagnosticsShown by mutableStateOf(prefs.getBoolean("showDiagnostics", false)) + + /** + * Ob die technischen Angaben in der Oberfläche eingeblendet werden. + * Zuweisen genügt – der Wert wird dabei abgelegt. + */ + var showDiagnostics: Boolean + get() = diagnosticsShown + set(value) { + diagnosticsShown = value + prefs.edit().putBoolean("showDiagnostics", value).apply() + } + + init { + load() + } + + val activeProfile: Profile? + get() = profiles.firstOrNull { it.id == activeProfileID } ?: profiles.firstOrNull() + + fun activeDevices(): List { + val profile = activeProfile?.id ?: return emptyList() + return devices.filter { it.profileID == profile } + } + + // MARK: - Ändern + + fun update(device: ConfiguredDevice) { + val index = devices.indexOfFirst { it.id == device.id } + if (index >= 0) devices[index] = device else devices.add(device) + save() + } + + fun remove(device: ConfiguredDevice) { + devices.removeAll { it.id == device.id } + keyPrefs.edit().remove(device.id.toString()).apply() + save() + } + + fun selectProfile(id: UUID) { + activeProfileID = id + save() + } + + fun addProfile(name: String): Profile { + val profile = Profile(name = name) + profiles.add(profile) + save() + return profile + } + + fun updateProfile(profile: Profile) { + val index = profiles.indexOfFirst { it.id == profile.id } + if (index >= 0) profiles[index] = profile else profiles.add(profile) + save() + } + + fun removeProfile(profile: Profile) { + // Das letzte Fahrzeug bleibt stehen – ohne eines hätte die App keinen + // Ort für Geräte. + if (profiles.size <= 1) return + profiles.removeAll { it.id == profile.id } + devices.filter { it.profileID == profile.id }.forEach { remove(it) } + if (activeProfileID == profile.id) { + profiles.firstOrNull()?.let { activeProfileID = it.id } + } + save() + } + + // MARK: - Victron-Schlüssel + + fun victronKeyText(deviceID: UUID): String? = keyPrefs.getString(deviceID.toString(), null) + + fun victronKey(deviceID: UUID): ByteArray? = + victronKeyText(deviceID)?.let { hexToBytes(it) }?.takeIf { it.size == 16 } + + fun setVictronKey(text: String, deviceID: UUID) { + val cleaned = text.filter { !it.isWhitespace() } + if (hexToBytes(cleaned)?.size == 16) { + keyPrefs.edit().putString(deviceID.toString(), cleaned).apply() + } else { + keyPrefs.edit().remove(deviceID.toString()).apply() + } + } + + // MARK: - Ablage + + private fun load() { + val json = prefs.getString("state", null) + if (json == null) { + profiles.add(Profile.initial()) + activeProfileID = Profile.DEFAULT_ID + return + } + try { + val root = JSONObject(json) + profiles.clear() + val profileArray = root.optJSONArray("profiles") ?: JSONArray() + for (i in 0 until profileArray.length()) { + profiles.add(profileFrom(profileArray.getJSONObject(i))) + } + devices.clear() + val deviceArray = root.optJSONArray("devices") ?: JSONArray() + for (i in 0 until deviceArray.length()) { + deviceFrom(deviceArray.getJSONObject(i))?.let { devices.add(it) } + } + activeProfileID = root.optString("activeProfile") + .takeIf { it.isNotEmpty() } + ?.let { runCatching { UUID.fromString(it) }.getOrNull() } + ?: Profile.DEFAULT_ID + } catch (_: Exception) { + // Eine unlesbare Ablage darf die App nicht am Start hindern. + profiles.clear() + devices.clear() + profiles.add(Profile.initial()) + activeProfileID = Profile.DEFAULT_ID + } + if (profiles.isEmpty()) profiles.add(Profile.initial()) + } + + private fun save() { + val root = JSONObject() + root.put("profiles", JSONArray().also { array -> + profiles.forEach { array.put(profileTo(it)) } + }) + root.put("devices", JSONArray().also { array -> + devices.forEach { array.put(deviceTo(it)) } + }) + root.put("activeProfile", activeProfileID.toString()) + prefs.edit().putString("state", root.toString()).apply() + } + + private fun profileTo(profile: Profile) = JSONObject().apply { + put("id", profile.id.toString()) + put("name", profile.name) + put("symbol", profile.symbol) + profile.trackWidth?.let { put("trackWidth", it) } + profile.wheelbase?.let { put("wheelbase", it) } + } + + private fun profileFrom(json: JSONObject) = Profile( + id = UUID.fromString(json.getString("id")), + name = json.getString("name"), + symbol = json.optString("symbol", "box_truck"), + // Fahrzeuge aus der Zeit vor den Massen haben noch keine. + trackWidth = if (json.has("trackWidth")) json.getDouble("trackWidth") else null, + wheelbase = if (json.has("wheelbase")) json.getDouble("wheelbase") else null, + ) + + private fun deviceTo(device: ConfiguredDevice) = JSONObject().apply { + put("id", device.id.toString()) + put("name", device.name) + put("role", device.role.name) + put("profileID", device.profileID.toString()) + put("address", device.address) + device.advertisedName?.let { put("advertisedName", it) } + put("fridgeZoneMode", device.fridgeZoneMode.name) + put("orientationSource", device.sensorOrientation.longitudinalSource.name) + put("invertLongitudinal", device.sensorOrientation.invertLongitudinal) + put("invertLateral", device.sensorOrientation.invertLateral) + } + + private fun deviceFrom(json: JSONObject): ConfiguredDevice? { + val role = runCatching { DeviceRole.valueOf(json.getString("role")) }.getOrNull() + ?: return null + return ConfiguredDevice( + id = UUID.fromString(json.getString("id")), + name = json.getString("name"), + role = role, + profileID = UUID.fromString(json.getString("profileID")), + address = json.getString("address"), + advertisedName = json.optString("advertisedName").takeIf { it.isNotEmpty() }, + fridgeZoneMode = runCatching { + FridgeZoneMode.valueOf(json.optString("fridgeZoneMode")) + }.getOrDefault(FridgeZoneMode.AUTOMATIC), + sensorOrientation = SensorOrientation( + longitudinalSource = runCatching { + SensorOrientation.Source.valueOf(json.optString("orientationSource")) + }.getOrDefault(SensorOrientation.Source.PITCH), + invertLongitudinal = json.optBoolean("invertLongitudinal", false), + invertLateral = json.optBoolean("invertLateral", false), + ), + ) + } + + private fun hexToBytes(text: String): ByteArray? { + val cleaned = text.filter { !it.isWhitespace() } + if (cleaned.length % 2 != 0) return null + return runCatching { + ByteArray(cleaned.length / 2) { + cleaned.substring(it * 2, it * 2 + 2).toInt(16).toByte() + } + }.getOrNull() + } +} diff --git a/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..08e5aa0ea1f6b8dfca14a545b5d23e2219fe73c4 GIT binary patch literal 8843 zcmV;6B6Qt}P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91NT34%1ONa40RR91NB{r;0FW_8?*ITH^+`lQRA>dgdwG~u)s^oV?^Jbb zf+|3&94Hh)0Z{}TFsNt})bJ8JF%EHVUmK$)J`-b`w4dD?$L_?ZF;UwXW6(s=sHiAG zG%A9KqRdkvR8d7ybJd;incnZW_Bpr6*Wdf2-yd(+<=j22z2?34+WTB|&+~L$*TNEL z_+vZJG@T88VU&HUNk9&&36+VeSsAN9s7fGT6=1=nXb=$w4=t)ko*)gfEUJlbR)8+z zSw<@$AvCAyzUN0=D(n>~RZ*Sb0>~)f-vg`B=!qHR191PfRM0BD9MU9gW!9k;^dEw% zG7`e8QH7@hNwcU)x>!swbCkSC=oFC5RMVlQQQH#z|KCDGNf~t=HYvN3KO9y~OZt>N zfKX$2Mxamt8is0W*<7LHK+l1m{(+%Pp;UHV*AGNRBCrx$=nFh`r}T%W!W^kH#1Gso z(HJNxLVTiXTUII=Z>X(4tf^sQQ$sQlSJ07U3RunNh_D?tEjA7vB1vgeBrGjbD3;1A zKV1LHUstc+xTCwbKVK;Oz8_5juZ9vS6f7ix77Ou*?BW|ubO0VSqF(7zlIpslX4CHp z4F(L$GOLn_!^SntnSRXTGiT2~X_{#oqCW8{PKbCJt!U92Q%gvpSGXw=`FM532M<2+ z=M9^8!ZUdXDL=ky@fowHVP*jJ5;=5)s(cfPbctFhn6e}>0~Y9rPtdZ(x&|X*Arwl^#fwgRAhiuXUMu1ax0@q@V)K&S~siA$JZT@4C^Vu)N} zRLwzG6lQ2`YKp4nphyMWB1K3J6jfW$D*7A}Rt&>HP+78kbt;*7_$OC{?8&f#!4rZ> zA%TcdU4P*5=U-d7y1FVEj&dyEP90KEEP!d%76Hh)ut}DD`~|Lynh~?jRLZET(qb{g zFm-6VSnzUruTTWMVaE_0ASM?}h5f)MHK8r43spwNM#2La5#^~@-aB#XQCEKBD`9`Q z#yB(D$DlUO;nMnI>+Xe@{HW|WPz-tmijYE=!l*t~8EuGaArc$~BAGQc@hQh9kDC#1 zog8m$veH#%%!Y>=ny)z}KQrWZ?$2-DI=o?1zO!3HM2f|9-Ha4PPC~&zm6XYb>cNv0 zlG7c}ZEUE0@5!HIGhiTLzj%SJ%RVMP60xg2^81&DvpML4aEBCVCDbX+hpL(ebYudf z@O8gbu*Wu5pZV3){Ig>dnoXNnMK^(l5RDp`PbTe#`sC!J8qS*EoXL!Q{7Ki#Z;tHR zXT;-%g^&vuMWF>n*Qk@($W$Z^j3lRPmT7i$_x+-Vx2?K78uV znyO?3!*6h}<^zVpez0z1Pu~!HMqwvv(T-F?yRb{DGJ?f~Ev6JC;^}K{t-0cVX|?0s zoaZ?nl2C*z-{TXMB${D6(p+Ej0v#2694{#4UC&B1U-=KS@4YqESP$70f3y-m@=sJM zJfIKgd@xNNLvN<}<^F@8@7T*xO2C2<2Jxta5`Mg4n_xm6_*n#6DgugG1dxvs4^nCA z>YD5COw3v67d)>Vi3HX?077+UV41#U2DYi$W?-0ZU_dl5P8G|d>n;RsuD4U78os;o%LJy$cQi|rm|3_(J+T{X}k7z&|IPH)Cpx{iN2)~ zw$KOOV|Mzg+hdbY@C)!iT|$}w!JFfn=X&MtF0cQ9mmSsv*Gwd0jZKN>7Nf3;mh?P{ z`edO{*19)r#=eTh2(K1)&=0SZZAy3mml3Cm55;(}V_z5BG@;@)4EF>^DV55-{X-^w znqpFXjD(ojp=6AP*bLlK^4r&2$DHI9T&^hafZ3Q5Go9i7?3-`oRyE{&rl#6cPaFF`zFmFHWFLvNkGOB{e*WbzfA?Ixre3H31^L7f7y}*Q5FDVJjmi^@ z0^nf(kn~}UIzl-#yVuK(TPPMqY(_v~oET+}!bYs9@q>y}G-sR|JLMZ*!J{@L&VX}- zh@q9Q3_t&vd!W;><3=oQB~m&zF@?zY+ygy5OP2I6d$sA(%MZKqTC+aZz2e=r2OhQ) z)exN&NR0|qlCWM0A4Gy~jEW2-s^<#DP-sO1f&l}j5KJPB<3g`MN5m|cM25p6b9V@ zO;s=^;{@}|Mf2oy%!yM1hfav-r<=NG=)=!En0@8e9Y<&us_atR)h zUn+$NI+Ca5L_F2crcB0z71OY0og27p5EvoHHuLYlntkn=me!*VZ)u_LiLG4M+rFp0 zP%I;wL|P+fBCBDgv5Omqg(o&|?W7+p?p(XAdqbOz3;}3I3;mPwU}88>;cgVDQWJ~O zuS5+pgJ~5#O7(`2BteV>q$CK+W1EfUX)qU43bJMtdkzl${-H5r#{BAEZ=G~RGeFtP z7^}UpYR&EUKS2y4k7Zl2Sd47KQMt@%M9+1DQrSqw&V2B?H?Ddpb6~)-aaM{biYgR? zArF{EY-CJS3)s;j7Q--j6BN{k@>KGJhD-}>#fBIQVcId8r)se@q9UMxxa4W`li4$V zcxkMvW^ia^UH@iGmTZ&aO&mA&*LU9nB1jb4yLvwQZ1eifyI@iad4`aKg6fYu5++@J z*VEM#$IQ9yqLnxO+Vikt|PF_I2-jZ^IGi&*SbdD+_bg$`Bst zUO)-Ph*;QH6haxG015XZ!f5;uSA^UlKLnC#A$zE!<{LbDd-7#4 z3L61td{g6v7o3|Zmfm{#4alagijbrKu2Z#2x@89+1{*BvrBTosiK^xZ8pIv_7 zt+iVo{?l2@{-gHz){a%5P5;q%t*QhvDW2lM#Hei22z%s9WQ%&0?CRJ;Q-B$n4v}b0 z<+%7}XDAcrLtK~(lMyitBn{{Qh-n)KJKBG7|D$4U9^cKX#JrPF*tlsM?tdm7*@9^Y zdk2=U{M*IfI`4zEpH`<*N4FliyS=NBF9fFLWs19=e5LgV7wvm?`AEmXnxmUXc6S8v zm|Ybw4HrNH#(`_GkR7ro4e&#Dr9bRpU5xbi*KY)qc#4$)Ubz2pvY1_}0UQ;=5K923 zQr>_R9#UfAS5-+wHPIcj4BR?G=%%p^H{EexvE)4Ri=XBSg`ttm_ip-^Y$kW?hh2v1#a03?CQXbdDsY`21Gv}*({e=HkaSkeqhI! z`$sZa_+(AGdi=O?fZW#Bao+cCzF^^L_x$9akkMzhwC%j>348pQV8WP@?R(?RjoFc$ zGm;}?B9kyiSBZE)MZ}^dD9&gRP)Vr-f&&!C(oK`@1<(NgrewxbdT1gfPLh@n_vk!f z_?(U8qiW2AqgsvJAc8oA8e3nRuC9X8v0u%cHU&{6l}z>x4kzNV#d%kgiwOq~_O|WY52M}t(;GG~-kmR%GxX_E3NHK4H(qh|Md#mk{}X>)_HN*6ZMQw<=Sn6n>qohUT=+1Ez)4U- z6{7;}O24ow3?rdtL#!hec%O;WHnc)1D3!p$EIj4=x&h{-$BG3*`-6dYt#J|$TDl)I zv{eVpFOSeCCb8(KR?Fu#EtqD8ia&lR0}i~UvICz4MI7uQA07`s`8-Vbi(9Yh>>FOS zZj+rWVNpO|NM4{N8VgVOk#ZzCf?p}4E6OO!^q|G*<22RKAX)U?w3j>gh%vj+tD`n zq?zY`@8%!e@xY?9PycAmI*0^=B0O!~wU;`@KXMG#DG3({)Qx^N$q$&(ElYT_0x?F# z=~TS?FHsR<;IP0u8Lle`PP=i5py?qDq7x|x)+-efOZuCZ`7I}#V_P-Xfhi$!wygi; zvsCKS5B>G8mJ0b;sv64)d}F`HZPYU_ypC5%;4fjIZ6P+X;e!kQunTHGJ~bZv7N*-3wm(W)1gq zq(^V*?m9@{4!q?n-~Yoik8Llf?(9r$S@7d8*8Ev}>lrPcvZ~XFD9UI+1{1;YxZb$C zitnLLi&0u`xwz7VC_K4|K%~S1i_z?=!3u62EZghpG{?2zGLWm!EmJ(0n+Xmzry(DQ z_DBYw8ozIsv--F8{F_8KG%(DKOM%$r)3u}L`MtaSj*UTodypH}yfR}_Ds7Byez<(> zbGs&FT(emAs;f->yz7c_+xg%xSb>m4d z7^Z#d|HKmIjVI-2bjMx1F>abUaXK_0+mBp5W7`FnxbrFURvZDC%5dEH!Ylo@jX_U` zT2WwK844j9q$vqkJ4MoMinc*N^r^n!L$fK~rU}Qb;z%N%iW!v>`IX`HhcD-JMi~BMoC-+TX2L_(1 z&}34_bz&i$Q~MP^0lR@|J728w`nwGiR|YVexCVtm+*eX@6XFhpm4ZmyvtP@NNG`-E zfP|HsUAmG@&JDd7C>{a*V%y_5�LumoiQ2`TR9PDu(9^Z_b-1NUxkNmLj!xV8 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 literal 0 HcmV?d00001 diff --git a/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..e26116035d97933d1bff3dd5a7aaf48a17ee31b0 GIT binary patch literal 14463 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 RJi`D0002ovPDHLkV1jn}j|Tt% literal 0 HcmV?d00001 diff --git a/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..ea13a1850e842165bf0e8caadb3d5541a10480f9 GIT binary patch literal 28741 zcmV)VK(D`vP)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91ke~wq1ONa40RR91kN^Mx06#wphyVaU07*naRCod0y@!KkRh9OAQ|0QM z12hN?P0or4C?cW)Vnjg{6j2PQfFlYciq4o29WWq@jFF%WqKtrKM9H}uI?*}Nxx4bM zo4()ktbJ}3^UnAF0q?F`=j^@q%4?^y&$;L3YqeS~pUX9J`8+35BNt_5^9@z_PZbSi z1IGojKpKsJH_Sl1>V}(@*cFT;I#FjF5{?kEG$I-07DIF z6}ZA0g+^ZJ(m68b9)f_U1c90A(6qXl5mN9R@faX;O2?#{^b94#UDC~r%|eHfWH&Z> z$cSW~IiylPSI85v5iT;r98<=oHp3+Dql{0&LYHKs5%R*4s`A{Ze26xGLo)`M&?txK zBu9)ATJscDaA+FeBx&OQ4>}Ow)2t2Y!Dj^66!8C_`Ja4}xrsn(khS?fzq%TbMM{_d zA=T=MOH-#NL{PO4lR#ET(UL0RoGhYEVl~$0CnV8817aEw0E>bCbreYkihR&DTSK(z z5up$TZPS>n7(7)9h-JZT#O7p-odu53LT7l67NIKFhV)P=5CTzV*?gN}Ds^@eZMi9ve$Rr%h=v5{}P4LVF!O$#|+^7`9 zzZiO|Lpq6LSIB zBLtbSW@|zK9}CFpj2=ct?I;{93~idoBknLCA%wVe)J!T7O(xF13dfMd@PBnKtVVsv zT2#^mQ$kb#!wIevOVN#@4L-sQJ%eU6L*pm)LMNP_SlbBl3PaYL?MX?<(86AEFcp~; z7ptQ=Owx78qNPnAjZr*l6TN+dy#oWi zeFOaiL*+`PTB}#9fZbO9mvD}Rc$5!hy_-W))O67lLNRF|)i{MR0!LLwGWr~-nJ6g2 z;U*Y4jHYPj3xz^Usn}8~b+ol~cC?S_YVYps7}MFFSuLg`;X|9yqN$*Y{98*7im2n7 z1Q`CRe1;6;G>0|Gno5O?W-&4uEA=RmSg0`nXPh=|-9CTOif3P3`r^_RtJZDYytSu~ zSPc&os(Kw;lXWhkQR3?Tc0SrtErjuz6Poq~bBVctdc&Knj~%}k!WQk(frM?>FH+A6u_V?B6H&JA`C@4x)saBLDJ_~x{7nSHLoG8RO)Q`qF znmX~xGz^x~CbW38*BjZUyG>Hz(-~)x*N|1*BK_aag zM;J^>#o~T@%>K|x$Grb-M@<;pEiQN%P7YRLnes6tX4-*@5g|7jD>6YnB&t5VV;F~2 zofLH&2!d3F;4m_u-@JAEkAD59U)^%&rp?>iT3d>xqSdz||AQT}E#3_y(OV5Gki16T z3=9jkRFm{3`+vGsBw?%Y)DVI$>6jRbPE~;^94ZpqD5*S>Q#SSBCZcIIR8S}HM<%%o zpz6(2J2fPx>sqzC_ii&kbH+Q)c*oIF+0BXYiklGwOu*QhdKGAt zMM;CS2+Z^|0(>Ol*1P`x^&k9l@$xn8ZEeM3c9CGu;2jAPR`4;2%hEaMLyM$~QH7w) z=ma_`QRoz&*D3f1!mGI21;%W00~wW?Ls^2Gg-DacysS_mMsK;DtYJPi6%oGasFwn= zN`#i8niQLSPGPIn-gx+a-#q{HgZ7@2YBIi#7IaW(jFb>@a?U&{n6VBKFly}rRXbx8 zA4FNt#CqIsv9|(Ty-MLNNrGP zW**drPs6wo>Wl=!WQqCN6CqT%vIt6!Qb|W+lXIenW>#dNku#$8fh-ZZ3s4$q2csDV zl7)aUG+bd|{Mskq_p$f98I}qsAM21bh`j33ziOK}R>OOV%E7f64M}hcNMxON4GOQ{ zxaHh0efRE%pXzLHivdE_vcx7ei8BqXb4F};hkzy`8R^DGv6YoRL7UWt76}$1d2*2J z&6B#MomzU5CBh~*k*2@VzLCOWYNwDx1_g;0OaPKaL?N(6A^#C&sBh0nJsNm^32D1QKP55zvv^Z2VLQ9ZlunVP`fxN~6JY`GY6E;p(q^ zl;)oK01Qh{E|G%qZ)Wn970s1G$4r?FDTpblBI9LU*KXK+#%C|*0!zYx6p~@!VTg?X z-;?SsC8<7QY6y8XRTdsYrE5~#<+K%`wEnb7PUl11Dk3(cen zprBU9m_{0n8a2X+Fu6 zYBQuPwis~nx378VvF8vb&82~;n|LF1A_1zS#VK!2*)aip63msN3JxwSFMWNK zLlaaj&sHS&b3y&%KivJB+aF3!2n(%2*z0iE&xxPu)7F&os0mL?w}YGy(Q@u@Zn^7| zmwcZqEjZiHK@-PNCQ+nFjuI0lL6+FzjtnW$H*10icyfl4NJeT^q9&t`7AB{7E5RA# zgC}XsDDt^MTy+$=nvXUrK4xp3j7pI>C9nf#B&dBhfeuRWOhB(H18CLBX39w}3R-+{ z;TYX0$gB0**v^i>{`lep_T0s4hRve4mDW(?pl9MnE}WYlvY7A*S(WnnRcklC`Sj0k z+On-wDuryZ2wqlD`HW`(R2GhqlmbD56Q20G#wWGAD2hN1p%k>v1fk7RxmIh`svs8I z+e+icwoIJRI%!g2;>6;(vAK?pLTf7ODKF+jo?=Y#H9XZFpyIb!b>u z*X+d#-xEiV=9-oCB7?@^jI zt<*K9(9)VO=28gYCC&9b2~kiUC91=@{=WK_&BM!=_s(C~vvA44#w`qrg_ah=7n(uh zh=^3#NYF-Q;YPGu7?W2fC$yU~XG;7E)pFiW$nwop(q7CK=J1YiL z@B=QSY$|fJU{a~Fx!O8&dgq~sw;%C_*4^ipy1OO8(@-H-C-1hKA_PYuX}i*w!m2Ok z3njMLbyumebLYUKh1(wd$L1%VFZc8p+teu;LgfwdD07pIl?tx9mO}4(Xu|uO~r&2oFm8a zr@#GE|KLzZo4QvdV~j4CqsS~q4VvVzDO$>_MwyhHO~`;;ts<0K)nO`$Ft8U>spK2= zwmtXge)HSgk2tz8eggY+S~8UNaD$lR0#>`h42zkjk}NwA49^(4bIrYpN(h}{aMkKGpLs1~g~bX#DQg2u{Ndgc)+ zDGlM0Tpw-$$R6>+6>D#~_wkRv`#4FEx?xo^$*^Qnb8AZSmCVMfC{J3tV(qc-KYw6o zNOD4!c?P-#1Yw98Ax+2v%a(*Bg5aW)8k5ztOUWmjvV^`ksH* zsx|I$iA>fDJ-JPKI;(|D*d!1s2}nlz1k5Q2BuO+1yqaSq5h{SFSFY3!-S;*3{N&4R zY=0Ot;n*R{3s?VML1Zxxe_0!9lg!)x_V=wjc6N2Ni#2~JXf4FWDM|n%lg=t&voI36 zPW}g&fut?XlcE|RCk=!NZFsoQ**@l7@9TQ&`wHD-8`Vax%;+7M>QZFg7bU1bhfC7v zd)8e&6_$8cn0!X1Jm>;ZO@FIa>(yLw*6iIb{_M2lk6wE1?>8@6QDQjJHLCraTww*t zLuG}7t4*LREu?wo2eB81y(L9L1=Cv{vonuIgPR=BFIoA-f|rjubRYOjGb`APf|&^# zj|e3B$xtF)!Lme(2LJd+b*q&gBV}*5@`4zAz&zhdO9bM-Ou|&RT>0Wg5`sE-M27 z8T33d@Z+{3jh~UXFX04V)SZJJILEiCHQCnQfu`uLzs05rWp$}x=;;)F7ALjCGcE!W<$ z*XPffu-9(S|HrQ@*4l2d0qmO6J!(mt?Re_GV&+VhbG3010!AJH_Bs>%-;hw3Cm2?YAy!AK%UWLQ%xO z9WCv{)8Tj9)-=&7f+CF6B&@7W7StiI61D@*!pt=o#$I0Y%8GTHRVGD6bTb*H23WH)`y2p9|wzcmewHEEL4^z(V>78QC%3`qzB6+N$G&gH^RhCJ^h0VmaR$X z4y>6ZF$%Ru0uWM&M)PBapwYkd@+uw-3IWh%!Av}ZP#!X66Y8RgB2tprY?KCpYEx~O zt>DIYAvfl%3p$Q`dt=bu3NVT}TIxc=Pi|Ev;wR1)q$3`LIY)*)eVFAqx^F2Y7L%BW z%6%?lo3E+Jk$x7(+;AmVnl{C{OBH9Ja$daXlhKqo8%~{*%0UphCW)}5N2)eYH%145 z7F(;mxNL3Ef*YOD8DJ43trFAJqci^sEN8F_z5V^%Owo!I5cWlt_@zF@8QE%S&9i8Y zAT(#=5cyC&Mha`KmM9WZrRxKqYdid{`Jrf=iLtT7kW5Bx3c>UKFEqOY-@T!N=ZCU zv=(9oKX{^9i_9r+*)Y6v(a`cmwRNj1TQ=7FdYOe7?6hmq(o*Q|ZkslvZO-l;`|jVh z_r7d7LS3&|ka>pHT8t1;rU*J3TOy^x;;XM+fA`8@ zO^pTO-)&4(WRq2{uz{>y^J_P5;o@Ewe?kayV~U@Wuft60gSbkvt2b}mF)%m;u@K{^ zvzwz-Gp6dYByBT2fLSpSm~|K@)GSs$HyXo3r2}8v{`Pa~Lv^hm5blrE2nkKK&0e|{ zijB5ht!G>R6A$z~e0O=#OSK(4Sg=SG3wAZJWI{;puRWWq*K#c_EmJ3rIpTGbPkvX| zAx9KS#oTb65tD`h-H0r>v=*2D_NJBB-`LVQCi7ad(YnaT(h?f~Nv`bTPe7Mshl{R| zEdnHiz-YQJby!ugH@3Z}kM(^gd$s~tioB|vAo;vD)%dfMA_E}U=6JH2r>c1-*#sh2 z3Pwqun3{2*L>3g86kda=OJ*L(<^ZaA)T{aN6FT4bxm>Q5AzU;Lll9YqrO_%0yppx3fB0Zw_o?CX_ z__NB|wfWZeLR$wBj7?i%$dgJLv27Ix=E^!FPNT?_)n%`cKNr|Rk`{kT4+yG(La36D_ju)1R(H8Uji#Ul^Lpdm{PPdStOBd z_Bx7y^x?8L|;G6jaI%Fd13ulv@(Lx0N`T8f?BY`mkX zcKftr7{efCTcJ}*WYAOlUy2#i6vtwrRO0IxoB#CZzUQ9b(&X30u73(_VWk`!y!nAnk!aLbnpflT)*9d>NXYYxf9V3?|@45tKvZlo02 ziseO5Z~5MZwXK^Ao!t?t)T^RzIa)FoA|DZ!ykcc+H=T_2xpsCGKcYpkRw#578`aAC ztFGQ~?ax>`7TAg6UMSJ>)Rj!bnF@AX0mzih*YdK5%FJdruuwt1wy~j?5RGh9s~WAr zt73)79%dNeYD_pPHjSVl64I+T2@_GO4bK`4$`KkM)|jJ|u^?k*TmvY0)T#NX3ywsK zJkJ^&{oWkk5YxP$v=N3@)$+S(a*dYa(5i*ouOM1m3T^H3vO1QQg33gcWSBVkkTmg0 z1|v#(W(am0YnMWyPDd*+iz2U4)Fbc3)|Nu4wZKSNV&bM&WHN$gk2raD#0zL@5pmQ- zL9zjSjh2)wIjLhb4FU|xk)qiLxf~@mnZcce(PY;YWUd|(jVcuM6BX2wm@$)Vv2e00 zQ!(S2ok0@^vx`HJ$WBNyCRA0+#kmKTc0Hg`@d^!JxS@)Xu{6ums*A;1@75hxU0mC| zsnFh`iz7L1WYUsDH6$XF>nh)-i54$vK1rh8&N4#UNCa2W=l~RM%${s5D>?ueB$>BL z!B#`AmXItiYXxqawRi*;xov|$%`nqfP>Zv$0EZSy3CYV8BCqjNLP z2QTX*8#wkIFk=GW)UGb zOEsE$?{B|bUACB!ke1hA`6MrEt>KUR8FV5BlGB|?J=8_AI~?{Xio22tb>@mflswU4 zT-izYjd_tkhNVm=<7Z8pckZrUOZE^2R9#rQ$OF>`~uW`tBq*rn>e5s%QgRvw=Bf#VPP z$fNSu|Lz90i@32+06I&eZWJKO}XA_@kCW%#x8Wd`nc7 zqU3*B6b3ZwGPO~*p4;oS3`op5W?&P73?!Cg(6knI-1A?Rm!8daj?0zH2kx`yqfb5e zz{8Ifi?lFJ+j6ij(Ih_XWi(?Qv;W*VH#HiAgXJs$h|xs=|NQA%0$N?O`3g(a;$$|V zDawRTH7jzbV)et%J$%W}kNf_o+GkFF{Usl~>+?ThR^t&KOGkaBrW#Q;*HBCj%xYmP zT=_+!R7$PM7=r4Kk5VA&nDUVk9*d6TfB7kR39*?hIHbc#!6StZ;+u6sgh3oiP#q$r zk#ivWER=e#y|XxLUmos?{-0=UMeyc2q^(jrsjH{>zEjw9VxciquHJUX1JD{-hgf|G zU!3`%l9mia<%1`^SxuE?p60zqPyfKi&D+^j-MnpQwO(&+Z7pi+UQDu3DGNl-QM*wV zW~H{);l3f3jw|kYa?B4WAO7XjCm%WQpfiqt>X&!6uyrKu(+&()$s_WO{;iGKI9}pt z*9t^sxG?fbo@-R6wwfVOGCX{qQPE%n1xCrwhBOc8k>S4T9B?8?#$6?=mElQcs*ozN zAsQJtF+X`$Y1|C14+RG$Q_vy>7_R3IZLeH=;YYYxP8;`DM)W3QOCNWTXeDgcDl~Q+Lg` zc7~cTl5oi|A8$m!==cf4xymqGEoC2vhRW=d@wF=U*@lMrP}Gs#PJNUaE)NrzD!bKu zKb2>P^d(ha(;&E|wKQwS)KgA4`q%$)*@HJ-b@H2zC=U)WvZ>3e6H00%6eNRJChWU= z*K1}U@xAk=AHFY39EvnJk@CErE%AR!32Ia<`78jFU0lDnKzW;VKIz z7xKADvtdO%rM|)P5%gxwp{tlXe603A#8KRXe;g&rtV|TStPF^OY6Qa*f&!wrL~I%d z@3+^j*M95bPkgX4TsBZN=4DB0hmGoB`N>Ouwr$nAwn<|T``Sm_Cyrr&-L`Pms@orB z4xIUxgS)4XkGC%%3|ll)*!vVl3HSUTt7deHDKjAAqFVoFXo;hNsw1Hhxq5?#z`k?IXFmUtcUOjnC~I1!8CjXyP?-;+ zH$C;z)0h5isCTequbKOvbBZ45FBO;G`oKU>U)%JFGhe?y-7c{e<7Bg>AWvzMRUv~P!HG$)GKmIRw$Tc ztf0~-x)8HZ=R`W1{6lxp^Gf>IUDBFsv)#HH6WVzMKCSJt6VsX2dI+>__OSP{c-|=C z>db;r?{NtS$O2^3L3AywEfUXuGT~bnpSQ>C>D4NJcekXwmCG|uIO33hJFjEvSk{yq z{{Gy;U;Q@*?t1Dm-MdVMeEX6$+h1JC9h&J!?i<$HVzo;Na&oj>3||N+U3_X{nFL=2 zK(2$th0JMUnX2t$bKszB%m64TiD7~`#zr(VMMnsk zty{`j}qWjqA&w)$62Up ztUxMCfVzq?Q>{+o!my&8p^gJNsRxJ%N;egBXt1$VWKFDR0f~`@J;*fp>A652iDAwE zmp|O}m%DzMZ|jsf@`Oos7T_JnAN|8`U&`jUN<=dZ~qKxrJC(D?G(} z`Ipas?mHpy`(1CEaM<4Y-`)kr<`P43ImVOaQCAb9=deI9Ju)s|ZP% zOsa3b{oc=>d**)o?8QT}-~HiFKY7+^?|;|Z$ZIrYe}oqXl}Zmj^3=C~c+K06KboGz zfH`yKbUp_jxZg={KOScE$v@w5|E1si>AKB5+%zzE31GC`bkCz}Z<@FJ`KL{M%Mq(@ zytmTVx8<3aW}f=S?%7j|ZKZ0tGPr36i&r+N+QxV9T=@$3u%6&ipO12PW&_}0ciF)UK;cRWqItWu+Fc_?e4Qv#Dg&;`NIXYbun8~t6j#elG ztE40u`j$5wf=2C=I*lu)CXVb~`38q1%K&R?MzjhBKgK$U=tr#F4>@q&xN&2*Y}vYb z%T^dpo-}dNqzQ)}FpsW76uUax*R0*}=`VhrU65U7&)R+0S^Lf1?bxGVch>3eI^>}J zPCfggmFqW^+S)@&9L@s-n;%~=yac2ukCQ2^g?tAp zd(#`=aO2Ovci_HrZ~W=Mz3bx_Z``)C)anJ7sKt zphQF*Y}J!qT5ag|?ZgS=X3Utu zR~!l*ZH2B*zPa?^Kc71N6JOfCedm#fANc#LFCW+0!S%KcMbXCiqYf^1kJ<9V621*h z3nlOUfzVVsxz=DHcA|uqLc~mN3e_`ssI`@AqO|ZgG#y(uxf6oSF05D z5w#Y@NDsBE;|a!)*gakh`Oqxl1ED@*VY`Fc4>#8|X|-@Ox}-0|dPy%^WhM3XMpswo zl!+4%!!EGyCWF$|`QW3^eC!Kf8yFZo{@B-Ef90heEiKykAQkc(|LdMLH{H46*MC*1 zy{-GOec0#dUAC@PuF$+%XH2ZOl!kf-%3F8J9N8{W%Gn&B#0mY~4L-*>O+`*F|>)Fhi~IZ}80WS~?@B zULV+@v7eaL947@SyA)%vtTqZ`r{UJ<#9|Irh_-JK=vb@&NDUd_1D%g&a!UHHj;hej z8D3mAeewkPRM&8NXopX+v-7sQ9yWuv44d8ekm%7m|Mi-uF%;$t{U z1c9#zC?NU|Her-RSctQ5he9ez0dY}M8<(9VD}+c@jM5DpN&uy?Dmhy(4%EoC zCZOmXwDp}E)glQJ1ql^<9SJv4WQLzOn{Tb=^azkE0>K4+rKOF#T_wGW7J=4{38l>U zl-PQJYf)duRLbJYUFg}frdvSp^PocLI=imF;kF55JHP*pfBER?@7&STf8jTN$hITv zjy$(W^W2N6@q|{R)UtE_%RNsn1YYcD8+Y(LdhO2n%WFf!rH*(>EOsUv!abNJbL9pq zQdP+I1xyALNO{oF9F66wrm?{kXt+*r>R(*^n&rYLbK%OL-#mT#v3@PTbHUT4Ik89P9N`sd(uZ&WXOv3}qN~Y6SoX z28KRx>RUH%+{8PG`-jRDbu3qyzy=2g`UZx6_UoI^I_=~slP6yOrSte|^Oe`$*wfoz zY;TVduYdl_dVdbLu*>D~C%wMV*)j0a%D(x_*=6zQYA;$vtCajV5@o#PB%-wh7X=DnLUSeU zwwUYL*y!6_7(0XhA~j$fF%yP-%;SMi>}Y?>WFBLc-xDA53V4p&C?QE>A{0FJRI8QC z!)nI-SNSqP$#YmlOxF+u%3BXdqEEb_)}`DZR#J~*0p`OC6myIUH_db&$p|lq1yquvbdi zO5+Ib6qhj(Gx1P`Oa<9q4ZCG>RuR1-ZeYrAWSNs$+7(i$x#%zkT-0*O%eCTWY#$^{ z^M;PEO6H}5+Yy&CL%fb=d1>5itr!WFQ#3KL@RPugY-!oCTcM+%^`p#T=TKEmi89Xp z2Q8FBd*Psh)-cd$7$H2ASlrN^tgQ~(ge=m9`1S-@{E61;pG=EzIB>tcZurSJ&i%)e z|MuhG-T&kQcEH$AmX0l@4cFba=@0i*xA&_5$@JtO$+v!%k80HMrv#nboRL*hjgqS( zYxIcI2$krdhjS!ynnL_&k+?J~QngPYXjLsPaipkETo2K7V$B^v)nd|ZN*akK^l{=(zian(q@Dun0=_#`)pnv{<2K0@-%Ch*XjuE{6QLqmQ`jw^#n_r%tC?s-sA4-Px$_=+|SWatKc( z#PB2Ui!j`q@^WfGrJg*aBY%d<31sxadl{6kuSiy;bWRR3I~f+6zxEoa<}LCpIm+H> zpvnTmS42eqPJyn_~TuzUJzoN_~*^a9@_@7iA)OCSqcPLNST)z`t&RF4n@dk^=j*NtmXdpi zaFq=r`X$hm)PdZGngC}!Dk^6_M!+oY%e1$(@{0gWc!3|B=yq59;twDG+&2z?`&mD@ z=C^3~nGc=PJ!TAY?F2a_JRn!fIXGEn>fj(cj&d4ssR>+|u{R_$6@nxw^Q&hARFq&yoCmCQHbcHA=Ma9_BYFgu{ zQuK63SSH(qo7?;m^eSvE)LTM%*)J!hAhpZL(bbHjuEJ$>K2=EhB% zx9zdp?5X3sHE7Br+Ade$Hq8b$yHx0`AQ23GG7ktjn^p{0QJx9sn3WL1OHp(Susvei zOma-T2}kXfyj@QQc9G1N7J*t?VI&|yGP0&_&*QYfB!4Ke!*_Kw&O|P%2zKq z=dk_fZrjn@+TPA%bFHl{(4vK~MniQK+5j`}`B93q7svyoI$g9kznQ}-t8k4Ri2 z*oY8;hy?Q~beO7;p;$3Du)VhA{^Fr$u(cr<;C*>q*g}V`ZY9)zyrkoR@wEfT4i9r# zEtrjv`Y8jZyQSjAr8jWC`vnj-bPGPB!fkv%y|wMZe?0!t&wl00pZ~85c7w)jA&>M5u%l@I79QB4Fpt*Om)ToKv+~lXr*8U>*TznK6#RQOg*` zJn7-)lePH-blYj_YSlE7cpMO46abT^(ArpdcfEg$@QDzCBxW2@nv7g7uk09VT=sm& z#$mp=qGlmzRN_71s!Ng7$C}CGA(a4?jY_>AvFDP1zv{~uoOi)zKFY1K8*jP&u6rJw zI(g!?KfHY2KJ&i$AJ;QBg3Y&t2M34v)hzDIaB-%gO2eSr5YnAwYB)g#g+&RL)w?3$ zgl9bNY3K`os^{6UkxBXRo|uGN!AU|DI1+A1N@NvTkxH<&z0Hi}=}{9{lB(_{ZH(i| zECn)_BznSv2subn%O4fR(o9%-v^07))?T_ZSK#Xfju?{huXcp>k)FKfELUH&J@=Jo zI(Jt1{xMt?dtE$5OT-)xj93vdQ}pysGv0=&SNg+2eXQQ#qWJNrU#L|oA35!vYu2uR z@7bR_Qua^~m1aLISrp1kyek3aDDM}Vs_30<=(gxJR*xoNY;L$x0|jV!hq@?MsQ zNFRLSV0hd!vO1z|mgZ!*y~essE{#iq=!I$!{)G47FV={X2b_m{3Sl-OSrOHdj1%W* z1q_V3=14&bogx@=oW5)9krRYw^U0P#DQ2=njgq69Y@p>^+G{WVwYqI(gDp)@hz^`4 zq^!eftE;`xcw}SYU!Lw@f#rp~wJu3()p<+gM57_23gPEMCs41f>iKY!T|I@)-oUSD_6^KSaiUtxG?V8!~a=U(u2MJv|;xwE5V z$25)C};seAMyB9dZ9-^V<;pxyZnUS6c=>d>L=Z6FvraMIi*T|IS{L-NaA^ns(h)Z+RNu0ZK#y;$+D2lH5Pm4zK2RN=GqY9d3KwdwC#d@DEoZ6T|)T2S1`JmjWXO*b}`?T>Z&} zYG4#j#6Xr?69!C8;X-Fb6gP*^nBrGx^z5uxhKsy>n)#i}2%%;v3gHV};8zz(tQ`lj zyclJ{+`~LK;=y`-=houb@%eF+cuxYxd*~M-Rc^nu7WX_c&@|=7h@x5s7+<_8sa^w= ztTR1tH0i2uTr&L6pSHf~LiZ4_Q8XC(YFqI)RU)Y8hj|35y?DSoOZ%Nrd*!9t(#LA6 z=GV8a&kgmnDmT;6BxFEMsERzG!9AVQ?77$U?DCgKO7+eXSIUEx#!!VfpYkmhk6K%6)kiSiN&P9z5jadLo@pG#EE)#+&O2u24_Yx+jH4>NdqyuTC9a~JO8 z@;)BU=X_U*(MbzD{s{+XiL@MP?coEfG`(T2ik<@`SMpjr>dPLkw2vtr`3XOaCa~BG z%}fAbYy=Mu2tXfvI`gp7F0U=t2J?NJ8oe8HeOv3j+ZyG5R*C8lrItc#N27CWe*Dzp z#F_cAlZ)+R8JV~6)q+~gC-%uX9@Y~QAR17=NnVShi5TIOc&BDI?d{7AttTP4C;{?QQ( z`OjET67bj_9qa>C@7td5+mY)Ui{IsEj(h>6uO~M&nB(Z}AwjnebhCLAqoUh3?*HL@%4kVV2_?Y4?{>W69lWq`1=)@0IHOS~Hvc@GBH=qmo z?wN)0yE3HUizG0t(awo2<9;b^LH9o^9-+)0-kDQmw5Q>GrP}t!GwPexRiAk<&+DwL zywPNZ<~Do}u<3dadR|ZvDiF{sOxBT}N6*q+08p41z!yWeH+p)sV)hrkLxnoOI&tg^Hd@r`B!NX&Jq&uwRF@L{;e^vz2)f7<~t`bvdMVh zif&j1hor5$8jNj^oGlLWBZByggh;xb0H|bARe>Y~pLC!mI=-N#mTF&5<%NIbS=^f@ zdI@+Kr;-G$yR#L0}%GyR>p9B*s4OGEUrb&A_$SLV) zOC>REm#qeY8_8z@Npzzvo0tz9Ep5EWi#Y>0-*&C{_Gpt1_rS;>4+7lE;+B7dSu{5| z(7;sfY0&`H?nW-`qZ!H=?DR)g@wT9nuB&*ujXj0duKKDchwr$ww(-RV>qf$Y6*5MS zC2wWXP;sQu=;fon(vbab8=6YVauf-zq*;_#04BI=#fX1Ex(4C8y1lY(-SF?eUVC}I z7Rkv*Aw{^7pYWW!k*&x!?Yt_{Dp;o3Ihe_}p&1WLt#o$!R-?Bk8DmAXN9D-5p-CbB zN0Q7;6?dj8-ED5Hks5 zryNb0)nLMp*$@LoRs*TtU#TGBaZM!<@_?JPq$t~@8GVujKfY_!*s;EH=Vir1Pb=-pYtvBto#z2%+9TRjR!BK<&S; zY3$gV@6bx0E1DP%P?IWCNKq3Q zM@5A$-~rB2kto(Cwc#B5b&QkrHad{vO0>iZ7l;GB08_;}yaLT38OhaS*J)!1-XD6ID%uI1nagL zMRf~p(=ZO#26j{*|6}E!e`;*sMCXos6a>Qx2-#DI!hof5RM#G0dHrI=X}8im3reZ-R8Nj69Fjh+pa1^3mSzO%7;4I7*K+NU;m zm`$~=%VMKWC>zBp`curoaP#yG&z;-2yvcWt$&VXP>(uuI`*zkhtYIOhh{`8wJ~0e6 z2K(~uT^gCNm!V#lC84x}nsN#ja$NyP98}{WkvxS_h|u>sTv^tSgP33 zUD#IVp6B8R^3(Pzyylp~E{7F(K}*R$8c5`{$05fmcpPD)oHT+D1iR8=!liZ@SsL1-f~8Br!}Jx-r!=tQ!9h;h&Yqum`?5qdizz>$i&RP zCXP9YslqF+IwSFuVP*m6*7H(_gRSXf*8LanVUKWR>3#{Pw=^YRmBtCwBvb52&S;GeLhKj&2MrnA|UX=1aT1ixkmM-}k zNEJ?NOcyK<<>*_LvOIt(8qaY^MVCEb3(Ng7@@hAEE?6~q5;NWa$$|w4F2YQ>VXiL1 z>}|WmEG$b+Pbph;L>R#gcshcgua^fj^rZ?~Di1XE#c0eh$k@$3kkILNv`v;6UE`&? zL}@kHAe`M3s8_gEQEjl`92#&QG_S0f;c(W7rqYhCjVx%q9F!)Sw{b{}leAEnZ&*}Y zJ71l!)Sl}YSLmLou(ftKN^O3tC_fHX){s89BiFyZ(Z5}-I9eNV;-j$oqS$d#e+vI1 zK0fFaDf|Ucmo8F9t#F{Pv2&{+Y9iJYwW;D0n$r?l4h@$XfwXN)qzMdN3Pb2;jL7V+ z4X~p2B}mVf#+ZDry9*&o(TbgOa1uBRSX2YVMXE~G!lwZecGNOh)vaC9fIy;|HrG9Y zp$Cxi0A@E@`5umVY0biw0A6ch%!D*Tz#c2l&C{#^SP%_DmwK%-DJ+)d=4|B zB}Zs5Qj^q@hoW{6@H-PNjkKR@kIl@fYEmK=C*y#oW-bRYFc4{^ke96JF3u;MupF(V zm^>eBI3fbI%Y$+SO_PAB*)b9j1_q%-A$0vA)JJ#>^=is&Zxu)M;V4dTe}n;;TaG$meRt6|eX{MFBb zzM3Ou?5biORcxX&!y{G!)4VD+nt$WU7Vq;Gh(TJS){}fAkD-i#0dHyK0Zq$DX|CvM zrHb!$=_6-p8q`PcH^&C8FlEf>Bq~`X^7J8K@ln8N11X|HaR?P%(ZI;OyeGh*%EDNe zgWS~9*Pkr}ESck0k_1_tVB*Gtf|-7(Pwk$-u!jyIs$wI@p?)bWr^p;F?h5VFl@#J_ zPqP3xzHWqenw5=b4V80Z-+?pp>WCK#`QiQsGi6L;kfLym01lzL4#PANrCC*mv;<;d z$(A{@I=v8^X;x~1p(T__q86VF{WRW|R-7SgBU+r%nE9;PF7aZm=eD>J8>6g}{+QQq?7$ELs3W z@DpBU^IuKrx(ZjjkEt*r*)`K&RV36Oum!6R%!8rjEka%P;K_buDnu`%~ zdAp~+MlMj`C{%b)lL;^yU5&wzX3kI4sMVKqT*>fNMyBFW7tLu(m^#eY;;OkZQ$+>8 z;2%O|gJPi#u;D>n-i1#IgC*MxoF^|xtj0{@WK}uQX!ONE^*iaT74)biztXU3@%t=X z{EI)crm*q2BW`q4DJtlpm53BlQ(#b$?I?qP4U-y9!P`0sBY8G$7w{_-EHSuREa;vF z?|vi$D~W9|2x;Ky+m=Ys8Iw~>%9MwtOhJ~2I4~qq4hUQ*xfn;r$+u79<&uMU?#^{NN8 zA55hM4G%a}xQ1){R=&;*ibggp_mjGW24$p$Z1&Z3by&ND%r{95i>I}cz=2#dzm_E- zE^MHZI}PH+k7}NHTKh?#%y)Me*e`3(bysSG_ubfg+pm)ClI3aGt>3aWa>)D8hi;(C z;Cj~^-`aZ6QN4e-wz^@R<^_o)S|S%FZ3~a%BCS7loeC8zrOYJN=33!XUsG-yFS3eBn2PzuhwVJp81=fe4A)} z_v&9BaMVfr9`(*IUw$P|_x<*&uN}1CT)vc`FFW@4wik1g#&p8HHZZ^-&aADujaLQ? z^mXTlyITsi;c~6a%kXLw$8;8m8{bG*m(py(Xa|=G9W2#(a;jH7Rf`b5T06`>!h|uM zV>>%^RnE!wOm#%Y%Kt`1-cXPup?z7c1+Qb)I}? z`%x!&>D2I6P9@Jz0n@N(^>P-?T!P_~Z_(}y9Zuius|@BR&ul;Br~=3|I4-2P$SV(O zF)W3>l4SMB+N6|k4E1VNNh*}8ys4$kE|l3uXcaAl;72n0sE2qRkl;GuGnAng<|;KG z5peTSHMNS)|42d>rP%uD-0o=}gv;GrjzI(V@zxKx4 z|91aB|Kl4M?ziWhr7PEc?MJ`6_kl;wI_>SBJ^Q^s{q?OU9e>m@hws06>(1+MzWwUo z{E7Ge6Nc9vu;*7lclMEo9>6zW?|SI5%dh_Js^u#WKX|WezH{jx{(Scyb7r4>!qM+M z>+{bnTGjE^v+&f8>;AQ};7L|AL#vk5Hm@7|vCB%+cEubzZpYhBYdideeAoEeE6d6c z{<-|ZV}-F}$DecQ@T!H3>+MIKSRd-IJaJ#|-)`x8@5fpXdrNMhG3M-xd2FliuQ!i5 z<5T6Ao@j0FZaeyf9oKwo=*6eUp7g=)W8aqR8kgU=a>rk8=zHwJLSZ6b*yj00U(Cd& zuiY5_bVSrU`KPIchgIbPoni_IB--g1o!q-4Fc~<}XZ$lhs^(1yZ+!h>ApGu@JDJ1v z`%R>=W3Kqw@2~vXZ+O6@d)$~mTzBQ0j(+Vw9)0q$C!T)$3CG>`yPur)i7(&t(4zw#CN`Q?iW9P+Okz^o_u!xJ5D(E*WbBdzqdX=ZSsV}_uuR4 z%RaMs`Re-~dhCQZ9eL$tpKB>JzI)ZL4}0ywcmDPVtTldn^KIkCjXUeKQx2N9$8o29 zX3UuG!w=er7fSJP{K{2pd2}d0esX^L+{(%&)uk`!vVqaFy|cP@`L=)ixW=w}t^3r^ zj5+bN!NrTp3!m*e_~^EC58d(Of2posT9`3!%-qAO8`qW>zu11j>$~3n$;#T7>)Uu> zgzr=5iUUJc?h951i?jFdo^xOeU2N0ZO8<_@A2@H!`_3L(ytw+(^WBFYHT7ShE{rlhUXa1kRa4E0bIQ7J1mMnQ0 zk_{U-pZKBkm#yBwz+2pPZlTmRyloSmQnyK^Bs%K0A>nM9vsc^E@9BH`(Vm}N)EFEZ zns?B+i>~QB@xwc>`x={SmF?TM|M2s*4XcOWcuM}fZxrVq(sSK6ixa0bPWs5s8-6hK z($l4xvrF8CuasZ;!THtYi%K(h?Re`Mea}9%<=@ZaYt=jU-gm|~e>MKpj}BgWp)T&V zh#^{fj5F&L@^Uy$@sR&UB_sK|1-B-w7!n;V^+3lyRgI!-W{r}S7~b_o4_3c+PzjOOrMi$ zD-A#MKz(3{IjpvHL2bpN;`BX=V<+$exZyQRYMa*c%W}gj7FPRfd3LUORJoC76Pq^? zu;O6%gvn}n>GRbkFR-*I?z;DITkF6R57hhn^X;9Lr3)*I=I3YcT^Ku2Py6E&IoUdd ztL(L`zqz(KAqfrby+R=$_~{`W%BoU*a8M?t#8@SvjWh$WEByn{KK~-`u{-XVH!Obf zca1JC`U>@-!F~4L?SKOgeCnxZ^dJH55n+m`8W&ucuFaq85*8Q*Kk%y+ouH}pp|5{d z09D#Xk`H=&&=~$?Z=;bj;&?(V)$hN{P)NsPcYNNqRgW#F;k$=wvCl-8wxY# z>WRvo+tta?ir+Ch?6};P^*p;^nRtXKN(65%&<0~JU#-vw?Ob&Yg3Sm@D;(sR2~}(3 zkrq|uWtw{L8$116-*8C)g{^jnf@PfzF=|0$A!V!OeXkS32g&E?E!DHoWKp!6&)N%{K}hu`F+0C z{n)b$A%FKtZ^_llIP2g8_dWFB{g*6TwQkE!mgwT)K4eXkBm7@it$OCAWd$}Qv7y=c zkkGTO^5h+b36r|s^MyiJ7hi_wGP3Q&vpYZa-Ih1JtG0GI`!>0~k5r2q9LUe!y*_*I z`YS7H?7`z-t?5X*a3*TL(XHDL{?Qn&?X+pf28o;-0nY%RR z>|a^GvbLSyj)jv4J-JX0gvN0QXX>brtHKxdkKN88g(bbZC`zuAsm+*~9_oxrjGPaS zyyfSiN1wgo>gzB5!pHx9$M65~=u^Cx?sZ2TG;zZC-`sfX-~M)g*O)PnKl$88&wSss zDHB$#SoOa5ytBKz>pTDPbG|Lax4~Puh|z@(ja7l+yC+tfZm--slKjEH z{A$6Ai%)+0@l&Qu`s%-5weyutSN`Psx4q@qJ8t~xb-%iSZHtee^#SgP9De+15o{SC zM_booOXZ$lx9omM$LmjO%simJVNvVUImOrPJ-ll5;9q~<*t~x5k=r|teP`iw*VH#H zZ#(R5JcQG8`!Co9<&BG6TX5}Shwxkqmyud8)V6J6kEi>?mkvF3*YKlv=sWh@z@vi} ziq%z11|R&d_7gua@yoxctbC#E&=U$>V|M)I7hE>v3tSY_?$~yK37sT54H}|sL@RWr zHYKkkr~<`kKIq!h^>NH_n`gjAFtRv1MMPELd7!U+ke7JVdKS@ zefN_WeuKp~zt#S}vp#?A^*0=P&;e(i`GM7|*PQKJkhKMVF^vk~9zB5mIXRTWK`geZxy&qlAJ%~r2 ze(BURKl{w{3(o!MhtB!%`yYSu*%RLP@x@D4GN(TO{DPIM*QQp7^0;Q8XYjfU`~Lh> zX0w*Pj;ytG4nB0tz>h!O*t~%~wDL{g9{BI;YLjNQ9QL-#=9L52ezEf0LwI&*#q;G2 zOT$0h#3BIIO)HsVtB>DVe&D}&@mtHBefS0Hp`}k%H@qBPA`<;KTrv2^pX9p77LPcY z*P!jV`l6vH?&k*S(6af1s}`rwpq2T`wRW!^t3u{sxz-wouO=Y0AryuwVaK+ze+O#%%P5zqgYiya-4CXC|(b4SlWxv!sVGL1{xyx`VMZTzGO zZEYP}wr*vum}}?eP^07_F`ms}c%;aLp;?*kreV5P=^wiIqECM3t6%u=CocTmO@Ey_ zb1EzKzMcVoCCo!*UN;X5xhYeopb4*#MY+6%@*9b?c0SfOYg^uJp}q4YFGCrlAAEY=5^D0m?VT#}(0A+PxeB&jAy#u)(o}Mf)ipB)ak71}*s%w%i19-{Fe7YAjY zOyyZYO?4KJ0sInl3y(~DkHb%{!G&5Ky}>P10U*$aKW%yNv$R05fXPS#gcIIiOOWsE zR{AiA>vWScgBc|lfhJT6$rv+N9TeV9O90C>X9*W_woj<&saEG;LLCdL$cMI;Y$S4{ zIk-pNFgF|HiF#T6fH$Ft$`{z3}GKtS)~=C=okUaYEpm%vD(2sxHiQE6BUhP z;^^feGD)M5kqFPa!QaL+s<9Dabn6+6g3y4hhq*Aq$P~QZSih3xOI=r)xwQ(_q>%_y zNkoZ{=|_rIi(;6W&>d~jx=T3#nR`qgXmNZMTY*FaH?o>&vug2YnZxgOGOeh94mDp`tvz`^Qc>_-D`Zl?Q#}yaO&iD|=D2b%g|F(5h1}jy*aHY#HUdV3qkT5Y%4+7=J=%+OIz9z7%`2WQk^ zx3{;yX1Cc3<}XU4xt%jwdg5#)7{|xFZr|Ma@ry6`kK&kdH8x}P_-sqP++P|qpYMJ9Hs<9Pfc5@Jx}aQcw@#ii>%7k|xZwO5r@Uv*Ip;0E`bw_I ztR0w;Drz!J6OyxnDdqVFHnRwu9hSiU~b zItzGv>%h8=^S^uj?2n$h>5j*x8xly!3#lqXNc$*FKjX}CM<3rP=65;2+Sbw8wdcH9 zAN+9p^jVwkzc=>P&Bc<QZQU~A)YHOmq7xuPPEF{Nv_S;JemF8Jz?XI*r5;g^5bGY*K6 zpT$rIX~AB_aQ6{Mm1gg;`l?UnJ31=cwyyj2byLp%bl=6FNvMGhLFy}%M>OZ|SKBVT zjX7>_R*9*ZHg%Q^8_Stws~H<={X?a(e4mcnX1cl;7H?M{KaTrwFbPz6XeF0)6^c`S z``TC5{O7d|#=}siTIra#|LpTFh+^g>;rqWyXz7WEs%Q$6Cr{dU-dw{*p{Z=^fO)UX z2pUhwh|tw4khmK%b?Ri@g-S%k9OBG7I{V)MwLX6Mhar84MqcDr5bxx#4D|3*7`aj> zq3rJ;tPl3}E?igXN!v693ib`PXp7^F zf;O`F=4GiFTHf8dDF(^UeZi!Do+1Sb~ezxI=LR?{!d3f@q{f<2B3pKa}7vOG` zdNL#`3Y@USXLz`u&Go#6n$l1it6YlpW?@+97iht(;<;vq(?!(3_!e$10BKL-+r8?>^nVp zxv?vBSQ9%jP#9g;dhcJ$ zUM!Zjy*=E}tYuE}0L+7CeWQdRtQhQ{@XgD*Ha}Nt{h+wdjINeP#+`Up-Q;PUvOr^L zuT6HrMEGc^xAg8cw@x_zh%pDvK*#H6PTS|Klis=ZwxP~$o;XN~Ifq2$A_s!FAK$)q zRnvkWHGXj}?)MkQG&CMNZ_F7N?tJJzU5BYh&UgmS1jCbST?Guk(8n}4%{%otlBmwH zP-wz*wjgQv7>PXsqgg?U_?>>raZ~r7`ri8=;R=+p`lqP5k(0uP!5us6=Nwp^w7KJf zmb&J>`?kJ^IfRA1nzrBn7+y{|@a{+bZ*6aydq}CZv+Jqn`E4N`kiyZ7y?3t}ZBpXz6Mi@32uQCCm&3hH5f3EY6rA=4-a(Kq9 zx=l}aZrV_P@G*lA{~jT?5Ovb&1aKD#P%IAZ*xB*KGkab3_2v>LP#4-azR>@EYnrq} zMo2(X%La)g@9=Ov(DLBGsQU5Wx}p!is1DEX+Sz*h(!njy^FDn((N+gSV1%=WWqhgs zgp-awXwHEgwxA<-%wovOR9TSQ-@|^{!Z!U6591;4{BzE{>E>V86fqODlF*D!Fn3m0 z?0xQ~?)96RzI-f>1~}D5-!RIc5zkoB5w6ccWniG~&imY5?jtsc7e+z>93G5)`1asO z?~FO;@}8A9VTCRf6sOFrJME&b)yr)bbwVow7C^28tbhyFGS*|2Yf%{L?x?GoswF+} zfGibbJOU&H>*2pb?XH$*$8?vsR-0ev)g*l9c5I;~wJSX&a_DjhlDLuH_30 zSVf5USIs`M@76_{P)#``u=+siDvSynaq=*6-?{^)4Ysvm>PNAC7hYbgE4*NoFhs-U z9D-`$P{4W|oKNoFJ*aa&uGZ}dpRRtwn10bjf2|rmORdHJnA3nii zl8nT0!or(R08_%#Sp1ahv+IUBKB$>6X}GhswDyj{&FdX4+Qfe)ET84f?KDI%2W#u* z`a@4_IQ;b5sk0jnJEeE^Erb7kkq%{&D76;2%9#4t@c_dQC2n~GgKqq>b9X=epIvJn z(+v(WAtMtqT3E(%|758^)ri?Ra}Fzaw3gp}4IN}VVwM!5mJ&z|W+)XE^R{kZe*Nsv z%)&9lY~fK?f;%KI2T0$du2QvaQGuCcYisfQ)<>WAjhA13ozHyD60G7#ViwQ-%$3EspRZWf!+`E5s6VQ;f>!{fkW5ATOM`G0$o`O4JTZKhx@(6&|J+}TU=Plr` zdvfUy5}73sOqp;FDABNWKQkT4v^)nbfSnUcQI5tiFysW0%Or?9{R8{&KV`$Z z`=?Ev%mXz=vvHIeEMoG2zLUV`I&yyqZQT%~Bsy~1$;VxDmeoX9Lgid3K}8e(-n}Er{dPoUa$v(HN(l z`tZn%vx68n04FdvT%gX1^bt9!ka|^7N{Kd*lkw&qZxnOJMx1G+NDSA}V{8 z(_=w3FcT6xlLIszxiKmdc@ssgmecnK>HMd^xoQy?%8U@E;$W7bL!xO((J?xL9^$-J zlyR8Fv?r{3!WXC3EGGw5!bA@$r4UFXGRBgM^L>F94I_o2BBhe}+(nw;RuF;TU%GkO z4;O#$V(H4-#GAB}o!HV~**GN1F5$^Ca=ATIK_$3~#2nD`F1UQ%+P@=a%4c=%0@ zP&uK1O9))vVfi=KlOSwiD55qi1+nSw?)mQGB`a2}!c9*c zk!Vl)S1D7TnGkT;*e~#873=I%vjU-ruDL29m?av9}xWcEqDC*r?>QA zk|?K#GEq$SS`rkGg473Y?Iz(;1VStO4MNANj^``vx#nkB4xv*PgG=y6kOg=Zg=ki zv!?&zrX^oJ<7CuyT9{)Oa&E+gC*7Th2*7opMr|P*YLcX#3C8G*VAVOgrsnC5|Gw(l zWgDJ+mX|wN3+&^eOt!)pJUo0yN2s9MP9F5)O*nWy2RX+;LylEah=a&63kvCQj+(dy zDuThts0(J}9!CkDl3>aA_BW}VQRNumfK2|SMdTAnS&ktX(JVET%KAXTRL3?!h-5U- z`mq|icK+Aq-LUlP8PliGs7!b0G6s#4WP(X#0!vdmOUx9JLtV)VR_#3N5~`GE!f(~w zb@!^}zxv&4Tek8H4YR{Cb>z1Vv_oXLR+DiS1CaAZZ$*lB`N3?qN7(N#=rs#)wa} zBx4lNGkYp*a#~GCN9Vn(*4%Z^>gQg31vl3C-Xh+Q2Y*s5oq-P-gcjdS+$X?;gk}^R z4-`ps6AFx6u(*sm&I!$9LGT(%RRREtW%Q0UDd6!C%k+2c;2R5laSb{-*k$mj=M{en zi!==#Y*q9Y{2F@VrY0roqSJ8udvIX%*hc(X?ShNuW1*DiLMa3Wvq&hEtORXm1S01; z^#yF&!z${ekK_oVtzpH3Nx-xzOVOS12==xO|NPgQzdZ8zKc0F0&36WSdTBjwD#BCt zFA6w1;hch&$>TOLs?;5vSs~+OqlOMetHKjf6cLYQX{0EmCf8UUUh&e~#aChBzJ|Og zdnllk{E$h33J*k8P{u(KXMG&=y1WBgu`qVR_|JcK)@dgljqj>29l}M!l;))c5S)@R zVVFW?Q}dy6XCc9X6ybEhTt|xvg2i!`Tm7?a`?3zGG1P?=!|J^aa`@r+SGR1%`mN_* z#FB>Mvorf)HHFg@pxs) zoC9ZKl_}N`&ziYEem~Pv)SOqXI%!Nmv?ArK=^&To?2ei&g<9p|5ZfeRAns(w)SyNs z%#DrOY>D{bzHD@;OTnNG_PnmHp6;F={NP<*UkS5`^+T`_lzo8a7qLnqP5@Mjm6Cx+!m}=|M8W={JAu*X%p8EwE^p)@)Uk>YOH7Ig6@F!`v9WP{(>P3P z=VG5+A~gFXArt~B$H4(d6U0L;EU>CXe}a(cps^a)VXkD{0vf>zc98?r;Q>m%Kt(wP ztVWV;A)jdfZ(APpvfj(N@)a{CM?#LMD%%aYIPrL9B67ReQX=}rDjZ|M!!`_!OeCYb z-9c4uEWt~foMC4e4ni{4Fgdd-S~683pTkJf)Xj39HMrS?1Z3LdigMVL9k+w5X2Oe; zU`ZDWre`!(-HIG3Ov#m1j2?M65(OD0?4k@LYZ4lrtdyB;XS$P_Dzfl-I= zF*9ONbB%@(fyIh_VT)AAd}h)c>>1J0ay8{y#lH+@H7kpbnr$G15^V4XH4529Y6T}P{8%Cn)g&mF zB}jHvZA0kd%-<1-U8y!MV~|SgMNW5fqh{F}eKQd`kQ__#K&1%ehpdf3nWyL6kl94l zMG-u!CL#9+TQH|`CT^qz7DCZgBUsROB#I1^6`(UPf-$&xGPA@$b)XvrMfgGCWTQA~ zn+xI!12l{RPyED@J%9?!GePqqcR`$nOgDciN;0U}LI-Q+4ia0{mU6I>HE7u6*})qi zNy;#!2QatzPwgs2Q{8+4RtKg3iA5pEA%4&J83RG$yLCG^G zme}T-@?|yWrwCNZeT;JtXjKBAq!Lj9wmga|y79?Vg`Xp|Vqt}zVQC2Ofu1o`6$q7Q z&X=ng_f#P>=%1t`X-5hC>R!Ztzb6$j0C^1iAAayi3pD`Us{jB107*qoM6N<$g1H0| AVE_OC literal 0 HcmV?d00001 diff --git a/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/Android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0aaf9f9c7da6d7121d4019363748b52097c986c0 GIT binary patch literal 47257 zcmV)LK)Jt(P)Px#L}ge>W=%~1DgXcg2mk?xX#fNO00031000^Q000001E2u_0{{R30RRC20H6W@ z1ONa40RR91z@P&F1ONa40RR91zyJUM08KkN$^ZaB07*naRCodGy@$V@Rh9pL`YpNX zBq0z=0t8U$Q4tk~5rGlwsH5MpG4`&54aX4%>)5bj8O9N?jb$u@*c}z=QbR8xB!Tqg z-kaQhPx*e{pSAaMP8@%~*Y6Mb?R)O??7j9{pH=pH_EXPu%B@zbR4$b|`Y)APlscVG zIclX2Z54VuG|K){!(A0icDxji{>%+e0)arkfdOd;27J*+O*lf=Us4 z&`LEE&0lc!b{6#nk`a(dpcyGB(NS)DutHauEyEQMOC{T(Bcw$_cSWS4ScRxU6U`7= zZ5mXENTVk*IH;#yDJy}Jj7g~rWoC;?3b#x{$X%|`%z+A6meEy2tPn}#oxp@rFdMQ5 zH>wy}op$>4C7xFMm$kiTPi@zU{Y}Yxse6FQF`>6J-Z0N z|1Jgg|LzinaSmhL9j4mozsOFHiY2nwZfBe{FenvMq1%=2!XV*GLQ`L=xuQiOYXT8S z0T*t$-D>X>h2Rde54^+Qg>~WXckaCA@0&(uz$8J+ZFETu!O?g zhXBJK?XEGv6u21^a24~WP7{JP^TT1X1Dp8^%j~k91tM$wJjp4 z1d8gk^sf)wLN2B-%>HDm*eqQzhS7oRK{BAJ0D@_xEFencS&sptL^rNkltm_a-9td! zZ0ZOejAC-4*)u3cf)<##5d4myh_ct#g&@mtu5dQ89E40lL&Aa98!*V$ge4XTlSKP7 zgfIgiqz|}UJXt1PGBSq+)I`!UZX%Oq9LeO;i}lo7d@)d(tmJdEujW- zbh<((+*!42QUQg#Ut0in?Rn!c#LXjhjQ4tsO zPHggdq z71qc_%Z8}v;F+RYRLl#%3V{9)qo`OO@n}kEG4TYl`&a}7C}8|qp*O|5+mkFQ^tuPV zDu94m@R*vAgT;9CC!X*q@;(s43;R@S#l5AC{`-E zWa;$;WV}!zkw8zznJvp|9i3f*+L?q7Ck#r-Ok`OELjt4^aWzwn3|8=104p#a3yT6v z{x6775;C+jPf1ERS++N;U?@}Z<)BW=%WM|R_0~#JCy$Q70Y)#M1PE1Zs zPESux&$OD#?RJw-v(wHyqu{k5LGz{-K(fgPbm%KeZ=hqVpx5zoGi@mmqYwZcb)dh|*JuoCS+AEX zNj^*o%c+};5|L1)6&fHHOpt}{{TK8!Du_oRdsNa~V#$DtZY{x!QUghZNLkT>Ga zX;eVWp%=OF+kL!hZ-g9N*J+nSJ;pgB(6%itQ=Ch)GC!mqpk#+JEwpC z(BS_2EjV<=vcnEqw)&6*4mn`Sq6Kq7HVpX^Dj-n`RjH-!G%k6_L`hZ^rn;R>5U=plafptg;Hr`bnM2p_x|Df+itx5 z-gO%u9Uk2?(`?B)MJj!W7gLEuV%u?8ybzz(c%@D$VtgrdhOcg#f~*U^h@m$`vHcn+ z+aFxS39uZY0w7hqO4_3y+({@%)@vY4Na`~;mY6|=WB|@YcQF|h9JV`J*v7{jR@*d% zs=rZRx@i7Us}6e7@kc)8q@x~p*g^F=dzHd{he+JA!!k(|>QnTh8iW4lJXVQy*liIq zq_C5YbRkh*MTcnnl)t1nVHq}LF#S(KNVmEe)oZ4>C<1Sa+5WOf0ofQZB3#w8yNpp|#jrqbUjwK^T` z=v9UW2aaB~;;bhh|D4m-9Dd01z;q^=Te@PMS9UeRoM(+dOl$z;vQN?^``s5aP@srl zK05LU^yY#E?0u@irjo`sh*>$FQXij~{NXRIy7ULXxcQFzr)Qdd+L>1g0TIzE4JQz& zRFDFIz=jP_%n7&tLqxs72nCv2#;`H=Cm=)G_#u%sWGRQyjf8K6fbUfiI<&Om#V#Mo z$O6?OIAJgKu8q)aB8z}c`%p!~h!!s(!~$0q6N{w)3~94xe7g$JOjC!9t;O?(&Uo^1 z=REJUlaE_1KIf46(IoK*6g@OUy+f9J%^dbp6_1&z_GqU|Gms823Zt?rxC%|GCys&< zyd>1?J^fZDzsqGE6o3C`zyJKD|8@J_4{G<+(4k+U-7`vVCQTrkq-7{zX-wM_kxN}E zJ;O4?Y9U*qctiuQP#NfG0J89jJ!f{Ct77cslVpgB;{C`;BD z&h=26+tvekD$)Q$PDh$u6LN4eH+>exo8VJAAlvD*+8xdDgMGELo_xaFUUv2gk69%R zI+zrrL=tF(!RjS1F&yf~CL4u&5icSW_Q006pu7t!_`4cxs?9TmVQg|T0Zxf^F{2t< zuD|uJ4}JF1-`}uS<5R0wGm}_c4NDT02A;bM@oZ;kQ!^ONVO|KPCAc0l!Vycx;c+kq z*+|If&oK~8Kqc*3sU(gh8To@1(Hl@mV#1#hLp)$JV_Jh6CDknN5lrKeRU|$&&8bre z$t9s=LmF=N$V5~~(c@WSKyu&w=tA2);U+gjBDDpm^YBl8^*dj@^oKg1>hEiWQKDyL zS6C!U>M?-X94T9?*vO6)xh$X8iOeb^<*ryK2h>Qg#9FMu53=g)W^IAEyCvCV0`w3> z)Nu}d0;59eGZ^aBV;$KRqncSP!KRh|LKc&(5NSX>eCoE}1wLK5m=h_Z*{bL=WT>z$ zS-^~X-lIJ=J#*xtD?a$<7eDjV6Vz#Av&vw`e3fi{w4y8Q3$qJ0y2NCMNFa4*1YALL zi)J!1A)cD7 zBC|5Cn8N|cXtOkef3ZX___ibaZxg*mp&=P;&DLm2P_tfiQ5dS(W>aS@Z~D74-uIg4 z>w1!|j)eJXUcssanWX?sJG|a5>igKAp9iuk771$~CV}Z>#Uu}(48y9hFG*)} z;H)^VN|-paP(02u{5|Gx-JmTe*4G2e#eEM8=si2*K{2M|3VA;Q&p?N z%HC+f0Ir?SE0}G&HAb|PT{g~(9)Eq{71n^0O4tUp8O<5B%z-8-6hTEY+4*vL zYI^GU)rVg4FK;|@rseu?;^XAtpV|tR0dWJ%V zzpwr073Y5FVx7-%if&V&x}esFd90FScon^gN%RyixR9_oNYP3GG{K}0k8aqJp#$7t z=A?n6%hX91?Pyv(XjwMlff5+N<;}O4t;CNGONYkb0mbQ#A^s0N_A}X#gw#PX4oVXX zl_YCocw?iZCo#ZeZDD0t*1M%eKUp3@b`m*@rPQLj(7)<{W#9bRTOW7CK`E|uI2sZQ zwy1=s*{RH97c*yZ7H0WCLACc-K?6OCeYEQ-UV{|$# ziE`B}b}xb~PW5HME-gpwFJKv5^yIA_)PglQj#+fF&q{2eKd~joFe({fn2_#qfLH*n zKNxaRJ((o})t zq_II6R^1!{mUThyuoX+c`^k5#K4f{yAvA=qj%rFE;{%_7F(Wv!hnnL{Z()R4$)6~0 zrn*-q1Xc0B@;BGM{lYIOdn$4NK|q$w;s$m@c147RB5^sSW#B{zO;?qcx1iif1SnQ7 ztf;6JlZD&ROgEdDvH?L;$1l+y#qE!$rWVOyqHj|=duts0)flXUqb-`D*?i@)Q z5&E!`oibG~rH6F^>^kEXg+3ot;;%oj%jh&P9^zn?Oo-VKc%-7d_MUZb{J=%i(=(My zHDOwz>V*>1ZB4{D+UQ9(ss^)qS%zQ_YvLETWXxz4W-OF|U=MU(FRJ(v|9J_v$J%OK zH>(|2hpw|>L;6HM`D+-q3{h3cFpAi^=G!pmpZMKLKNZ6zo7{jzaW^pLRNKsSn3l!W z(D_oB6hcLViVJ?KL{mobQQPG1Si_>0spu%{3d=3`u7CR{F4G>3*jkNvx+0jAz*8j| zB4R;LPe4R|+KR3#Y6hQeqfRsWbu=+Ny7!Im|MZSsBf5KMJ~MP{m`p)1GzX4bVF6iz z>iBba$mAuYoXya>P}U=g{MM&CMs6xpXcTr7Gv&I+SYBjx$frKZ1YWFi6D=92%fY0T z3RZ|A3sXrXEH1=-*BVX@WFU6=k~9#d5~-Y_jRe)aETafGrfHNBL4s(_YQ{ys)B}Hx z1XVKvG|=C7#UF3}_}70D9Eckg6sZT49;8D#lpqymaPeLo(N?!AVu@zqra`)kg!(TR z{l}ly-m64Lo5ixetgdKVL9!}vkf0>2WPykdRP^Oer*Y!%6)_hU(+423u`r0a zFnD8T5=jY%L7sKskuqKPZhN!taQM>#$#(p59N}ymOMFA^@FnVYw*USMYq=c+hmhl#q8_oshq0fE$ zm%qAhEn9Mijx5QHVY$&n5-i9tM=1`X%@-W0JM1v46AJ;$O4-kUf5VI4biR&zRPVtH z+^oT=SmdMig`J^V%dQJvq`hA3CJ8s+60zw2rhECz(fQ?L+nBsacgctDkuAL~CNQJvG&wn$dmBj;7*D zRnP3`5fn4R9!jDnQJ?zRNcGX18Q_ycHaN+|$_)6WIjhT@kPG~7UoixrIn|JIMAA}i zn!>brbVbngIE@}E`sKyvEnYBW*$_>7Bj%VDxDr<+3mY`mrPKJND60)+(qji>W&#Bp z$|HOBKI5hDy8Hfz8ugl|hl~e$a0P2o??xdIa!2wjH}l$|!qoyT1dTim7B8*LUsxU*s`U3)>U9~Qv>-1m zt+~3|X?3QjD>GB=u|2c9cFt_wGPB{4@r{p6KDupsbZ@)aDc5RxNF`!M=&(WJ`lCvn zitD}Rsh;w?+vUAb;eV3$ibdL#Tr=QbNJF@6c~_$b(&o?wcEX>So_)vPpY_qVz9d5E zq*1Sp5sTKrNF@xlA7W8wP9W9y-4vv$*DIs3D3?F+Z^{ZE?Hh49E`JUeKp!MktQQ=7mN~<4^xOMAkt@ts-<$Zq@%xTbGAIZduILm z(cAAFzWvUz4O^NsP350BQ*^=(tVUi7>`zW;rBAj|^uj-eu^0l2BIbf@7!jiKJi#$! z#!raG_PDBsJ(9#Lb?4y_Sx0kyjry-He&4aH56)x*^%#>hAZe5HDiX-3F%gCZ@L4(B zXpN!(Ji2wqsV{ol_FcQHnz}{pb|#k08ot5cKhRZToC#@2hD&kKr@B7ynKw)@0|>pL zHIxwoT~*KBJ{GZ$WT7n=z1^8@N^#%vrGqD&)c=Gh*H*5s&Y8;v5#2}TVU~8xGvqBu ze0;gY0GX1Uwc+q&x|ozJwR*X#l$w~DUiaYWHP>ys`cGqj(DKyM^`@5(GIUzNL@&EL{?X&p~v7$o!z_l zTz&l`KmEw}TU7ogrTy&haPC{hR03j#|^H*Go;M1+VXrhA3dgJ&1X6;fQb$W>WNIzmlT@0?(cUMaQmE@XIaB6*Z9 z;e_4da)gpC+1uPKm45o$>mJ&;Ww2jIKl;O6{(8X0nhnbaSXE*{EE|AfZbOJTxWbbL zVAvL1vmQJayE_s_BodN%Y}no~_B(fqScU-Xodcm4F2 z4}R~b6T9|S`}LdxUPzf2%wjHpSU)a$2ouFr*X=x#D$b>_HrNdl0SX&sA0KH6cJ|fS z4OADi+u$(h%4=?W@77%h@4wh5fDsL20%N^cEgAmREd}w_7F5`#FvBw9K_qOVQ`bMf zf93BqON*M|A^~W3i;PU70RXT0SkMD%RI`wDn3myyuuNKerSokIC;8ye(sv=UdwME( zvO0gxe&@V?zxRB)cFa@T%~EHUV`I5zL!=@K3KdhO2%6aSaV{U=5usc+o7IUQBD*q= z0S4)xn3^m!s`!OjSmuxwmv3x1t8MvPOmkxTXzm$`Nv!Kf!6jxuOS7I0K9Tn%!g?N zLs7Oy*vN&cT0XG;(Lb%dPuHDOdPWEE`;-vS(sUb{>cUEnSzsSYOrx_XR&I=NRUPOJ zZFy<*P@!DVlfY90Yt}6J=TA4D@#0pgzcXY02$7S810bDpXlauKLo;1mw7u4EjfHMl z6OSvYmw86qiG zEf*|uJ4{nG#mTYE(Pr8UPOVcMfyu>aj&YXm_NZo}HjGO7ia*|}?{k76VXc^J_Z=D~ zB*0*+!`l+#p-Ysgz#+-(Cfa^~-L1oW#w0)^71gc8&5#UhB0BCBbQVEYDy$9ea1r0B zK`{epHq&vW<#E!=gjhHvNvhPIoh{WXb6@t_d2jkqWziv>DJ~D@aWOUn$#5c}5B<3= zy~PNIshN%8&vl`Af^klWh%uhMFusQ^k|w0AF@emsltqi5bNVTt{lL=4u4+zB;fzE9 zN(E5<--T+ z4Sr$`1RG*j>j#mu4ZwxlLyI1kp)JfS{qDM3V)sQ)ln_#;CBrg3)-hoOzMz6R>46Ho zT}qaTLaznz88;mnc;$@?3}{OYAlft2m8JX5`^SHobNWll&1z>h_7M!KzO$%LBmpR( z+X9qWl`86X9i3E?5s@ya63t7i6?<8y{|VXBAqbMrYL!*m-Rr88d4SZv520l8>_oG^ zYUL9?{r9PAzHmqO=-WBAk*LuJn!0mw3s8E|h>VLlG`qMf%iof8MB!(jjhzE-Z zyH_pq2cG)rRv*lvjg@GLVs`0J+^)IpKIriuTuc#Al%vqeA=WB-{M3p~50aN*u9?JL zD(Oq?TXyWyWe)cOr3z+Q+S;HYfG_S%&`A@#3cDS_7%B-TvI*pDmb=WwW)V6jNF}c$ zRw$%c&e~{*8m0u2mc}p>F-!ao9|#7q2n!93D0-2@?e}k(ZMHO7pi-ph1I=(KLX!wm z>V(L^h7>sx!2%&uD&2Yi`pKyoL&0V38d)QzW+sEn$65-89$xrO7YC|R^2(YncQO{% zCR(RN|X0RF&eo`E7^$-H{hSnhiq8t^c5d4>8Z2o@ih(A$IXy^_ zm{E%EJd_^OeN^53z{4qMn4iG286c%PmH3Z16d;zSWiP$VIEW_jgkp+Wqbp(r5BnNQ zYihFZ#3#%<=l#{{P`TOi=&3suYUo!%lSeLgZf-h_(tM>lyee2ugW2_;l1M=I`RdH)6I|)@1s|kUPPHh#$1(+Hk5RG*k zHpg{X`t=W2Fk4U}F~#&`r=j2r39a1-ywHYnvl+ywQG*Po!)tr!lsrD@Fk-5H)MMtn z@}J6;Il7b%go>R&2Z*SWNzuf2QBIH5%Gv%c)eDBk69mRzpPj*hMWub*)Z8?TgZ(qXez7Z|2TSHP)G zK|@*u1s-S-D$BT;DC#yY%nnOtC$@>3ib2F6H>YNr8@BFza%pwYLJgSe6Kk}yEUyB| zMT%5SP9PjHE0A$?9fKXCUy^x z^4NSJ7Ckn#h#P>0=vqaSVU$Eqr??0#CPatD$lRiI-7zUOWma2{dJVqvJ(UG3IPL)! zaHA2o2)s#n5eCH?E^$~n47!U`E6tAYoxb@p-eH?PHx6TqDV0jSvi{;r z9{JHP8bkAts&Kd}W!f4rQVRLUIjcw5lIBXij#lo#(j*Hh{efX9vk{!2BqgGP8$O0< zPzTa+ija*E*$i+Rn2yjB zqiO~~x*aBUF$`r6$2}Qna|&TV(WwRO?dH_Li{4UObwYcp<#QZoVBSq&NH}bH7LIwE z4zO1sm3Fn$3Rz!V-aWb(ZWm{W z6%$^Q2oJWZw1bwZUqTm znN~DVVp~N)oT5E!GzbR51$UuO{f1?uUPodOY93JB1~1}E;u7l^1MR7rOV z-_+E!4ujMcw%dr{NUT7HU0I)TY^gIyP4z@c>)aI4gX~VDfKRXfX1tyslXg10+qLTYTkUtvCN`HBF+lG<uf{!R8^k5|D9K72`?+370eBldHc;UF1Xil=BFclUW(ap(#xh)#FNw)_* z00TaUj5TVq9Db^~cVbenchXzrY*Lt_u5c!01we8gNe0juSZjMsW#OXvM+a8g`zKYf zp;kpCtIYK<*j?b-ur9mWVQ=z|9#}BVJ&6!DMDTzYxzn1h{mskEiw-Jr=##W3Jzj}N z7%=0Y@C+OQu4uQ_+55}yj9z|mYx|CJ|3F2Lv6_nu89ex`NZF<^jJluhDHMocowkVt z(hSQPRl7nm=_FmpF}2#v_T3ME@I%8_UA5v}?;AXHRcV^*;bD-Tl^$RB(7o?@UvtlR zZE&EXJtn7TG7>vgY<7&UT`|q=a77%Zt7S_vQ+OBv>Ik&tyJQ#yvC=OU&NO&LN^ouj z2VToJtu*3#Yo0DDa{0j^0&te76JeyOmUJ3V2R51B)U{&-|Teth{rb8JgYNfn&Sj3pgtUc4=fQG z7Nc0U)TvjS<2#4H_R)#o{#-{nm4QJCY4G&^Q}|prj_WdmZnW~>1&Vly7#2%BLZXRC zIi#UclaoND(dbpzPTu#bgWh-E;?vLSOgplT%J}x}554EzQ;%+|4Gonmbv>r2V`nMQ zaUBQq))#Jt>d+q1a|WO~d6=zYsP`$U32Q;8#_&Q))Z|OAu=~+(VtJ;?YtD=jJs1-F z2_xa_{1yCQGJ{P9s4Jpxv&<5lsvYSC@N|-q6C`*x)!u1Ac*Axa7OeFow$xL|dK(E9 zw!|r+*}qyp^A#L7@H#9Mz+yIE3N?zFq0L}umvCQoX3GP+KlSeEd+x3b%wcy!>S)Gc zspAj!UwF%hbn!ouRPxGzNuPY7L)(*#AbZFFDe7?O3}Crc9jMOk8eadN_fEd?p~cUC zQDbQC)Pw6ceB`6!_dZygqwJ~rQ-j%FkQU(*0d|nnh6LI325a*a(X$y4yG0~J;kT(4 zBI35)PyrO(K+_ISwK6Ad-Ls=4X!;``G7fFqTGDB}Z44!5M9)43=xUh0R+mVDN#Z!d zq8i?0#8r$ERRV2Fxa;&uJ)xII@WYrq$x%?Bs-5=ibnW<)>qkCOPZhG`NNUtcG?kqN z0g@UyJf%i;>VaE!fAU?;EnBLCb2MN%dt`gg9#;$@VltCekisULnAC}msnROjRF{!@ zq9N+OB1x#*WxbhAFT(!fS9gBzyVZH~X14BVP0rMY=9Vj*>nVY7ys#9jZF!CbK}ib4 zg-gjNTZLrT=NL%F0Uq<2TNZYi5K$Kd(834R1J>lM!Xv7;yr%>T40Q%t;z~e*^9;m5 z2+h2~p?U^XPnyj_%3Q?)Aq{F6B34T9b;mIiBb(GMb_pZ}7S+O#Stzk0+0j0z_OusP zS_-;vSp*|JGBTiK1-mX#0$P>6>g+?e?)u2vTDykJeL6EQvPW}|*gf}^J4Sd-WM9AX#)m#N&0ZOo zHO_LakqJp1c#*p8P0}d^N=?*;jF}Q&As_@B7}8(nunrG zCl@-iGnJ!HsIELwPx;sZ50!W%A_MKa=|Zy?M(xzJ=UO-NiE~@SBW1m(Q6=_STniN& z*~7B%17dcmsATI{J6yPtM-cAdR1G_HilN)2N(~(h`K^IEwo#@uIM6{_bB`vTs`g)2 zEean{5O?0;uT?pQIXbAsJ37&*N>ZIi2=ySj7{fT35>m1uAz}>-hy^4~IunpqazedP z3h0otFf{e+@Qo#;WE>(1*8PUCBGm#ZXSRu zOh4c&s>B)7Fia?gA>NQKjf$p|ePp)Qop`Qq3)Pd_NfUfpXOS~RadG-vn5ZTk9{FO^_M?9i~m zwk2Km+O{UYiHhw^E?{wCd9p1aj6*rDrqfk`CnzOQ;3N(= zGQ~!T(c=PIFbN;jB7rfZD-K^ODe$bi5DBz*&?KHz247aA?{?LWd2)4detBv(0oa}7 zCPRc3AuFArG%7PUTsiskALzJ8TeJR}x&+#8_vx#BvHP&$h|rzj+^lFSm>3_g)$5Ih zpYZZ&dDoTjx^R__VuT{*fO;a*(q=_iq)BvTu+Q67R{*6^=IdN}@v8mL{LEYHOXlDG zwV(g#Yro*4l)*qK;tH(6QL35?-I&l*XaqSEqg(1M1&k?*6o4qfj(t%a$ejEFGN%E1 zvIilcd^456tH212mb*jypBb4fbYEh}n#>DOK!J+o1`l?1*y4gIafXZqtlCa#+t3fx z9DQmz9{UUh0<(DrjYU3u7D&0$9^bw9J6~WgrGY5v%UM&WJ>?0ne(4LAFI%GTljv|9 zRki^SeH5hmLRFQi>FGP~dGOL7{rcv+9?(xJp~}W9Y}O>>y#dllI{=JHp642%)SW?^ zZ>&y7_-dzF5OK`V3_Cl!-?2vw9(qu-Id;rjpDP77eC=noK3?gsZ?l2YGuOa> zIoNEqu9YYL`|{?-4V8fbk($%fFZcSvv)gmo-Pn!i+@dh&@cec{=! zI`_jr`t{X)@uaB?bY!HNVYBtPjbZ{~2h@(_!VCdhPsEunR9UmuSe_zLs?@8SZ@6ps zLmTI=TB4cq*ta};a`&FKKm22Tkgsk6@1P_E1XDq&6=nU&L}}{bO45O$r7H-AjRjVg zVyJ8^V`QVn5_r+dNM;NUAF{!3r|evIIN`G@(ivcJGX{`*SYp&g5C*meW^lNUAvOkK zDDG1ws$RQ4eBjplLRFFf$r zRr=zXf&G9;R-RM_lF{ffl@=P)Oxde6`Y|lJsRfd0TI6v^EGGLa39hM|!6L|*?|AAX-(Tu(I*P5mZYAv(=M#+_M5GTH~3i=DrqD{XahYH}M`zx=16W~6aY zl1xWdB!Y%N0L7&juIb)r?UAj!wEJtOyA5HIL&?!FKOv|~I*gtr2@1|6L;^|G_Jt1c z!)}yfwjQLc`Z1tdb@-u2uQ~6MGrn?eb*QgB)L8TG7yj~1pP!v+>9P^N8zwC9q?8c< z_X=3hQK6Nsb2Tl15`(*mLXb(iBvJ4Yq@I#Fl0;9B?}epNL_50E=wwA+3jxT83oA4A z;(~2_5nW9YbkusxQP9-`9t;0l0AZYhEC&6GS$Xb)+Oi|`Ua-(?P*_7ju+gx|dIo21 zv-ygX4(dDXT*+&QwbZp#fpM0c3^2DyzNRQiTfn%BBSxQ2XNF#v?w3n(7|t_aB^lBl z**ms<=kUXiZhd(3_H8?d^$NT?zh0$0;GD~*IR@+?MZ?-CNk0^2u+q3SxMbnf=y+RS zyRLMqef2H3-hb_fEBPu9^#8QwX)hSn=mPb%$gMv_uF9B+Uk@4ahT)367^hb7#+*Vb7@6jPitm zOrc-I7!1LMo?b3EF$Od2Bn^nEaJnXfzC+clELl;VvzV_hh#^?Q0M)}5$p%GtkK5(H zo;&+bFFRY$Q7}39ct^`nWUj~%RgnT9TbFhJe_d|i#ikbo5)d4NUofFAwTNJFu5Jy5Hb&^ejgdCQsuBt!Q zWR2D;?j#U3ZP4xV^22op!0~_w5UHft*L_y^uU}xdWJzCJvyhKY}Emf9*EJ8w`t;0#&By<^BEuBr@nmu zC1?HhDHnh2-Piy4tN;Awmk#yUr>16N|7Z)dm0hL$`~|3kDwZvZrhI!Aq&+6PSe3+&N$jOnji|LyN*Lj3!kU z?oFH?PSsOC-TM?kQ*l*Wo5dwzk!ZQ9;Ooz-f+a-Vmoc=qV!D!5>fdJ*W*os*+Ve3U z+snB97M5gYBRQ)*fia3S9WDNEdyV^>x2Tp zJMcyck{K9sWLc0Lp^`l4azf03r=QNm8qLTm%UY~=q2b{Odf|ckQjGH}c5H!|mJz>@ zQr@f{XfA0W7OCT*BP}8|V&$bY#vd!kB$mM1GOZ}l?HZPw7G$+laK``iopk)sS6=$b zv;XRe`nht*(Lp62wj&btwqBoj$CrP2+h@O{?|!x?r1`k!96 zcim?0g)Hbl_zc|v)VIe9g`mVHLM;^{p5#XfgK|7bAc;`f|j^apMes4^oY-&NHS-Sa>xVbQI1{%SXpr#CpoM2LJd13l5;5Z zNJ9p+Z*8t4&s(Y|BH-3GK=c`y7!lPOOc!kdsvSAdtnsQt8NpFAy*Zuab$ zl{$BQ@w<=w_>ZOjhW4VXU-PU*s}J_u_{tNzM%G<%b*ENu&9+uN{lv!H!Q_rLT4fA` zVL-wXKGcd&igmKwvh-m-VV6ybm5K)<34;|~a71Bf^2CSxj0adQ@eG9ZDIN?L5J@tx zU}nVKrIg_!uP?2J?SzsK*jk%2!P68PD{9kWu^fTRh#6d2&P4s`-82Zf@P)@_rzlo(ZE9;AuU)i z?<*gB_mcTsv;=_?VmfkaiEYW_A2aum<@y0ZB3-TB_Q`MVxNnnA`vw-zKl1;a&Kqjf zsMR+8`cE@^^x}@ryp;zmTyUq z_vA+(-m>?BNA&HpPG5cTu`79FpWO|ESw_H8lx>{@5+O;EiOo~2L=G9o9$vGm*6US- zGgHI_A>bsL=|OOMxoSH1h71-Y(IhECFeg#3v`9@W*hi<8B>d+`0C?^{R5|$ogfh#FS5SRvy4L(S<|Def;g$zw#qv zn;xw+YFqwr>)84&jYF1Hmo8Xx^0DiG_&c4WHK%5G-?47tn$>!ac>d!KacUBIn;n}! z$x0Y(s5;UZLR6j6E3zJv7~rJK#b_o)JBKtSg7^_hyfDYeJM=j_Ojb|aT(m-DLQ|GN zbl(zYcp9_uR)euwUt-Vjc1|>0GJ5py>!9KQ0jrHf?$!Evpg|`!5?~tA$1I? z?tkEchaY`p*ZAJ4*;;94LMQW$K_wJM8qQ0B*B|`1pY%Uw<-9|e4;-}Y&~whZJHeZ{GE_j$3ltxSh$ZO+Q zsNpZSC&#BIXMpwSCu}&tvZpM@C-;8x%a=dr>@!xbI#?5rpgwldmv6n}p3A=c@dfjS zbm2!lnn*M@>Qyq=>-9^&cjar(z2LaV9eL0}2XJw`T;6Z-qNV#S&MKHJ_v{^eXu~5{ z{_3i)T>72y@hPrevL)EBWIj{d=-m(Af8kfxTynmyJIy=o#DV2Yr$@%*@%HO(KKLKb zu9r(gt5#_Gk*JIqdvyEE^sH=Y=ux3L{Zo4;95RkO9Xdk5kUBbYMD|37gH1y{Fo-|= zry2i_Z>r}(Ny50LDoT1NmUIXG(Xth8dImzRq|nF!reYg%gLPp-mnXW_0f_;INelZH zMz|+Q)0=#cYFZ-Fg`%nrQL|4ZL>R{5K4&(dCOUOYo}OU;Lx+mb#9Y(D%4n!h8~0SR zJu9UTU`|6!WtleA1cH+Ly@oZ1GbGIvuXhOn zx*-N1%Pg7Jxos}f*w(P2$I4VS87#GX^m#%sKVCClUWz5#FRjR`>G>z=0wk*H$+ckB z#G^}zq7k@+f~AglsK!>@@%$y5IXLH$oBBXsf4{Cxt2r_qnYURP-yW|ru zc-6cAaKo*&fkCFnVkVG&J*zY`Gy3r6p*2VMRVsZ8=ICi49fh^^7`66`t*R2XtY5nx zn-p(*M(>%{S35c_S*!_$9jhi=Z$nHeqQvk-^kN8b(Ka$95u&d5qR?j+BAKeemiv&4 zKy~Q|SuAz_+1T;b=D@Np@*xdVn391QpI#iAF_K!_pRmnFV!c5=}|<$6`8QTlPQ z%Djd5KD^~cuYS*6ckyn@0}ojC-LL%XQL7GVPS4mz$zz!Ap$;B#(8A-7(u>dcjO^X} zz{6!d^w=rY<_*r)t2$zy92skk>sW`~e|1iOt6G`m#n;mtN+|ZxK(S(#sadY6lbzlGh zKmbWZK~z)k9CQNV1Xz*BeAnGMt!7X(`FU`}hHW-ar&>+E1g=!6_s`LT9RA2Moz*9+ zVdoYq{5s+}_`v-`4d!aQ@mJideYJHPH@*0E@7=KRQL!Dq>Y)F;_`(&-mb7@KN#2N* zNBY`Wy-}V&fA7urOg#Lk+!Sr_u}A172AX-Mc>-w~b*)xy;ZUny*N%B+@8s||?p0*m-~@JOAA zmZ>z`iZ~fiA1cjRU^_z*ka&TCHqsboz-hrr8c@gDH8q6JlZ&{iA8ab?2!tt42-x6h zNcYBJ=um_JmRGFuD>*N)+-}Gz-Qtkm13RY)moq zf)hf_I@;hOYV7qymVIz%fy^sT(#4Xn7$9_;KLkB+0!;nbWfB$JP}0i@Cq}?maq6To zkGmNjYaEpu8sl1jb(9MV&sj^yS*Jhcl7Bs~zgpo( z*$Agz3#6A>_50$oHr?{zV~%LdpV#T@8^3G)_P+*M8<6eW-r&S9XrBeTWyw z;3?t)@RG30Id;m)Uo4{D{g{yv@DR9?FyJBJgeTdb&q95v2 zzR$|8j+Un?EhVd~GP?f!vG=~|^Ot-}H@|dCby82k>ia;mttsyrd9RnQ42gAi_Cpta z^~KLS^WYWBbu9JXx1FOGa#RS6L;$T;22bvBn9e21S?{lk_Io_vhn!lrjZ z4FBe4e8dqIkEraW>1a3x9(~%5hS$=bL=vm1W{8;C=a9*SXaTO87g>l{7rgYPj=bg) zoCgGgf|P~bpP7)cWYG<%C8C4y(j?q4Qy|bl&}$OTy&`bHfxdus%9s+M$_gEb?B3Lw z8mrX$fE2$|*4aSPWKk+Qa@jOqzI$)=iSws5PCCLMeTjIR-ZSo zRIQAyePHDJJ9W&29p=;m5lSpoAq-l=kP2J1{YEZgn!At_CEzTVWkTAqLIgn}hqU`r zinxwWRop8_LN?(#3v5-8vuboQiy9gt011Jnt3eYJE&H4gr2x}EXEg4#1z`v5;tv4u zscWGl$Gtl`d$yDoAEpgcJOoCZ8~}-pOm^B~hsgR>=4*E~PFbMu(3248(HCZ{4doOp zg4rvzi4n(BK1Szhjl^M7jq0NhXiGY zo3h}=#e&D5__!ba$0vXGo2&oz%ip;A<~vJ`fqLD)euOieZ0jI;;_ipH{o9WlN37U! z*%ck#q0`$%P#V^e@s1SW*5ZXcfr-Gu7@e_pqn84?Fi;Ie5it`Pef}v0PjnYEivyT= zFf8rD!Wzltw4)k>3YzdZxz#6|&pN@Vs+va^3QfD?dg7rsiHZ{Lsy_-m=JPD(EE7OQ zS7BQ_9;hujg7sc7GKzRm=gyAKx_7@%BjHAVK_d!U{9_EL8!6`Xia*R%GqZ;_4HGp@U0*I z;-g>v{<=rD)cyJ*gp97o$F_X!Cur1z0eUYMS8*KAyaYc=F~*37dOBcKk;x<*^k_y8 zPILv4%uP)Y-9fU~76dAX&;+(hfMdipPHtIYp@<3`y_zBzLDZ7Hg!)yyzet9qX>1?`9K2hj32bMW9ni4cD!~+ktAwM`#y>Ub{ ze$a{@y2EAIQz2szbPyO_FtpS^9E8L*^XL&HbeSV@X=Uv&+Y0D`&oXMpR5b(zsO%A- zc502j#;gARxqtZ1MbAF<@j6c|0!26#9UUa;J+h7T^Sta#*}>6+p)gSt6MquH5pp`b z+04DMMLy(>fO-5TSFx9qkml2%r!mke+bO z8gUaf1mV!XY*gxKK{1QNo7xG*ed=Z7FYwJm1o%W&NgjMhhIM*Rbm9zUqK1U2EJD3O zNH{Dah(cllQ;pd#ohi9D%ncSTEkH^Yq99nm=YXUUzXYhf@C2rljBv7cU14)TRVDgQ{Nc&__ED~2JE&~v4aZ`WlMMxK3K*(YCJ-L zyL~g<>LA-2dlF3XfD+qR?v$wZSw3JwW!~3zO0RtL`|h}Z!|EefpK$bHFMa+QC!O%P zp`pP~y!%zx-FEjxJdk1wF-ceqq_ZmQ7S#i*WmH3>c*O_J18jF~YhtG(s#+2@1SHBF zlRBgYss_+Lt1kr?GBUB5g|KOeB24jgA~4ApG=k)Qh^ZisUKbw^MYb~K=thz!o|t2??yJZd#nvju2Fz2_*| zh>30zX0CbA@AnJ{g}Re3sYgt0ti&q@P$ZPQrUSBc#>JLGYO)vG-Fooq%CXPaV|zvk z&H&OOoHxA4Z%sm#lFpkycYpt}Yi9cVcyU6uk`!-r`QJB6(T(Ndla?Ae_j)QKwOo`p z${!n!`TB=VS3z`7wB5es>)-zO%fI`;=22bX>CBAvRXR_7$|>i)?KMw5WsNQl*=3#l z&z^YfQD6ShxnI5fdlNIwcBM~hL{#!ETQsB-G(xA6;^~U&d?MDNGe=B2Urf-nL6ps7 z=IBp?_##5`AsVKLKjC8pb2poWa8sb#T04?c#7=h$8k%t##jTsz;xt3D@dvTo11_ zt0fckulGpTDxI~%)qh_<@QzL>-u}kdtXg%j-rjKgo%eqGir+rtg>QKO z+s=9Kxo`07hDu!of7#zX>qXB!Q%B8uRE-DtHFK9rt5+SOC)C11Y*UdX5nDI#FABu4 z-L*uODArp!+HfiX5|H@t{*5qeX=a=bv0_mYWJ(RCnG1d+Hy zpw~P}aG=9eFWBxb8-glyTLcQ0(Nuv8dSNR>7(`_l&_=Hc1g#VTwnAwjnZyBA`{sIS z=la&e*Oym4t=!V{R!I^$HLQV}KOB7P#fs(5*VpwQv!H$Y(#a`)ZV(k(u(rH&O-4wg zi9RsWajz&u9vW`w;*M?A?vMv;Jp&0sVPL+(n^xKl@C+8>Y9U zwcBT&e(GCa_ww`q^$Q>T@MnhR&UxosU#&YBVS-+WrgN-*z4!;a1kmPNkjf@KKtdpJ zFTaMIU}8vw5Y3oI#Ki?4e|aZ>(KtugKt|T#ag`k+>}mu6ya`TKe2z{SnAb%~ArqHP zr$6dpC44fs?1$zn?iqLd3O7Feox6;s}i&3sAS7nzTf4+ai*5{n1 z8~7!?*6Qu=`^Z&S|7mizJu=z;_pg8d6QBJz8LIbC{q>WM`|a=lIHsrN9aO{j=y05G zTzfCCq@@stln1obgys?4SR-Hf=iaaOv5u#5N%c-#gy*Q1h-UJ9-HwLrI*lO1mfK5RJT@avo_$6^bZ28@gpz z`5iY7+`DsTc*k~qA9>H{UW)FQ>(ymCK+{xsE{kPt?u{>{nBxq@H;1^f@NqTgMbHpJl zw(s1n1k`hk`u>m#FKy5p73#G{qbB4#?pb%)kAAHyNQ?{?5Lw1m^%$)bA{sF!DPlG^ z#tT8%Lc+4q>ueKUf}VlIs3D*pAEqJ{l3|W^RtlgCxsi#4T*ZsD^vzTuI;-i(a@2~p zDxo!CJ%pYMgq}#^*FHr2%94C{guw0qt#bkU90^3tF4FbtPRX7mzzFp)k9n} zn|JNq{?kAH`s!OXHPvf8-^%D#`O=QQry;B1ht>=l$i{PL#ee68f5;eos!G$c$Qja<=+Ef4Fwe&>T_XB@9X*<)Xru2yWUSI3?ofA8C?%#XniX6s zDd$k&lWx4mix_-3!ZfcBYLqQX!jmCqHzVGq-t8da1 zk|x`DIi+S@-y7`Q_rSWRJ@w=xSFbwd*dzXU_0653c{-#jjqiE>nNMl-_v=N$ci(;Q z?y;%Ad)>SBi>XqlFVQNcDz$!XAUt-hjij%i?2(VT!%6Ef{*;D_-!A zZ+OLYyY#b7)0f;|X+GtJjpLr$8GfWRyj{-#X%h2hM65jTNxOatN_yDcJ7e+z3L)|* zg^sA2N3s+*y|>pU!EsE4-6Bc|F)&8FE`oacn2vO+n2qCcQ9PMd^evEuLj$ip>tyY> z70NWLDY#LDBda3qbR}9A`3SSCy@OhZUjB=cjU2RkSlOI>}h>IWq}H)ttM;twf< zAuAF@P5*d7kzlX^#vw3MjyYA6V$~H*_Fvv5AR-zU+yteF_qA{OW^3t@wWW{g#AP9i zh1uu;PtgdqL>RSFDUjLO+7VB$9QfGg_1|jV^t1NFSXrla;=llVD#g^K2!5AX36wBu zg?V`DYG2>gH{AA>|M=Eh-|(sfR~-1Ii!K}+8Bzb>!o|1mEnPKQ`^IgpJ%^vB9bQ$x z@w@VIGnc);wQ;?^&?9`tPh;$ZCW)xn66+5|3@I9uA#9xLjTV_Ugya-2lnydr`hqVktR0RoH$Zk=G%GRfq$H!v_Pr*sGThwSy zwYS|{S$RsOF~>*Iy-_w~;X17l_IL|Lr(Lx<)x(}tJL-wNOGRJ&m>iP{{2E+%AxEs* zo~lmMrclqO_RJ9Fl5(ihoH=IoAp?E&(Nf0V`U zVdJ<{+7GX75AW3b@SQrEd+O<&C@FDDh1u9H!=zLO+7cohwnW6@bQlYR4jaChBH>&y zUijy%MVDm*Rxe7|)88Y*oZfRRPdpcB20G*M%s?(hhPYEoBC7O}K&dHMOpM~HTe9Sy zP1}BO^;+%1a7jcRj_6JN+aBR(9Oul#Xcg#$81y*QHxfgK#)Rd15P}je1rH!eF(5Fo z$ih+4p=)P+S7&5Xb=8v&b{UU+H+UGCQr7eh&Srdp6uSyXe;W$9#WF2%{0?9rIF$G-aUFOTu-s-aWQV;ui}w01#MfMZ=R7HAE1#Z?=FKtJxmit?wvwrY7}jLVX1x zA2FL*(30cdXU1apl)zjBT_cX0RQ1E#=Je68s$rAQ$YIq}%?i6%b?6GW(0vfw)R9Dc z!%ef-f3UN}_J|%DWqL*KV+# zN@xxY%k{n(FsloDw9}uzV&LWPAOHFVrQzX{hDT<5bXZ0mgX3646^MLPSOTbzOo`nY zN1U{A(5{9E83q+A=)_|siW-q~;E3d**fXqKbb7g)G|17`?p^v4WO-Dzm8|CNQfXqG zJL-JniS%Kl<91XBZo8$n5$uQW`)TzTPXB4`dfGXs#G7WJ^mk* zL*R+OYqNKq;ugNg9gE!UkOBa=6gBl`=SpYM>hjW~t0%PTt*OrRp33x|&iHU^Vvly0 znwfy%n7UDJ40N>L99mc&T3DU4P;awCtw6MAY`2*1VU6uY>xd+J6&Td1%*<4l95Hmx zzf4^ELCr(yy?Sx*fUG!UbiwKU2o3)t$RPUzQEdOWz4!35q$v0Q&pDH4cV-hCSy*yd zG7=<%fC*4hQLa~1yj;a_kt+tgh@zMS2E3@45fmdq5kWu@lptZr5@lJ~ytA{D(>dq& z{(PRQK4-!2_x1e)el=&htDbstJzZU0UES?INf0M+0i|MElY1+M!t*{b%;x@l_V)0G8Vb6SBGLDCmAUd}Q+O!O$UJG#ofGwV!y=n-)tX}VW3NYWq)Raj6$ zgr+J88==BZ(I^0wiV=cRljmm8)=^z{g=S{juSoHv5aEqBi^&rvh*iw31e$>dT41-aMC3wtfl+PION^Ck#lt92@PG|1sQk9${?WM3pL|>(%#yZkR zX7H1HL_0K4XS}b5&?9VeEre{{+1EGq*VnBH7lJMg*o4nj6b}^zjUGt0n?7LR8^;P6 z8HmGKN^obI@1IYfS>M!$j0$<3P`Rtmfmr1Ts|^g*hlk5k%&=K4=3PcdO7$*21%nB>`wEPgrOfJpOmeqpC%6c0QjTJ|t+RUXuj-SNZTr1lp0oX88Rii_ z5XL=zTbtZuM#2h#f|{pP(P@jDV051_6f_P^a?({v6~WEuhN@Q7hS)A>&C@g~XSvv2 z8y|1kZg0LOQ(wKjRN<54@^7>;S+$JgNu1p`o4cg~R|msS+K`cp6EO6{r>=F0^MhEV z7$WA>yVuvRD-8{xi+V5JrQx0+IXO*MN&!74mz^m&a!Zl2Sz%3_!sIE?lwcL!H%Ar2 zFUm+FYbvImeWpA*=?kn+;s^#O-(t+j39FTbpB zSWORVbk!ixEVUKOfTg{($z6-`8ioQ<*a#j3KE6F3Qdwv|)0sD25!S4Eo|6~sQv~SA zafOi}#*&_D6pYrBZc0E%kykMsOF4{^Yt1g|gc}K#+Cq_y3LiD4qgEv%EbyWBLD+J^Of`!fu)F6iUSWN=8FxHXW{ zL7^1tMqXA#j;)=fk~HPtr!X?I&JZ#ppcr&)Np;Rmwe@RD!-K?1ZS`_4bfsve%58yC zSFZvh`uCs{t*InKCm=0X(3K(gCr8*WSehl#G@(axYMjuDnBX)M=dWM|#7as% z=3sCxI&UtlqyhWxu@Z8i@->g1zH?QK)RA9$( z0y^>?Nusm>9fT?XcaH+5ni*#pQ-rC?84Y(h@N{sxisf|i7Lm29YU|fXF9=anTXsRp zZMmuX=wNBuY?U2cuE}zR?|;#oT(n}vF3$s*QcNQw2~CN!6=UJ?Qf+8cZPQwfG37g4 zSuOJ}I)1%dl)HLFi{6~Rb=l><*J&)Jc&ye(0?Hd4$_F~vh2 z8sG>VWS>M)P$lURL1utOi$tgmS|N%;*oo46*(MF4(Bx;OJo&(%rmnrXzHuGrTuBWn zv6l{NA2mS4koF3KGz=oI2}xcp3qc^emLgRVE{>j*hj3t1ZQW{V%H_)l6Yh3saX6Sq zQiCB1lcKno1;iQ^SvAQaVM}a+QYEESo9BTXIIeJ$h@!9%Figir_=FAtUYXWg8{WhL zWyV-sWNG>g4Xx-WG{4dbhK?IC zHh1|&ks}c#4y?%IridXBLknu@No6>^nNaAiu6Urf=IqLzC(sp@I%bxuc~m44%}=V! zLoM|^jvXJaa*BRxc_|qdDqNh8E--L_ID>N0&{o8RRLEdOkCM*U?bW{JlQ;dSy6hIr zUI*{Z*8hgpQris)~QX(^Z%cVWBArZW@=gfsB5l z%H@x49Wx5yN;E1td6m0+lVC;?x{~Jttz2rG;f^BONi4)f2Z6#v9C@8?-#Wp;X5nA^ zWSKWnIDq7UxFVCc(&fDZ6t-fZmnjQ}bE7Wu*-G8$jmUE0R4R87m$kcoR)6ZRmAzkI z+3v`4Yj>GhLAQ6(g#uKx6wQ&kH>s@N2!8fb!iOc2`TCt2P7)+ir#zIm9?FQsKQyI4 z$5G*(thJF1Q}0HnHOOAZrRf-|vnM4$5QbuAx6b?V} z$yZ^jizI>e1w~^BOonriVwu&uqo|mwV8tcR#?_p^>eFWllVInBiYi^x8R4ppB+nAr zM_z4Y8{|P{>o8%EG&x@HVMc%%T~KwFR|}C?Z_Y_aMC#579W{?kU<<@o^-`Mdot=UV z1B5|`ziCzVrmxo?x~8(n@s%Y9m)mFPWieocUh%@_6<6<#lcVP(=0cppQrpq(6-~$?UF1U?Hf)eH+bt z=|jidz~Ne(&caO~mxu{ec8`mZ3#fE%T2KZWEY-A`PI?y!r*j)}!qH=(9Pg0eeTKx% zh`vF=sZ`ReO}&Nh-)J3{r6m_3LsZgT#BMyS($}x`a{mE_sp&SgHSX&|mv z*THR49Hc4_(A^cOY5niPR+%kMaZ7OJxc=$t4PULz{$*vyBkS8AR-U;v!zIla)pZ>! zzG0~tkhNh_Qo2&3W>%7CP$4X^mz1|y;}AF8)h|u*R+^~?Z=AaS23@GL@QA}&LEv*1 z;ZEK~QR=7oh)5zsKP2a*suYoyjw(9iN{xQ5r}`BNjBGl(NNy z7_QP-S1{CHn(Wq1ehi}qa++8g(64$!K|Pa?xhm@9JhA0FfExBnVUHoGgqmihH`@68 z)a^em-}{@&;@!*J9#+|M&+_ywm_*W9l1P$D=!70pDYmFa7+GJND}54g8KwrHy>(V% z?LAAzs?Xk6`^T-d6%Ut2S!~p<(20&jL6c`36g|GEl!4^or0&+qvL&B?dDxNU=nM?4 zyV$@Yel-VwzKGEU6bA|S2AIobZbeEHZ~+RFr4$i zz`8Q?GAhE}$?dMZ1i}?2?BJWq?t!8W02>9IYGoGvm;aXT0=D z%yjZHZ}KfHVAjY6_2e84S(vlt>tY%kJ#}a4$-h;*W>n^FUtYX-%lxI~nTzXPy*lU8 zmS|xANF{5lQW~1ThlsI%rO$YY*QoU^udjTRVS9b;)0$t!7S!gl0L2w3=;^U7BV05T z&{x`0Tt)rFpG|f=dH(|6c{d#0eE&vWF4-X>VwT_J8Z7s(2Op(U*aX}x5g{~wL^(?-pa@VpmxR#b*^bRy`XAGx2&tPyuxQ_DdnU?OHGlQ4%yOC8x5l6eEo&zglHmf zBdgj95h_V&5J^of2m7-Glk`Jq$z2OE$II9`JfMD+KDLFmz*A~P;z>ggtD+r4sBuk5 z(s%?%G#4J={o&k&W${CoPSt^^Ez&f3sPmWZdMVWOWD}Jh-9nAm=;s)TBOVbZ zlo~aS=bpW<^v}OnQLl4`zVSO_VP)2$QqO`iGa?;5byhJGaGJ41j1SPMk@EPUS0k;d z_dj3nUsW1d9h35eroz2V*XI(7X4-nNoGy+60?;ss6#_*0~fam;2 zM(X|Rr8an2AxpwvAx_6W0B_}9ota6xs!}%@GSqfDQDFN>sk@h@x;prEjV3x?eZ}Ba zZHF=uF#x3}8$vUJ++t-yQdixE`|A`8VifG)nUMq|&-v1ImL^yv*^XL-m!^P8KItHl zB_F6hI;;-1I#pqCJ#ztkY^HQn9_=~nc}$04VZS3&Q%!^*jxc`q0?pagt&2YSSCd0J zjtM>*DTq0daY89HlXAW*Cp*Dp>r<^dFuz4nGat43*rwWWUuosT9Cvb!n1=|RoGiU3{pz%Ag~hI0i77{;Qli-4>*{KtkdoB10! zbDhh=53NCScz~M21Y`&(@fVclFGu*WnHVljpOZQy8UiojZH@f6fvD>^y~SwL1`VR2 zlbfrhp$+QQrp>ktdr^4eFXbSVK>de<*`{aX1p&v(q&g|nlxSUi%4{LMbkPaP2hg?D zyJysC3C^aXu)A@e1j*Mrsf`aazA2Avs=JePV`IRS&byq*C&%iPlZ}UUn=vvq^4s$EWiRykt%0XKmSXY}lpQEunF~R^Y z*s2F@?_yw7n(*^?Ni%k0pOReRWVbXXj>H*ws3-&@Cf~{Nx_!YJD*8rqkW{(aodmja zK7vd@|1|!9Od}B;+sbzpdu;R754GU1oh~^6xnJE+E_;Ua<5Qc4>vji0Us=w zOBvCbQx+$nqC`#VjMYdZ9t$$HFmf;jD)f)jWk*LaJMd}nNw5e6LqCpAu90AoHB-WVDjJO#sO&4uM8$kI*+=URFrIk_CR*b#I#epdnKDOFuz!#Aq^!xqzujp{W_Gy1CKH^iQjX zM4TmAFlueox}lYAv}(#pS>S{t-PoiDBJh_c30o~ek%kO(Ya|3lOY}RkH`ZK(B`vK< zc#5qc0*0hGBxH$Uz(YuQfr5T<{D?7?BK41o$Rv)cf{~mRSzX5BAFdb>DpH@tigQh& ziA~Np(nOqX2dZ1MfN@#Pi>sK<<#P$h@y$wZz3KSzn9&RsqnZuULu!gGrQk;7F>+)1jZeP*@Hd`7y~8`cwBojqpHM&hjy8;r5oP(#@o z->0Sb_uV&Q$cP-l!lj^c#IuSDFUsDfB8U_bK}zHbrjUqgnyVC*5rjm6yxT8%!=~YM z8BT>T%|XXDQY=%HvILKC`;Tj8LfOZgj8kGOjmV_vk(feg<{8YgK5AEfK62EZvt(}? z%4#kmm~cCV16aRG$oiiRblLwoJMbpO4U&Y#`0Y16%9oC zm|F2vb^SWtM5D)8#SvEg5xN@AaW+*X!Y_s<2B^IqI3Ymz3VRDhBF6wH6zFMA!4h0d zlG@~pFR6MFF*2-uI{cRu3mcR(I?Ko@RDEt?nb>86zvp!KD%H^i@OcFP>x&<|k|L_S+dLKpf+&qT*j zl%ymZjX`iSP^h)eo!fETJKFYqS=;m(e62vr4)j$Y`t#85FRO0a#Iw)lX9*JOqYmS2 zucQ)8QIt7Jx>Qj%`Mff7X6u55j20%BKcf?zEO%lLt#BP8vd)~<<)qu9F;0-U+W1yy zet7z>OILjDORFA#lox-(2XvXwd^Bf&DN`t+l7>oc-K7bg^^g~pg>YqH8{V@z9H6Ez zP+$>B6;u@k@=bJr15?@<$Nc~~kg8*2d+)Zx?=QcIFVF&c?zxp~)~@gAnZDDG+wHz| z$DdtzUT;_1_bYQ&O*3D?kt=z7G%f|72-X|jM?Ak@kh`O@;O zjhF>8Y-mM1NH?g%RI$}CM?6f&k){bu>SKstR#ZWX``~RUI?&OD3j*DuX<4{<<{4ip z&s|cRC{3HK%qc+Os!I{$;IYc%*kCH|9q#ZJ?+cuci2nd8^& z#0cwKK-8JouhWuIqLW1bZ@zVOG6o&*f>TNgQ1eUrTMBhHkW-key%T5tlQ97gi^M7% zLak968{w-u;p2>+-k$G#?R=t#5BGiYix>U)(qC;H80?(Z^Rk2Xz2Gw+*>2k<7kui2 zcii*9Ly!NHXL7jQs4>~dcOH9ZFnk*xW^6Q}k7UvI_;!k;3iri*{@S#j-mxkt$0{FG z=G_+fl2`R0g-;|`y0~@#P~({gbyqyLM=xEUoYvkt#^7yqtklu&AP^Sn>MMd16MUST zcmIrzPfqb15}&Q0!@~iL_F#fk&f2{1jQK()5Y=8~o^k6w`9tN|ODeqj<(eOk-u62l zj&7MTyW`N~roH0SmTh*McHCQses*Cx@1OvEbEi)V9PZ32b~5jP0PaFm6}y_G)&ng% zzcKw&Zqwu;)#HOxL;XB&!@x?DBTS&MW`tadovqhFBSoU^B$2$5_d22@=mY4sCL0l;Fltb66{ZrDVn>p1 z^&hM(Br0-k5|Y`axS=+<)>%4z(wmMyaQ}S(o%gw~f9dN#E@`1p%izf9Raag+JUH;X zUtG+Km5w{=r4K#)1edKX)rpf{d+f<4zG~+kw_{F{kGFsSlFM(n^{({q#^pg*I z$(}ddeEYo*{^RWbdiyqu7xfK{eE*k!xa9Jy4}R%BA3gmoJ1yCAV3D4KDz3c*>l{Dp)iX@?wBnZFgU$|^ti#K2A0jz01LuQ}qGA(l8yuuMY-8z~yMXv!2BfYqw; zr!4Uk!&BuOaYGf29Dzc{$vseH%|=c% zR(|i&-CgR3`eD)YIdwziADa?ab|^7=$tDlOe*`!}3$ z%;&!Po%6o=Q!eWc-FNA$k3DqzEw@;-<-7R|WuP6p|L%M5u;n{We(kJT33AYWyKl2_ z_WM5h&5qfVq3uQ_+3su`}QDk!p93Mq<^{ zwC#3lJ>b=Y*Z*YTw?AOnP3z*No%s9C&PH&Dt3|?bEjRepB?yfBiEpJ?jmp_PqAptzFzJ z(uuBPul;%tIez8;eq#8xn;B|x?^x%$)O2nwJ25vFg+{|SvJGe;vNa3!T$x_ss4-!| zNCTv>+2u!oybB(-k(~(YuO_YJ+Wea z|6qM=_{{%2IilxBm;UOQ*PQao6W{*3Yi{5zVW0Tu`(JnL!KMDSydn%%OPB8Qm%rY1 z!oQ#K-j95C-TFRmvHkaZPFb^VJ)hZr?I~y6b+5h|aO#_m-+8;m<0B)yQx^@7KJuVz zZ@A?(r+na*|Mvc0{PqtZ9DVqK|MT8|XR+0^%G7s1eJ0Vvi2k@Yy=#xdUVGeI-uK|c zkL|Sml8Zk3k>)L*2-Tb2OA%HZ@QYWM~E`p#rUSx@ff|%W$6dmpxJf>5Lf7zWn4EI2B@*$KW z2z+58IwYl@N`w|CQsvW@jH|>?Fi3d)8yx_pHN5$Y8xG7?3sW} zUqk-tYi{_DGtTuOx_sNc|M<&Q3-{f7x6@BK@yaW&(e2n$>FKAJzx{n@_YZI*lQ&n+ zKlg*D@s*_aeE8Eh|K%R8(Rj1dEm!`qv#WF0Z5BPhVujA%iWL($b)~I_qoeDwqDYG(91@iegxfoeHMqknh1a9=1Z+^3PYQr1*uK3*_3839~+hx}Lg}O2; zl^%O)dH=w8rK63}?bFXZ3uIt$=&^r3SL*8E(C2l9bUQO<@U|kZc(_7QOka8JO{2q; zmEIognHsKMe#Nx}(7d^`7td_pfA<{-dwTXWPkVFiKi))tLt}Jz^KC%I_wG9^dGO&! zIc>q@>ObDxzpk%5eVPi=q(z_p=78p;qN`~929T;4a#G-0N#&L;cfskCkKR%3TVG~! zk72>sc=hg^YI_}3nY&F(&uk5zTT7EGpQ^Gzj4K9^S3Eg6Jkr7w3e#sW@5fyrPJDcB znw6Sb{KZ;}3U4tlRR;&h@BWKkk;c1)X3VND*a2sc+<5~RUX@M_z?qJkT=v(gEtixR z?_6nZgN%;RZ9n1c7bu4nFPplq=dGM^K6|t-fGx&qZH3 zpJU=zSN-vUM<1uJ;?%QX?krnLy9#fstro+TKpf)30KTpkT;7p)ab?I?09!G6Jfvd` zudsCIv1tAl+!E3QMi7sWZaJ5yM3|79+&EV5AJq8f`R7-ic*^@mYAlXs`NS9>|DIsL z-`2i<-5TbnJo1#hWLF5_6XB2DIknf(<%PSp?D&%EBX?=}c1%T$kG1T7WXEAAmHStX zT=hLh<{CS9&MYx-;fZ~8;Cf!~-6>VbjF0j7ngS>~Sd@35(-k_{H28)|D;x3siRfAdFMx8GF@)W-&yo@v=`*RD5wq-D3mN?Yw&9~_)~ z`XSw;-07v2g^OyVBkDGorrQ6N90XI(KVD^3Jccm6#n#cUqk@?s4g?yTzRLGK2m+>t zG%3X*XgWn~qkkb1aI+>zOSOUi>WWACx_#?QU(GvoYJ3EUNvQ=}w(Nd54x4;@8C@nv z0Qr!WCQ7+7 z)mb5W@z!USuQ~T~7k>MK^R`{G`1&h;bnTyRerox1vu4aV>adsYy2}o*`^lxh`u#Q6 z@rIpmUHp@8f9*3z9dXc=mwxMKzxYjO_q2Dt^Q}AYwEg(l_&2`uQ;w>lzrlM7`xxNJ zE`*{XrupK+4KC->`EzKOO58~K?@@@o${cz=LDyzGjJ;n7=fz5TjB z-TcazA9e8spWI=qxp&@s|Ki0<&U)Xed+)h)`7GS9rL!I{_by&-2W$5wRX(kre&AI$~`kDhsQ^M^X1w||HN%qv>f=lw%H4NPrq>F zrb}4S(skf#>pQ%3VsvWskJvR%U~*=fqzAJ8HbfK1bQtP>IA0G9XqetPtLLpB8GrKL z@!PK9asd}uBU52)wKjI$W$jB3YukRmS!aHI?5|hyneg_P{d>!t1=TfchX4EA$}>Lpg=(d}+*bMi zPp{Z^tHo!&|D7}^zd+-*>qkHFrJHWKooU}rrfzxeqk}KdrjT#**J!&tJL6QP!BI;q zmwuwHPSjWrW#e!B^KJX@xA#R~{bcZDd+E=A`>h{bR_bVDsPmqWe(tgvGY&p*-!Ghh zPJ!^$Q~!L&8K2nDw`uO2?haonYV@JgqYd?zOUS7^uNmi)b|;)M)ibaC(32Po;aHtq z2G)%I=BrZ=-dX9KR$Kf0*d-sYy#3SVt#|Br$NB9(POC#BqgP%yvFuj55$?(GO0!7< zg%uZet020O=`M#`(aRsJJ@xn2UH5O<|Ja^Gj-7hyq1xoc6nwauqw^gq>3lu$!0kgn z``omX&g$6fz}7tuq+xia=hT{2ec$`U)LNd`>#DbPvD!ta%4~(tKkmko<FX>1kLUFLLsV84b82s@BZMjKi+uDn_hpy zLHqC1+dFMyeB#NcpS|kZ8*jYzZpAN`q~!`DlQ^^}Q4P0}>Uw?7cb&Lo!ByH<>=J_?Rc+QzaIZ*8kSbo>Pq);=@2 z?3UU;|Dgrc)v3{Y|1{aZy88T+#J{7DLEomyYcFC2bZy=9v!d($Jff}RII(FUVQ%~OCx&O;bTWlrouXvRC%-Y&jnl52I#PvTN z>76t2*!@f~IO=FecPMT{Hn~@LL1W6bIi;%PgCLJsLLR-eA-UHduaPd7Q@Sh&f1gqS52)6dgr&chB!Fi z)omBez2~Oi&YC?_LkL@(j3OWUgX;*5(iJy6TljVmgRH;%? zE>MN&t4qn6=j5!&RkuWOCW#Ewm78w|(8N4Km4yWG9iZ;_Q zY!me?9gUGd z5|J83Y6wk?leXOPj$ArGtWvaT!hkcMmI2M`NKkN#T>!mp*b|gum8yeQfI_&j8zKy+ zwt(bJ-e5AlG$P!j1!9Z$5}#| z0LCu-lt>e{83uihgv8Hx%Q(|Ah3P2_^A%Myb}$CQ+jOE43JkN0KuZ) zn_71>J5=wdGHiI#5bl|Ev=m5OBr^rUT^V;%0KiBaXIxZH!0bf|_D{o{LPujZZ=_bF z6n$cp)yV1xH~Av4BYs4tqb6W>9)o?;`d|-*d`Ki06)I+;Bw8|!60CsQ@8L2@4Y=$H zn_Nvk$G0>Quc%cnaTL_R6zFImNHA0-2Dm;r86M)Y7hDZIT1pL*YoIo_gl}#@nsg9G zlAm&R7Z@2?=V{9rQisb2@n`onuREQhpo!YXu@si zBO1F<$^@gx1*Y_B2+JrwqzPTnsgDk@pF$tviS($$zYwOlZp3CYcL7|9njGkoV0koiD4lI)RglD$l;D#X~ zBe4n$p+i~NE4y3>+K>xxx2KWaLdr$TisiBtH>?n&B^6yPs5ZHj3sqj{>e`ZX zbdIUS+YHvT ziae#w)iYc@j(%WMD+>h{zn}^JxSX_lAxOP&@%<`_c zXF6%)ra`80veb;jUc6I6p0ooJKp9C@f@R?u8%ir=+#pC0ljlaFCYZ3Vv~m~4h>SPZvBQ6m%Qin-f7)^ zPds)1xt|<)ezhh)L+6HSh^u!mS~9kNZH8`r?J#obuLxeEnNI$Y7rs(*YHf$#Ut+VH20UHZDe?O>8H-qyoit`It^c(2iU&FJgx4b zmL_rvy)>s+tM2xZw;g)rS9@QwbnQJ)K6~Sx)%Mcf7yr+}7oXG7+e-xJMza#?Ahas+*c@pEGCCaj$Ld=3QP2TFZ4#8?zH_a1q77 zOsZ(Yg>cb=xeZKIOtEPZagfB8L3GGWTNv5fuqeEWW*r^vi{{V1|9&Qm-D3&OrUqHk z%fz8%FhPZ+vz|Vf{fYzHwwxD8)PN#&5-r7)#}NJ|pI^7`miv=7fs=X0Da?|Kb~IOU z(8TNPBaYnT;_t7%NfcJ-hDG|JYN{{`{9b zQ~S>gzqQXr-)fuDGqG`#pLVcSY94rsZH)*>!Q<6xaWPJUv>T6 zbym0YC{VpR=fJ%Vx%k|deB%Rmyz|p$1AfvBZn8tCX&Dt20+KKUt;?;W!{c5z$BU}C zFViwP$`UKiq1gl}k&+`&c`1~R#G*QtjVhpIv2k|E;w=qtq6`HC3XV7`HJ!qIEu#a% zC)&sa65H*!-CD=QX3527m>nytn}N7yVq(dMPTA@|P8j*;bF6QoXQt)3wJc`%&&F}v zm>{Ez9-o@vk*fK-?(q0mfAQqEe^c%V4TGmb#F{%MY;kC$`COPyO|{S6V%v|MxAJF~ z&pPtx1%JP5^&hV-o}|f`lFBc{!YGAd(mCx|Fv$8qy=bwko7d3-~6S_ zG`f;SYvYV%ghzo$C})6i-W^tHM`<1u=4H#3c9wN;1HZ~=B&1iwZ)`&?Bc#^Y>=$POzF){nq z2XA=j$u)nty)?a>4o@T+mf?^}|2+?`xcs^WC%$Bgv+iyQN>tzRQarD6lUj5~zAAd*t z^tRo9_{lwf_2rK3wjJ*q?A~$fMXx_f{kD>}GE{;?cp*_49UJ3gbB;Z(_khD!eDSlq z9$@9=m-IdP_>wcu(#b^_{%30rV#4?~^{IlUU#`{b-n2A_Xa=NZ#gb1p*`4wte^fqduvZwlrv|(%`H#`1glssc|pYzuLY@0PJjLn=-D|N3{GP6vQLUn1M zjbX3VNK3Yyzi8nCVW*?QaOERXl5PqTzt>&x>>ca`Y7Fq%XSeLM!?yFcn7xt}=FyXz zCUFwp=E+oYX^P*9?r?I;Z|2PIeRru%Rx2GXojYwk^T6FUEMA1o-TUn{(#NN`Yd!ny z(mrz<&vtFNy!xCu3r_#=b3gd@$m5Td zyE{h?oQvo!uALv$BPTe${C9s1E>s-T5Vma3{}ccP3^hc zE}g}!jXa!D619s6v-69qFwoR489CFy7smC{yt#9B+jVEHTy`>Ift`^9kV_z1gb1_( zQ!P}P8k-s&=BX&&F*Q{l)2iT!vC-k-@yT(XZ#jK@L*JTTU8)=STENgY`o!bU{`7})PWxaB&wHq|DZHmV?UAnY zrJ(9ivB02ZwOFHfR%x3ZSmnr?lyHPCa;uoYbX@itQt%Hp)#}Dao@(7`>sS#e87M9R z7E-Nt?YZNo<*TMxw{KXOA|0GEW}y*NQB#9@C{l|T__l-|%;4*-EPd(h;_1XT-|!Zz zlro{cCH)k-I&_lzT$EAHXrqF(JLG_U6AxJ{(!5Pz?WnQ@osyLr+agCi2`PBc@f>;R z0a_xHP}v#NQD+vh5s8WH>Wm$ee;jQSdc>UPI6D~;vV?bPY@|9qGRfjrRz$Q-ry+(X zCu_Xsx23hh3(JrOUgDI(J4`Jk$^%o?wyn15Ip+0iesrNe`yh>h)>$I5;n$bdT03VR zbG#?<3L<6D-mHwQ#s!cialKrxOz?nEONSO3Pfh5-21ki}Y`cS(vT)J(CZMr(8@0A= zy!MXnJ$CFpcz3ODk0c3Y#=@Q3EuQ)6Bi8)(&pc}B8VL0>CHxA85|DWFls-|y%6Sq9 zvQb%)F$a}G+{FZqIwQ$6@s5JX!eZwFN;QJJdV3BzXg`4iwIC?EA7u=+uFN)O{m4+K z+6XS=fn+=wzWk^|+NX8vNH%=}7rmm@$SAvC^^9JZ$dfhKMV&wUigZJDK%kY8%Hr0Xt zf$Oj8I_h|>ObRk~iC!b+CSF!l`f|NfWj3#c7ZkU4aIMThl<&d#lo2X4ZOw|92MLx?6yD(a_Y<|)r^s>;<8 z!xd3c3#-UzM*;*;WXd>s)sT3iaX{HZ=Df)>W@(SzciD5-9a&)*8YI;~Zq_)pj(CN1 zF=L3L2fQYE6#=iP(VO`BIC5*-BrlugouFOQ8J_DUE@PYc2w9z5==JuQuR46*Ij0Xi z^u*K#eV@@fBz|d}7^ACDl*y3ob{~1@9$u7T#j|SEwzLl3d*{^51r@%ONIzj!%}$pa z5lM!5V_Q&^{;czaQ~K}yTWw^Zwcaw-w|;D3bkI)AKG8mZj^3fcd9kg1`!AoxwCeJYeoZSj)#P@TNnjyL9i}i%@wu z)<5=e+Z*0ZUf1rR;?}XDN>}%sQ$93y&)+Bc7;5%a5P7Snh@uS_=Uf|(#b*&jdMfl) zM<2?xpFG1agA;yel!&Atj0pb>Aci82WReY$HUdHqGJIcm+_87xwM=hLX(We}SQ$+_ zF;3l(D21xFo17Sba>cV}eSK7Rv6I*5~#wl^eDUEr2)?9-kq)EL(5vPYKMZKSQ z;rs{(jMYv`qkVO9#>wxmKfh}IPrljv#&=E4+`4Vij(uM|d%x^Olpj`MWc-Tw;?mxxc0)nx~5>v@-)!7uj9Gh?mc$ptUa-2v~}U! zN^AS*iq(Wz_ujkJ21a>X#JpEYr zA+ISjY^t#|Sb>m&Vrsm7$GxZ4@x+1AXmId>f6O@Tr0J)f%q?!_ z%7>p_HMVlK7V?_V%(5aT^cctxyVS;NF@J6OQv;v+&zWyIqxW5(9OF2ylsl^91NYt5 z|MRcc`_}qxA<2${okGWoIUJ;TiuLfQ+BYFEadr6NhwZt?u7)IsTXd42oo;~ju&w`-!Yf($n#R2h>_-qKzjSYKMW z9n*eeW(O4;`*gtrL3W0E$WrbTbe`zHISpRA>;z=^U6lKZ4}YwPH;M^~JBR%M!+R?@^N`I~%*&_%Vt z1YET?I$q<#-izXd9VU^ZqveWD0u%?S87r;TRnKg?^o!Hpc1~;0{NbA~uWedg>F93V zZ>hQ@`Yk7NGdG>71k1NmqYK`~B zeazc2x*YOkO~)qB;ZiMGz(8Fg=0&BsrKOUP+A%Rk2zfz4D?hKK`Tfru9RY zoKIKTx^SmyANua-Gf#}(`6pHji^fa}^~}cHq>he6FbWlUjH&;sn}(M?swaW`0*2}) zUJ>t`cQlL|D-%gPmNFXA9zN`)XC$|jvs^W_@%B)?nn4c$Ss$UgHxp4qgH#ZXfG{iS z)Dnr9gLdje?|&yRoRByHm0~X}(Xx-!U@06wy_@4)Bn6(;PtBcKn%4slJ$mRX{%vrC zXOtT7%w7AeWy&{KDW8NCf-F;<5;r*xD||XF(2Kgtw3$4}*Mw`07391znDpU~>C7jl$&6*nA5Pk>-mdQp8MZrt*-g`l9V8hr?KgYX10*ur0 z;VE0Kg0?S6A*caF=kc*44nOd^-~PCRvwV!_Qnh)6XQEM)mNw8hdJTnJD=4I2HicZ^ zB~MtX*pxs1=?gyl`ETL{s~)sgSuPNZLV`I4(?F9>)+l-3jSEHxr=ZQs7$TwLqm>zRI+h;D17efUJu$WXG3mi= z9IF5prBDf0Opxl#;@zVfF$vx3D(~dL)AFMb z!9GQQ)Jkv0Qd=z2P^oV%osckuBM#=$WLU7&R`LSD3Z@DormL;>#;Y$m=)iqZRJzLV z<`;N?1SoVgpDZEuKx#WNPcpruxNuv!+}}TN#H-)(zyo^qa1v?lOdnK9LT14} z%S_D+UkeSH;cuffDPh@eC}ZbM(i6m{RA?JMl=A~2snEo>$O<7lC(##mQZokZ@KHC$ zF5Sv96=IBQx?qx;PfAJYs1{zdF*ihL3w_xG#OxR$D*+>AkGLRtcAJq?lbR{mUNfYL zu2gC2-0or~(yWj(=r5s$r%IJaMn3Z4_kQuyA5}XRS_xNy$~H%jcy5N^G9NV3M@AbJ zf~FKgk{Tj63s*w&ms{_6XN>#w?Gh8A!# zq_U@T=rm|-tUI9`SW)M9vu8VGWVDQ#N23K^_(FNPK)ZVV@WWpEnR7p=X?1CYOjTi_ ze@3z$a+(;s(l{!)brM+A1!E&7>bw(oB?&2#fg(d*u=g%u6f%-tCgWIoF?%rGHVMOq zn3G=+4!LffFb#@xt)=T^fFAzUPvQ4rvD z`Q43|kRVQ?Gdop;04h>~f_I&s{PzF4=9(Kdpw1Wxk#cf`auVl%N2pk-#8z47yw963 zqM1_`uSP4BGo`c_U9?8#m^j(98DEqVOF(GY%L^-bT?SKdydr~94&g%4-HDl*Irv;x z$1U9GiqKd}3$C|@yK~aF8R(34w@aUlu{@igoV}3jOpTS43kh+eEyuX{_8a5%}Q|-88EDS6y+~NQl>z*qA>zC2h_~TJ3~ZMJ z+;B)DMU9E^PyWvb&O7H!^B34WOhj##EsGOtcFlYmnCWej(kUvH`I_zKCG!NI6mCxLt> z(igb(DPUFs5H|!35#u=_(W>VFj{t&|L?#L0OqZL0jvo1w9~L8uOX;c7z=rbBM$Hf^ zi3!x^#ir2A2cJp>L`+PO0g8 z&->U12q4vP%w$q5%-NuI#K<|#$@T%ud-p&|FYr9h6rYHvKm%TIkvGhz_c(to@Yu+5)SPtVz2t&K|Hbl3FAw6IxTJ71O*DI?heH6!JYaKuS!ibNG>O+B_|@m z$w~vKBA+r*k)djejG6bTPEmziN|~FoW20LxUij^=f98Z&A1ws|1x65KkvL|Si8vw# z&m_)P&|EXJPhqNcdr2jcCO#Tn1B$AUlj_vn30KvH;5b;XxayA|{Mcund3r_j=}dS< zOIeGok_Bh=%ua#|kz(KQ0;OQXyKsH~nPCklCJltW+$R(2zAXAhS+n69e$RRpq|b#oV+Il20TjC1|5DDT|rFn6p^|EHt~_nl@t? zIlL^QpcNZ)4n6{+c;cH-*d-LWGaWiU{)$%|`He4sl20O8RMtm$;-{1h5TUd!Qh^|d z8Aqk*nrET|5J&}eHXJTyYKbW?BoUb~Q_u|0u6X|3PkrUmU;U07sJxvjYSfCtwWrL5 zlGOoT_%Cp^m6EP*fFy}YVZS6$uK{F06P2mq#YX^C5X5IjFd`$cd!Wc<61{s_B>q#C zsr+66b4yWWZG6xekxq_xDz1D&7wwWN6Y{^q(TuDHAq62v4q)d)GgVn;qbN|ovi6=x zG)vbA-!{QV3X^h62WvqBuAEY+68W^1@uDu5$HvBgb;UK`{Ps`o=8H@m4ejmBzevZd zXUaC1XGTegcAo8R(WivtNhs_TRBg}B+oG5l19Y=lmO@Dina*x9p$;np8y+=8yy%va zD-ob5lyjw;{36}WUHus!?&x1v=Q3Wskp7xYiBgajjRhZDFNR_#-IO!1sFW~O20|jq z6s~Jxlgeqw6C@Mk9X;L09ed=N?>+UfLk|R=CYnP?lVn+Adkk~4VSxPO6nUAH6{AW( zYLhZPg^3^nrCF;?Bh&vS>m;z%H~r|c-~9QuyM{Mz(vXE{ z@IsbEVCh*N6!_c@Pm{vM!XV($9H{}-v_iJU%0p@lZQv*Oh(OxGF_d&!F%iYQHI6xj z?1YRBftYLxH28(F#1PfIE_kS~J~pDIm028KC>keRC^eG7s1%h}O;GX8-pJVmP8?ku zC|S2mD0R3PqA#r17H&EJ#N&@W^^`a5_maH?mmK~QFLE}g!J()SGKp%nUq)!oQ!75k z$)bG<16gd$Dlo#XKnhlw5pHpV3y&IOLMh651*36IcoSsu)_Q@h5QDU2wL^pIJ)8Y1o7Z6p%w20tkjc8Yl_Cbd-~ zCH+gV7$ag38h+R;{%>*~Iq^3+_A(r~rPDxD|TM&x8Asz=cYLg*G3(SH^ z!3RiU0tXL$MAgI=Oq5K?8SsG!==P#T`82LR6{tu3GG^Ac0m(JZd(iQ?h`=vs147Yv zb`eKxx3~55^e&h;Yp3nDdC5MzA9TP=_TF>1dGqE3vvmGdL0KIz%BKI#c&9=kPRxxQ zP3f)ZqUfrGkn&6<`i@{Cv@cwhH<`7_GHF;C*HeL~=uSCBy0nd`1|C^xoR9Vill+X% zhQ5s}SFTyTX6>rg>)EdF>l+;Aq&Gf1qGu2J3KMsm)IiY7cV?b^VUdCa^Fl+T89TH} z=%HXnH@|e~h;6|$OUb|xT`(73LKcq0H3`xv&18$lE3K7(f>(^F9RIzk-H z4X%Q@8KXKieA3XLItWgp_aT+^F;v)!k;FPtsp#s)Cdx&9(s(ms^J&I&g!`UUj%aD! z&>Zf$psex|C?x})AnXi|owzy|n*y_eqFjSXGg{;~--Z?m7TgMiqPUp~t}d#iuzRRr zl#jN;v|whHg&|IJ)f`k?WX!L5BRS?K9P>}ynuw}B!Nm*kluTniz&I$M`9 zWpCl40=tmcfCwohBtdY5GMf=jmRV*PCN%Qt;0|y|e8EAL*o$R&!`8*ALQPX7yQ{zv zIGd1BVyev+Cr2nGHHjw+8Yy!ENL2w(x@s5NM9pTTW)2*t0T(%0cEKtnnQ7u2J9zr9 z&?K~iw9rC4vTA}lB__@JTm?)dS$-lZI8YrbCB^In)9T0r7{unRO-KUnpCDpgR3s8< zh%GWFp{TJ7WSGPcv>*49E=Aj3A7VcVIt`k5Gx6vKr@T1WRp@dFfkGbH(R1` z*>quoRni90(dI^stifz>lC)0Rx=vLBS+A@%KqukneBuoM@KQ#xw=CQwc(+MX3m{kCiUA97yVo`k|EvW{g0>Fj^gNA_36e|RW5?OY_c5TtL0kk^S zxX{TSm!@usf~B=j(i_0w(j>@=HZg3{v|07g1-oQa_L#EGtg}jlfLbmqCM=;vq>ynk zA>5kxGJ#6lA|wZdXq#D?+Lg|^7B1RD9L2yILve2A0tBfbL zt(G%Jj!mM^3cm;lKsJT~df%0kl(ZbwWsmQo)sy7_!;IKwHmci0pB-WD62ogH&rImE>ZlfGK)N_P$lm(t+~>shMpY{n1p>9#Lx^W@A=TCe%t5 z8OyQ@^+Je^*UPQqKe-1!#%^A&cB>@`}VTYjdZGk?=xZ$PK2dP%S({dvP>E z;tPopLS$0K6bTLF%)#KiDZ0j@w#a}Ic`^lmvN^vQF!L++klK!7SJWuesEq>KY|#s= z1GON?8SR_%B1@_8A?1dDR4_tPDRAz)0U9UK5Vpg z=n&E)k6u0=8a&AH4W4)|EO<5Q!qgXNZzORmqAF*SD*x}hW=TQyV#vSZwwd%71w+wP zMdts)yAW6)H#3K@sj;(zH&-bLo6t6|Zt`Ivs~Mw^8^9Ofyr8I1cu{lwD_FAxlai#EaP#b9?Q=J*0$LJfUhL}WP7tX0zX|BU}44pRQb%wG&KVX`#D*(Zi< z&Q!EgDXRVj!sh5rr5p>|+yAPQyEbFetXp%wi4j!MWtqJ6{{ifZ%UtBEZ*2eo002ov JPDHLkV1nFT_Im&T literal 0 HcmV?d00001 diff --git a/Android/app/src/main/res/values/strings.xml b/Android/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..38352cf --- /dev/null +++ b/Android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Camper Monitor + diff --git a/Android/app/src/main/res/values/themes.xml b/Android/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4db0596 --- /dev/null +++ b/Android/app/src/main/res/values/themes.xml @@ -0,0 +1,4 @@ + + +