|
Bogdan Timofte
authored
2 weeks ago
|
1
|
# CloudKit Sync Schema
|
|
|
2
|
|
|
|
3
|
## Overview
|
|
|
4
|
USB Meter utilizează `NSPersistentCloudKitContainer` pentru a sincroniza setările dispozitivelor Bluetooth pe mai multe device-uri iOS/iPadOS via iCloud CloudKit.
|
|
|
5
|
|
|
|
6
|
**Container CloudKit:** `iCloud.ro.xdev.USB-Meter`
|
|
|
7
|
|
|
|
8
|
---
|
|
|
9
|
|
|
|
10
|
## Core Data Entity: DeviceSettings
|
|
|
11
|
|
|
|
12
|
### Purpose
|
|
|
13
|
Stocare centralizată a setărilor și metadatelor de conexiune pentru fiecare contor Bluetooth, cu sincronizare automată pe toate device-urile utilizatorului.
|
|
|
14
|
|
|
|
15
|
### Device Name Source
|
|
Bogdan Timofte
authored
a week ago
|
16
|
- **All platforms (iOS, iPadOS, macOS Catalyst, iPad on Mac):** `ProcessInfo.processInfo.hostName`
|
|
|
17
|
- Examples: "iPhone-User", "MacBook-Pro.local", "iPad-WiFi"
|
|
|
18
|
- Rationale: Platform-agnostic, avoids device model names ("iPad", "iPhone" are useless)
|
|
Bogdan Timofte
authored
2 weeks ago
|
19
|
|
|
|
20
|
### Attributes
|
|
|
21
|
|
|
|
22
|
| Atribut | Tip | Optional | Descriere |
|
|
|
23
|
|---------|-----|----------|-----------|
|
|
|
24
|
| **macAddress** | String | YES | Adresa MAC unică a contorului BT (cheie candidat pentru deduplicare) |
|
|
|
25
|
| **meterName** | String | YES | Renumire personalizată a contorului (ex: "Kitchen Meter") |
|
|
|
26
|
| **tc66TemperatureUnit** | String | YES | Preferință unitate temperatură pentru TC66C (raw value enum: "celsius"/"fahrenheit") |
|
|
|
27
|
| **modelType** | String | YES | Tip model contor ("UM25C", "UM34C", "TC66C") |
|
|
|
28
|
| **connectedByDeviceID** | String | YES | UUID-ul device-ului iOS care l-a conectat ultima dată |
|
|
|
29
|
| **connectedByDeviceName** | String | YES | Numele device-ului iOS care l-a conectat |
|
|
|
30
|
| **connectedAt** | Date | YES | Timestamp al celei mai recente conexiuni |
|
|
|
31
|
| **connectedExpiryAt** | Date | YES | TTL pentru lock-ul de conexiune (alt device poate prelua dacă a expirat) |
|
|
|
32
|
| **lastSeenAt** | Date | YES | Timestamp al ultimei avertisment BT din device |
|
|
|
33
|
| **lastSeenByDeviceID** | String | YES | UUID device-ul iOS care l-a văzut ultima dată |
|
|
|
34
|
| **lastSeenByDeviceName** | String | YES | Nume device-ul iOS care l-a văzut |
|
|
|
35
|
| **lastSeenPeripheralName** | String | YES | Numele periferalei așa cum raporta dispozitivul |
|
|
|
36
|
| **updatedAt** | Date | YES | Timestamp al ultimei actualizări (audit trail) |
|
|
|
37
|
|
|
|
38
|
### CloudKit Mapping
|
|
|
39
|
Fiecare atribut Core Data → câmp CloudKit în record-ul `DeviceSettings`:
|
|
|
40
|
- Sincronizare automată bidirecțională
|
|
|
41
|
- Lightweight migration pentru schimbări de schemă
|
|
|
42
|
- Conflict resolution: **NSMergeByPropertyStoreTrumpMergePolicy** (CloudKit wins pe changed properties)
|
|
|
43
|
|
|
|
44
|
---
|
|
|
45
|
|
|
|
46
|
## Constraints
|
|
|
47
|
|
|
|
48
|
### ❌ **NOT** Used (v1 - v2):
|
|
|
49
|
- `uniquenessConstraint` pe `macAddress` — **incompatibil cu NSPersistentCloudKitContainer** (genereaza erori silentioase)
|
|
|
50
|
|
|
|
51
|
### ✅ Applied:
|
|
|
52
|
- **Logical deduplication** per macAddress în `rebuildCanonicalStoreIfNeeded`
|
|
|
53
|
- Ruleaza o dată pe device (verifică `UserDefaults` cu key `"cloudStoreRebuildVersion.{n}"`)
|
|
|
54
|
- Merges conflicting records pe baza timestamp-urilor
|
|
|
55
|
|
|
|
56
|
---
|
|
|
57
|
|
|
|
58
|
## Data Flow
|
|
|
59
|
|
|
|
60
|
```
|
|
|
61
|
┌─────────────────────────────────────────────────────────────────┐
|
|
|
62
|
│ Device A (iPhone) │
|
|
|
63
|
├─────────────────────────────────────────────────────────────────┤
|
|
|
64
|
│ User connects UM25C (MAC: aa:bb:cc:dd:ee:ff) │
|
|
|
65
|
│ ↓ │
|
|
|
66
|
│ Meter.swift → appData.publishMeterConnection(mac, type) │
|
|
|
67
|
│ ↓ │
|
|
|
68
|
│ CloudDeviceSettingsStore.setConnection() │
|
|
|
69
|
│ - Creates / Updates DeviceSettings record │
|
|
|
70
|
│ - Sets connectedByDeviceID = Device A UUID │
|
|
|
71
|
│ - connectedAt = now │
|
|
|
72
|
│ - connectedExpiryAt = now + 120s │
|
|
|
73
|
│ ↓ │
|
|
|
74
|
│ NSPersistentCloudKitContainer saves to Core Data │
|
|
|
75
|
│ ↓ │
|
|
|
76
|
│ CloudKit framework (automatic sync): │
|
|
|
77
|
│ - Pushes record modificare → iCloud server │
|
|
|
78
|
│ - Records: [CKRecord with RecordID "DeviceSettings/mac"] │
|
|
|
79
|
│ ↓ │
|
|
|
80
|
│ iCloud Server → Device B (iPad) │
|
|
|
81
|
│ ↓ │
|
|
|
82
|
│ Device B receives remote change notification │
|
|
|
83
|
│ (NSPersistentStoreRemoteChangeNotificationPostOptionKey) │
|
|
|
84
|
│ ↓ │
|
|
|
85
|
│ AppData.reloadSettingsFromCloudStore() │
|
|
|
86
|
│ - Fetches updated record din local Core Data │
|
|
|
87
|
│ - Sees connectedByDeviceID = Device A UUID │
|
|
|
88
|
│ - Checks connectedExpiryAt (not expired) │
|
|
|
89
|
│ - Knows someone else is connected │
|
|
|
90
|
│ ↓ │
|
|
|
91
|
│ UI updates: shows "Connected on iPhone" │
|
|
|
92
|
└─────────────────────────────────────────────────────────────────┘
|
|
|
93
|
```
|
|
|
94
|
|
|
|
95
|
---
|
|
|
96
|
|
|
|
97
|
## Conflict Resolution Scenarios
|
|
|
98
|
|
|
|
99
|
### Scenario 1: Simultaneous Connection Attempt
|
|
|
100
|
**Device A** și **Device B** conectează același contor ~același timp.
|
|
|
101
|
|
|
|
102
|
```
|
|
|
103
|
Device A: Device B:
|
|
|
104
|
t=0.0s: setConnection() t=0.1s: setConnection()
|
|
|
105
|
- connectedByDeviceID = A - connectedByDeviceID = B
|
|
|
106
|
- connectedAt = 0.0s - connectedAt = 0.1s
|
|
|
107
|
↓ save ↓ save
|
|
|
108
|
CloudKit pushes A's record CloudKit pushes B's record
|
|
|
109
|
↓ ↓
|
|
|
110
|
└──────→ iCloud Server ←──────┘
|
|
|
111
|
(concurrent writes)
|
|
|
112
|
|
|
|
113
|
Merge Strategy:
|
|
|
114
|
- NSMergeByPropertyStoreTrumpMergePolicy
|
|
|
115
|
- Latest CloudKit record "wins" per property
|
|
|
116
|
- Result: B's connectedByDeviceID + connectedAt win
|
|
|
117
|
(mai recent timestamp)
|
|
|
118
|
```
|
|
|
119
|
|
|
|
120
|
### Scenario 2: Reconnection After Expiry
|
|
|
121
|
**Device A** conectează la t=0. Expiry la t=120. **Device B** incearcă conexiune la t=150.
|
|
|
122
|
|
|
|
123
|
```
|
|
|
124
|
t=0s: Device A connects (connectedAt=0, expiry=120)
|
|
|
125
|
t=120s: Connection expires
|
|
|
126
|
t=150s: Device B checks:
|
|
|
127
|
if (connectedExpiryAt < now):
|
|
|
128
|
// Lock expired, can connect
|
|
|
129
|
setConnection() → Device B becomes new owner
|
|
|
130
|
```
|
|
|
131
|
|
|
|
132
|
---
|
|
|
133
|
|
|
|
134
|
## Deduplication Logic (v3+)
|
|
|
135
|
|
|
|
136
|
Rulează la `AppDelegate.didFinishLaunchingWithOptions`:
|
|
|
137
|
|
|
|
138
|
```swift
|
|
|
139
|
rebuildCanonicalStoreIfNeeded(version: 3)
|
|
|
140
|
1. Fetch ALL DeviceSettings records
|
|
|
141
|
2. Group by normalized macAddress
|
|
|
142
|
3. Per group:
|
|
|
143
|
- Select "winner" (prefer: more recent updatedAt, more data fields populated)
|
|
|
144
|
- Merge other records INTO winner
|
|
|
145
|
- Delete duplicates (only delete-uri, no inserts)
|
|
|
146
|
4. Save context → CloudKit deletes propagate
|
|
|
147
|
5. Mark in UserDefaults as "done for v3"
|
|
|
148
|
```
|
|
|
149
|
|
|
|
150
|
**De ce in-place?** Pentru a nu cauza "delete storm":
|
|
|
151
|
- ❌ Old way: Delete ALL + Insert ALL → alt device primește DELETE-uri înainte de INSERT-uri → pierde date
|
|
|
152
|
- ✅ New way: Update IN-PLACE + Delete only duplicates → record ID-ul DeviceSettings este păstrat → sincronizare progresivă
|
|
|
153
|
|
|
|
154
|
---
|
|
|
155
|
|
|
|
156
|
## Version History
|
|
|
157
|
|
|
|
158
|
| Model | Version | Changes |
|
|
|
159
|
|-------|---------|---------|
|
|
|
160
|
| USB_Meter.xcdatamodel | 1 | Original schema cu `uniquenessConstraint` |
|
|
|
161
|
| USB_Meter 2.xcdatamodel | 2 | ❌ Removed constraint (but rebuild was destructive) |
|
|
|
162
|
| USB_Meter 2.xcdatamodel | 3 | ✅ Rebuilt with in-place merge strategy |
|
|
|
163
|
|
|
|
164
|
Migration path:
|
|
|
165
|
```
|
|
|
166
|
v1 → v2 (lightweight, auto) → v3 (rebuild in-place, UserDefaults gated)
|
|
|
167
|
```
|