@@ -15,6 +15,22 @@ import UserNotifications |
||
| 15 | 15 |
//let btSerial = BluetoothSerial(delegate: BSD()) |
| 16 | 16 |
let appData = AppData() |
| 17 | 17 |
private let restoreLogger = Logger(subsystem: "ro.xdev.USB-Meter", category: "Restore") |
| 18 |
+private enum DebugLogFlag: String {
|
|
| 19 |
+ case all = "USB_METER_DEBUG_LOGS" |
|
| 20 |
+ case bluetooth = "USB_METER_BLUETOOTH_LOGS" |
|
| 21 |
+ case cloud = "USB_METER_CLOUD_LOGS" |
|
| 22 |
+ case meter = "USB_METER_METER_LOGS" |
|
| 23 |
+ case migration = "USB_METER_MIGRATION_LOGS" |
|
| 24 |
+ case notifications = "USB_METER_NOTIFICATION_LOGS" |
|
| 25 |
+ case restore = "USB_METER_RESTORE_LOGS" |
|
| 26 |
+ case sync = "USB_METER_SYNC_LOGS" |
|
| 27 |
+} |
|
| 28 |
+ |
|
| 29 |
+public func debugLogFlagEnabled(_ flag: String) -> Bool {
|
|
| 30 |
+ ProcessInfo.processInfo.environment[DebugLogFlag.all.rawValue] == "1" || |
|
| 31 |
+ ProcessInfo.processInfo.environment[flag] == "1" |
|
| 32 |
+} |
|
| 33 |
+ |
|
| 18 | 34 |
enum Constants {
|
| 19 | 35 |
static let chartUnderscan: CGFloat = 0.5 |
| 20 | 36 |
static let chartOverscan: CGFloat = 1 - chartUnderscan |
@@ -35,16 +51,16 @@ public func track(_ message: String = "", file: String = #file, function: String |
||
| 35 | 51 |
} |
| 36 | 52 |
|
| 37 | 53 |
public func restoreTrace(_ message: String) {
|
| 54 |
+ guard debugLogFlagEnabled(DebugLogFlag.restore.rawValue) else { return }
|
|
| 38 | 55 |
restoreLogger.debug("\(message, privacy: .public)")
|
| 39 | 56 |
} |
| 40 | 57 |
|
| 41 | 58 |
private func shouldEmitTrackMessage(_ message: String, file: String, function: String) -> Bool {
|
| 42 | 59 |
#if DEBUG |
| 43 |
- if ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" {
|
|
| 60 |
+ if debugLogFlagEnabled(DebugLogFlag.all.rawValue) {
|
|
| 44 | 61 |
return true |
| 45 | 62 |
} |
| 46 | 63 |
|
| 47 |
- #if targetEnvironment(macCatalyst) |
|
| 48 | 64 |
let importantMarkers = [ |
| 49 | 65 |
"Error", |
| 50 | 66 |
"error", |
@@ -63,69 +79,85 @@ private func shouldEmitTrackMessage(_ message: String, file: String, function: S |
||
| 63 | 79 |
"not supported", |
| 64 | 80 |
"Unexpected", |
| 65 | 81 |
"Invalid Context", |
| 66 |
- "ignored", |
|
| 67 |
- "Guard:", |
|
| 68 |
- "Skip data request", |
|
| 69 |
- "Dropping unsolicited data", |
|
| 70 | 82 |
"This is not possible!", |
| 71 |
- "Inferred", |
|
| 72 |
- "Clearing", |
|
| 73 |
- "Reconnecting" |
|
| 83 |
+ "Buffer overflow" |
|
| 74 | 84 |
] |
| 75 | 85 |
|
| 76 | 86 |
if importantMarkers.contains(where: { message.contains($0) }) {
|
| 77 | 87 |
return true |
| 78 | 88 |
} |
| 79 | 89 |
|
| 80 |
- let noisyFunctions: Set<String> = [ |
|
| 81 |
- "logRuntimeICloudDiagnostics()", |
|
| 82 |
- "refreshCloudAvailability(reason:)", |
|
| 83 |
- "start()", |
|
| 84 |
- "centralManagerDidUpdateState(_:)", |
|
| 85 |
- "discoveredMeter(peripheral:advertising:rssi:)", |
|
| 86 |
- "connect()", |
|
| 87 |
- "connectionEstablished()", |
|
| 88 |
- "peripheral(_:didDiscoverServices:)", |
|
| 89 |
- "peripheral(_:didDiscoverCharacteristicsFor:error:)", |
|
| 90 |
- "refreshOperationalStateIfReady()", |
|
| 91 |
- "peripheral(_:didUpdateNotificationStateFor:error:)", |
|
| 92 |
- "scheduleDataDumpRequest(after:reason:)" |
|
| 93 |
- ] |
|
| 94 |
- |
|
| 95 |
- if noisyFunctions.contains(function) {
|
|
| 96 |
- return false |
|
| 97 |
- } |
|
| 98 |
- |
|
| 99 |
- let noisyMarkers = [ |
|
| 100 |
- "Runtime iCloud diagnostics", |
|
| 101 |
- "iCloud availability", |
|
| 102 |
- "Starting Bluetooth manager", |
|
| 103 |
- "Bluetooth is On... Start scanning...", |
|
| 104 |
- "adding new USB Meter", |
|
| 105 |
- "Connect called for", |
|
| 106 |
- "Connection established for", |
|
| 107 |
- "Optional([<CBService:", |
|
| 108 |
- "Optional([<CBCharacteristic:", |
|
| 109 |
- "Waiting for notifications on", |
|
| 110 |
- "Notification state updated for", |
|
| 111 |
- "Peripheral ready with notify", |
|
| 112 |
- "Schedule data request in", |
|
| 113 |
- "Operational state changed" |
|
| 114 |
- ] |
|
| 115 |
- |
|
| 116 |
- if noisyMarkers.contains(where: { message.contains($0) }) {
|
|
| 117 |
- return false |
|
| 90 |
+ if isTrackMessageEnabledByCategory(message, file: file, function: function) {
|
|
| 91 |
+ return true |
|
| 118 | 92 |
} |
| 119 |
- #endif |
|
| 120 | 93 |
|
| 121 |
- return true |
|
| 94 |
+ return false |
|
| 122 | 95 |
#else |
| 96 |
+ _ = message |
|
| 123 | 97 |
_ = file |
| 124 | 98 |
_ = function |
| 125 | 99 |
return false |
| 126 | 100 |
#endif |
| 127 | 101 |
} |
| 128 | 102 |
|
| 103 |
+private func isTrackMessageEnabledByCategory(_ message: String, file: String, function: String) -> Bool {
|
|
| 104 |
+ let categories = debugLogCategories(for: message, file: file, function: function) |
|
| 105 |
+ return categories.contains { debugLogFlagEnabled($0.rawValue) }
|
|
| 106 |
+} |
|
| 107 |
+ |
|
| 108 |
+private func debugLogCategories(for message: String, file: String, function: String) -> [DebugLogFlag] {
|
|
| 109 |
+ var categories = [DebugLogFlag]() |
|
| 110 |
+ |
|
| 111 |
+ if file.contains("Bluetooth") ||
|
|
| 112 |
+ function.contains("centralManager") ||
|
|
| 113 |
+ function.contains("peripheral(") ||
|
|
| 114 |
+ message.contains("Bluetooth") ||
|
|
| 115 |
+ message.contains("BLE discovery") ||
|
|
| 116 |
+ message.contains("peripheral") ||
|
|
| 117 |
+ message.contains("characteristic") ||
|
|
| 118 |
+ message.contains("service") {
|
|
| 119 |
+ categories.append(.bluetooth) |
|
| 120 |
+ } |
|
| 121 |
+ |
|
| 122 |
+ if file.contains("Meter.swift") ||
|
|
| 123 |
+ message.contains("data request") ||
|
|
| 124 |
+ message.contains("Operational state") ||
|
|
| 125 |
+ message.contains("recordingThreshold") ||
|
|
| 126 |
+ message.contains("screenTimeout") ||
|
|
| 127 |
+ message.contains("screenBrightness") ||
|
|
| 128 |
+ message.contains("volatile memory") ||
|
|
| 129 |
+ message.contains("charger type") {
|
|
| 130 |
+ categories.append(.meter) |
|
| 131 |
+ } |
|
| 132 |
+ |
|
| 133 |
+ if message.contains("CloudKit") ||
|
|
| 134 |
+ message.contains("iCloud") ||
|
|
| 135 |
+ message.contains("ubiquityIdentityToken") {
|
|
| 136 |
+ categories.append(.cloud) |
|
| 137 |
+ } |
|
| 138 |
+ |
|
| 139 |
+ if file.contains("MeterNameStore") ||
|
|
| 140 |
+ file.contains("ChargerStandbyPowerStore") ||
|
|
| 141 |
+ message.contains("KVS") ||
|
|
| 142 |
+ message.contains("ubiquitous") {
|
|
| 143 |
+ categories.append(.sync) |
|
| 144 |
+ } |
|
| 145 |
+ |
|
| 146 |
+ if file.contains("ChargeInsightsStore") ||
|
|
| 147 |
+ message.contains("promoted legacy") ||
|
|
| 148 |
+ message.contains("synthesized custom") ||
|
|
| 149 |
+ message.contains("healed duplicate") {
|
|
| 150 |
+ categories.append(.migration) |
|
| 151 |
+ } |
|
| 152 |
+ |
|
| 153 |
+ if message.contains("notification") ||
|
|
| 154 |
+ message.contains("remote notifications") {
|
|
| 155 |
+ categories.append(.notifications) |
|
| 156 |
+ } |
|
| 157 |
+ |
|
| 158 |
+ return categories |
|
| 159 |
+} |
|
| 160 |
+ |
|
| 129 | 161 |
@UIApplicationMain |
| 130 | 162 |
class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterDelegate {
|
| 131 | 163 |
private let cloudKitContainerIdentifier = "iCloud.ro.xdev.USB-Meter" |
@@ -152,6 +184,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate, UNUserNotificationCenterD |
||
| 152 | 184 |
|
| 153 | 185 |
private func logRuntimeICloudDiagnostics() {
|
| 154 | 186 |
#if DEBUG |
| 187 |
+ guard debugLogFlagEnabled(DebugLogFlag.cloud.rawValue) else { return }
|
|
| 155 | 188 |
let hasUbiquityIdentityToken = FileManager.default.ubiquityIdentityToken != nil |
| 156 | 189 |
track("Runtime iCloud diagnostics: ubiquityIdentityTokenAvailable=\(hasUbiquityIdentityToken)")
|
| 157 | 190 |
CKContainer(identifier: cloudKitContainerIdentifier).accountStatus { status, error in
|
@@ -23,7 +23,6 @@ extension Data {
|
||
| 23 | 23 |
|
| 24 | 24 |
func value<T>(from: Int) -> T {
|
| 25 | 25 |
let to = from + MemoryLayout<T>.size |
| 26 |
- //track("size: \(self.count) from:\(from) to:\(to)")
|
|
| 27 | 26 |
return self.subdata(in: from..<to).withUnsafeBytes { $0.load(as: T.self) }
|
| 28 | 27 |
} |
| 29 | 28 |
|
@@ -92,7 +92,6 @@ class BluetoothManager : NSObject, ObservableObject {
|
||
| 92 | 92 |
|
| 93 | 93 |
if appData.meters[peripheral.identifier] == nil {
|
| 94 | 94 |
logDiscovery("BLE discovery accepted: model='\(model.canonicalName)', radio='\(radio)', advertisedName='\(advertisedName)', match='\(match.reason)', macAddress='\(macAddressString)'. \(discoveryDescription(peripheral: peripheral, advertising: advertismentData, rssi: RSSI))")
|
| 95 |
- track("adding new USB Meter named '\(advertisedName)' with MAC Address: '\(macAddress)'")
|
|
| 96 | 95 |
let btSerial = BluetoothSerial(peripheral: peripheral, radio: radio, with: macAddress, managedBy: manager!, RSSI: RSSI.intValue) |
| 97 | 96 |
var m = appData.meters |
| 98 | 97 |
let meter = Meter(model: model, with: btSerial) |
@@ -158,11 +157,12 @@ class BluetoothManager : NSObject, ObservableObject {
|
||
| 158 | 157 |
|
| 159 | 158 |
private func logDiscovery(_ message: String) {
|
| 160 | 159 |
track(message) |
| 161 |
- bluetoothDiscoveryLogger.notice("\(message, privacy: .public)")
|
|
| 160 |
+ guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else { return }
|
|
| 161 |
+ bluetoothDiscoveryLogger.debug("\(message, privacy: .public)")
|
|
| 162 | 162 |
} |
| 163 |
- |
|
| 163 |
+ |
|
| 164 | 164 |
private func shouldLogDiscoveryDetails(for identifier: UUID) -> Bool {
|
| 165 |
- guard ProcessInfo.processInfo.environment["USB_METER_VERBOSE_LOGS"] == "1" else {
|
|
| 165 |
+ guard debugLogFlagEnabled("USB_METER_BLUETOOTH_LOGS") else {
|
|
| 166 | 166 |
return false |
| 167 | 167 |
} |
| 168 | 168 |
return shouldLogDiscoveryDetails(for: identifier.uuidString) |
@@ -228,7 +228,6 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 228 | 228 |
// MARK: CBCentralManager state Changed |
| 229 | 229 |
func centralManagerDidUpdateState(_ central: CBCentralManager) {
|
| 230 | 230 |
managerState = central.state; |
| 231 |
- track("\(central.state)")
|
|
| 232 | 231 |
for meter in appData.meters.values {
|
| 233 | 232 |
meter.btSerial.centralStateChanged(to: central.state) |
| 234 | 233 |
} |
@@ -237,10 +236,8 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 237 | 236 |
case .poweredOff: |
| 238 | 237 |
scanStartedAt = nil |
| 239 | 238 |
advertisementDataCache.clear() |
| 240 |
- track("Bluetooth is Off. How should I behave?")
|
|
| 241 | 239 |
case .poweredOn: |
| 242 | 240 |
scanStartedAt = Date() |
| 243 |
- track("Bluetooth is On... Start scanning...")
|
|
| 244 | 241 |
// note that "didDisconnectPeripheral" won't be called if BLE is turned off while connected |
| 245 | 242 |
// connectedPeripheral = nil |
| 246 | 243 |
// pendingPeripheral = nil |
@@ -248,7 +245,7 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 248 | 245 |
case .resetting: |
| 249 | 246 |
scanStartedAt = nil |
| 250 | 247 |
advertisementDataCache.clear() |
| 251 |
- track("Bluetooth is reseting... . Whatever that means.")
|
|
| 248 |
+ track("Bluetooth is resetting.")
|
|
| 252 | 249 |
case .unauthorized: |
| 253 | 250 |
scanStartedAt = nil |
| 254 | 251 |
advertisementDataCache.clear() |
@@ -271,13 +268,11 @@ extension BluetoothManager : CBCentralManagerDelegate {
|
||
| 271 | 268 |
// MARK: CBCentralManager didDiscover peripheral |
| 272 | 269 |
func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral, advertisementData: [String : Any], rssi RSSI: NSNumber) {
|
| 273 | 270 |
let completeAdvertisementData = self.advertisementDataCache.append(peripheral: peripheral, advertisementData: advertisementData) |
| 274 |
- //track("Device discoverded UUID: '\(peripheral.identifier)' named '\(peripheral.name ?? "Unknown")'); RSSI: \(RSSI) dBm; Advertisment data: \(advertisementData)")
|
|
| 275 | 271 |
discoveredMeter(peripheral: peripheral, advertising: completeAdvertisementData, rssi: RSSI ) |
| 276 | 272 |
} |
| 277 | 273 |
|
| 278 | 274 |
// MARK: CBCentralManager didConnect peripheral |
| 279 | 275 |
internal func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
|
| 280 |
- //track("Connected to peripheral: '\(peripheral.identifier)'")
|
|
| 281 | 276 |
if let usbMeter = appData.meters[peripheral.identifier] {
|
| 282 | 277 |
usbMeter.btSerial.connectionEstablished() |
| 283 | 278 |
} |
@@ -155,20 +155,17 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 155 | 155 |
- parameter expectedResponseLength: Optional If message sent require a respnse the length for that response must be provideed. Incomming data will be buffered before calling delegate.didReceiveData |
| 156 | 156 |
*/ |
| 157 | 157 |
func write(_ data: Data, expectedResponseLength: Int = 0) {
|
| 158 |
- //track("\(self.expectedResponseLength)")
|
|
| 159 |
- //track(data.hexEncodedStringValue) |
|
| 160 | 158 |
guard operationalState == .peripheralReady else {
|
| 161 |
- track("Guard: \(operationalState)")
|
|
| 159 |
+ track("Write skipped while peripheral state is \(operationalState)")
|
|
| 162 | 160 |
return |
| 163 | 161 |
} |
| 164 | 162 |
guard self.expectedResponseLength == 0 else {
|
| 165 |
- track("Guard: \(self.expectedResponseLength)")
|
|
| 163 |
+ track("Write skipped while waiting for \(self.expectedResponseLength) response bytes")
|
|
| 166 | 164 |
return |
| 167 | 165 |
} |
| 168 | 166 |
|
| 169 | 167 |
self.expectedResponseLength = expectedResponseLength |
| 170 | 168 |
|
| 171 |
-// track("Sending...")
|
|
| 172 | 169 |
guard let writeCharacteristic else {
|
| 173 | 170 |
track("Missing write characteristic for \(radio)")
|
| 174 | 171 |
self.expectedResponseLength = 0 |
@@ -177,7 +174,6 @@ final class BluetoothSerial : NSObject, ObservableObject {
|
||
| 177 | 174 |
|
| 178 | 175 |
let writeType: CBCharacteristicWriteType = writeCharacteristic.properties.contains(.writeWithoutResponse) ? .withoutResponse : .withResponse |
| 179 | 176 |
peripheral.writeValue(data, for: writeCharacteristic, type: writeType) |
| 180 |
-// track("Sent!")
|
|
| 181 | 177 |
if self.expectedResponseLength != 0 {
|
| 182 | 178 |
setWDT() |
| 183 | 179 |
} |
@@ -299,7 +295,6 @@ extension BluetoothSerial : CBPeripheralDelegate {
|
||
| 299 | 295 |
|
| 300 | 296 |
// MARK: didDiscoverServices |
| 301 | 297 |
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
|
| 302 |
- track("\(String(describing: peripheral.services))")
|
|
| 303 | 298 |
if error != nil {
|
| 304 | 299 |
track( "Error: \(error!)" ) |
| 305 | 300 |
} |
@@ -334,7 +329,6 @@ extension BluetoothSerial : CBPeripheralDelegate {
|
||
| 334 | 329 |
if error != nil {
|
| 335 | 330 |
track( "Error: \(error!)" ) |
| 336 | 331 |
} |
| 337 |
- track("\(String(describing: service.characteristics))")
|
|
| 338 | 332 |
switch radio {
|
| 339 | 333 |
case .BT18, .BT24M: |
| 340 | 334 |
updateBT18Characteristics(for: service) |
@@ -357,7 +351,6 @@ extension BluetoothSerial : CBPeripheralDelegate {
|
||
| 357 | 351 |
|
| 358 | 352 |
// MARK: didUpdateValueFor |
| 359 | 353 |
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
|
| 360 |
-// track("")
|
|
| 361 | 354 |
if error != nil {
|
| 362 | 355 |
track( "Error: \(error!)" ) |
| 363 | 356 |
} |
@@ -378,14 +371,11 @@ extension BluetoothSerial : CBPeripheralDelegate {
|
||
| 378 | 371 |
|
| 379 | 372 |
let previousBufferCount = buffer.count |
| 380 | 373 |
buffer.append(incomingData) |
| 381 |
-// track("\n\(buffer.hexEncodedStringValue)")
|
|
| 382 | 374 |
switch buffer.count {
|
| 383 | 375 |
case let x where x < expectedResponseLength: |
| 384 | 376 |
setWDT() |
| 385 |
- //track("buffering")
|
|
| 386 | 377 |
break; |
| 387 | 378 |
case let x where x == expectedResponseLength: |
| 388 |
- //track("buffer ready")
|
|
| 389 | 379 |
wdTimer?.invalidate() |
| 390 | 380 |
expectedResponseLength = 0 |
| 391 | 381 |
delegate?.didReceiveData(buffer) |
@@ -405,7 +395,6 @@ extension BluetoothSerial : CBPeripheralDelegate {
|
||
| 405 | 395 |
} |
| 406 | 396 |
|
| 407 | 397 |
func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
|
| 408 |
- //track("")
|
|
| 409 | 398 |
} |
| 410 | 399 |
|
| 411 | 400 |
} |
@@ -778,7 +778,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 778 | 778 |
btSerial.write(TC66Protocol.snapshotRequest, expectedResponseLength: 192) |
| 779 | 779 |
} |
| 780 | 780 |
dataDumpRequestTimestamp = Date() |
| 781 |
- // track("\(name) - Request sent!")
|
|
| 782 | 781 |
} else {
|
| 783 | 782 |
track("Request delayed for: \(commandQueue.first!.hexEncodedStringValue)")
|
| 784 | 783 |
btSerial.write( commandQueue.first! ) |
@@ -793,7 +792,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 793 | 792 |
- Decription metod for TC66C AES ECB response found [here](https:github.com/krzyzanowskim/CryptoSwift/issues/693) |
| 794 | 793 |
*/ |
| 795 | 794 |
func parseData ( from buffer: Data) {
|
| 796 |
- //track("\(name)")
|
|
| 797 | 795 |
liveDataChanged = false |
| 798 | 796 |
switch model {
|
| 799 | 797 |
case .UM25C: |
@@ -825,9 +823,6 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 825 | 823 |
) |
| 826 | 824 |
} |
| 827 | 825 |
appData.observeChargeSnapshot(from: self, observedAt: dataDumpRequestTimestamp) |
| 828 |
-// DispatchQueue.global(qos: .userInitiated).asyncAfter( deadline: .now() + 0.33 ) {
|
|
| 829 |
-// //track("\(name) - Scheduled new request.")
|
|
| 830 |
-// } |
|
| 831 | 826 |
if operationalState != .dataIsAvailable {
|
| 832 | 827 |
operationalState = .dataIsAvailable |
| 833 | 828 |
} else if liveDataChanged {
|
@@ -1187,19 +1182,16 @@ class Meter : NSObject, ObservableObject, Identifiable {
|
||
| 1187 | 1182 |
|
| 1188 | 1183 |
func selectDataGroup ( id: UInt8) {
|
| 1189 | 1184 |
guard supportsDataGroupCommands else { return }
|
| 1190 |
- track("\(name) - \(id)")
|
|
| 1191 | 1185 |
selectedDataGroup = id |
| 1192 | 1186 |
objectWillChange.send() |
| 1193 | 1187 |
commandQueue.append(UMProtocol.selectDataGroup(selectedDataGroup)) |
| 1194 | 1188 |
} |
| 1195 | 1189 |
|
| 1196 | 1190 |
private func setSceeenBrightness ( to value: UInt8) {
|
| 1197 |
- track("\(name) - \(value)")
|
|
| 1198 | 1191 |
guard supportsUMSettings else { return }
|
| 1199 | 1192 |
commandQueue.append(UMProtocol.setScreenBrightness(value)) |
| 1200 | 1193 |
} |
| 1201 | 1194 |
private func setScreenSaverTimeout ( to value: UInt8) {
|
| 1202 |
- track("\(name) - \(value)")
|
|
| 1203 | 1195 |
guard supportsUMSettings else { return }
|
| 1204 | 1196 |
commandQueue.append(UMProtocol.setScreenSaverTimeout(value)) |
| 1205 | 1197 |
} |