openble-sonicare 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .pytest_cache/
5
+ readings.jsonl
6
+ .philips-scale.json
7
+ .philips-scale-*.json
8
+ data/
9
+ dist/
10
+ build/
11
+ *.egg-info/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kadima Tech
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.5
2
+ Name: openble-sonicare
3
+ Version: 0.1.0
4
+ Summary: Philips Sonicare toothbrush GATT map and advertisement matching.
5
+ Project-URL: Source, https://github.com/kadima-tech/openble/tree/main/packages/openble-sonicare
6
+ Project-URL: Issues, https://github.com/kadima-tech/openble/issues
7
+ Author: Kadima Tech
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Keywords: ble,bluetooth,philips,sonicare,toothbrush
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Home Automation
17
+ Classifier: Topic :: System :: Hardware
18
+ Requires-Python: >=3.11
19
+ Description-Content-Type: text/markdown
20
+
21
+ # openble-sonicare
22
+
23
+ The Philips Sonicare toothbrush GATT map, read from the Sonicare app's bytecode.
24
+
25
+ - `openble.sonicare.uuids`: the `477ea600-…` service and characteristic ids, each with the name and value kind the app uses.
26
+ - `openble.sonicare.identify`: matches advertisements to a Sonicare handle (`toothbrush`) or the second plugin family (`toothbrush-condor`).
27
+
28
+ Pure Python 3.11+ with no dependencies. Most byte layouts, and the SACP writes, are still open. The protocol manual records what is known and what is not.
29
+
30
+ ```
31
+ pip install openble-sonicare
32
+ ```
33
+
34
+ ```python
35
+ from openble.sonicare.identify import classify
36
+ from openble.sonicare.uuids import CBT_CHARS, cbt, cbt_suffix
37
+
38
+ classify("Philips Sonicare", [cbt("0001")])
39
+ # (['toothbrush'], ['service 477ea600-…0001', 'name Philips Sonicare'])
40
+
41
+ CBT_CHARS[cbt_suffix(cbt("4050"))]
42
+ # ('handle time', 'int')
43
+ ```
44
+
45
+ ## Protocol notes
46
+
47
+ [docs/sonicare-toothbrush-protocol.md](https://github.com/kadima-tech/openble/blob/main/packages/openble-sonicare/docs/sonicare-toothbrush-protocol.md): discovery, connect, bonding and the GATT catalog.
48
+
49
+ Not affiliated with Philips. The names identify the devices this interoperates with.
50
+
51
+ ## License
52
+
53
+ [MIT License](https://github.com/kadima-tech/openble/blob/main/packages/openble-sonicare/LICENSE), copyright 2026 Kadima Tech.
@@ -0,0 +1,33 @@
1
+ # openble-sonicare
2
+
3
+ The Philips Sonicare toothbrush GATT map, read from the Sonicare app's bytecode.
4
+
5
+ - `openble.sonicare.uuids`: the `477ea600-…` service and characteristic ids, each with the name and value kind the app uses.
6
+ - `openble.sonicare.identify`: matches advertisements to a Sonicare handle (`toothbrush`) or the second plugin family (`toothbrush-condor`).
7
+
8
+ Pure Python 3.11+ with no dependencies. Most byte layouts, and the SACP writes, are still open. The protocol manual records what is known and what is not.
9
+
10
+ ```
11
+ pip install openble-sonicare
12
+ ```
13
+
14
+ ```python
15
+ from openble.sonicare.identify import classify
16
+ from openble.sonicare.uuids import CBT_CHARS, cbt, cbt_suffix
17
+
18
+ classify("Philips Sonicare", [cbt("0001")])
19
+ # (['toothbrush'], ['service 477ea600-…0001', 'name Philips Sonicare'])
20
+
21
+ CBT_CHARS[cbt_suffix(cbt("4050"))]
22
+ # ('handle time', 'int')
23
+ ```
24
+
25
+ ## Protocol notes
26
+
27
+ [docs/sonicare-toothbrush-protocol.md](https://github.com/kadima-tech/openble/blob/main/packages/openble-sonicare/docs/sonicare-toothbrush-protocol.md): discovery, connect, bonding and the GATT catalog.
28
+
29
+ Not affiliated with Philips. The names identify the devices this interoperates with.
30
+
31
+ ## License
32
+
33
+ [MIT License](https://github.com/kadima-tech/openble/blob/main/packages/openble-sonicare/LICENSE), copyright 2026 Kadima Tech.
@@ -0,0 +1,455 @@
1
+ # Philips Sonicare toothbrush protocol
2
+
3
+ Static protocol notes for the toothbrush path in Philips Sonicare (`com.philips.cdp.ohc.tuscany` 10.21.1). The handle speaks a GATT family whose UUIDs share the base `477ea600-a260-11e4-ae37-0002a5d5`. The app discovers the brush, connects with the shared Philips Bluetooth adapter, and reads and writes those characteristics from Kotlin handlers in the `cbt` package.
4
+
5
+ This is interoperability documentation of a local app binary. It is not a capture from a live toothbrush.
6
+
7
+ The weight-scale notes for the sibling Philips Health app are in [lifesense-scale-protocol.md](../../openble-lifesense/docs/lifesense-scale-protocol.md).
8
+
9
+ ## How this was read
10
+
11
+ | Item | Value |
12
+ | --- | --- |
13
+ | Package | `/Volumes/Development/Dev/Philips Healthsuite/com.philips.cdp.ohc.tuscany_10.21.1-256_4arch_7dpi_79f17031170db29b4c8f951b1861544a_apkmirror.com.apkm` |
14
+ | SHA-256 | `1886dbdf1c399ee0e30f401f3d8a172fa62eacd6c27e0f661c94e209d80c29bb` |
15
+ | Container | APKM v5 zip. `info.json` names `com.philips.cdp.ohc.tuscany`, version 10.21.1, versionCode 256, `min_api` 31, posted 2026-09-29. `base.apk` is 85,721,012 bytes and holds `classes.dex` through `classes6.dex`. ABI splits hold the native libraries. `base.apk` was read inside the zip. |
16
+ | REA | `inspect_android_package` returned `capability_unavailable` (`provider_id` jadx) and reported that the APKM is a zip. No JADX binary was on the machine, and none was installed. `open_binary` was not called, so this manual has no REA evidence ids. |
17
+ | DEX walk | A Dalvik walker over `base.apk`. A method is cited only when the walk consumed `insns_size` exactly (`walk_ok`). UUID strings in the tables below were checked again against `classes4.dex` and `classes5.dex` while this file was written. |
18
+ | const/4 | Format `11n`: the register is `(word >> 8) & 0xF` and the literal is `(word >> 12) & 0xF`, sign-extended. Literals in this manual use that decode. |
19
+
20
+ Three labels are used below.
21
+
22
+ - **Observation.** An instruction, string, or field in the package.
23
+ - **Inference.** A conclusion that follows from those observations and is marked as such.
24
+ - **Unresolved.** Searched, and the bytecode does not answer it.
25
+
26
+ The connection path in this manual is the Java and Kotlin GATT code in `base.apk`. The ABI splits ship `libwatsonWrapper.so` (`com.philips.cdp.ohc.watson`), `libtoolChecker.so`, `libexecutorch.so`, `libsqlcipher.so`, and the usual AndroidX and image libraries. `assets/handle_feature.json` has a flag `isWatsonCalibrationRequired`. Watson is the brush-sensor calibration library.
27
+
28
+ `classes6.dex` contains `258EAFA5-E914-47DA-95CA-C5AB0DC85B11`, the RFC 6455 WebSocket GUID. No CBT handler loads it.
29
+
30
+ ## 1. Which code owns the brush
31
+
32
+ **Observation.** Two device plugins are constructed from `classes4.dex` `Log/h` method `v`: `new Lng/a` and `new Lrg/a`. Both implement `Lzq/a` and `Lzq/e`.
33
+
34
+ ### 1.1 Current handle plugin, `Lng/a`
35
+
36
+ **Observation.** `<init>` holds the strings `Philips OHC`, `Philips Sonicare`, and `NA`.
37
+
38
+ **Observation.** Static field `j` is `10000000-0000-0000-0000-0002a5d50001`. Method `g` returns it with `sget j`. That value is the plugin id.
39
+
40
+ **Observation.** Static field `k` is `477ea600-a260-11e4-ae37-0002a5d50001`. `<init>` stores `k` into instance field `f` (`Lar/h`). Method `f` is three instructions and returns that field:
41
+
42
+ ```
43
+ iget-object Lng/a;->f:Lar/h;
44
+ return-object v0
45
+ ```
46
+
47
+ **Observation.** The foreground scan filter takes the UUID from `Lng/a.f()` as the member of its service-UUID set (`j0.d`). The advertised service used to find this plugin's brush is `477ea600-a260-11e4-ae37-0002a5d50001`.
48
+
49
+ **Unresolved.** The other `ScanFilter` arguments (address, name, manufacturer data) on that plugin filter were not read. Only the service-UUID set was confirmed.
50
+
51
+ **Observation.** Method `d(peripheral)` builds the handler list: `CBTBattery`, `CBTDeviceInformation`, `CBTSonicare`, `CBTRoutine`, `CBTDeviceDiagnostic`, `CBTExtended`, `CBTStorage`, `CBTSensor`, `CBTBrushHead`, and `ByteStreamingServiceHandler$a.a(...)`. The last of those is the `a651…` family in section 7.
52
+
53
+ **Observation.** `PeripheralImpl` logs `Plugin declares support for service UUID ` and `No service handler registered for service uuid: `. A handler whose service is absent on the peripheral is tolerated.
54
+
55
+ ### 1.2 Second plugin, `Lrg/a`
56
+
57
+ **Observation.** `<init>` holds `Philips Sonicare`, `Philips`, and `NA`.
58
+
59
+ **Observation.** Static field `j` is `10000000-0000-0000-0000-0002a5d50002`. Method `g` returns it. That value is the plugin id.
60
+
61
+ **Observation.** Static field `k` is `e50ba3c0-af04-4564-92ad-fef019489de6`. The same store-and-return pattern puts `k` in instance field `f`, and method `f` returns it. The scan filter service UUID for this plugin is that `e50b…` UUID. The condor `BluetoothCommunicationStrategy` uses the same service UUID.
62
+
63
+ **Observation.** Method `d` builds `CBTDeviceInformation` and `ByteStreamingServiceHandler$a.b(...)`, the `e50b0001`–`e50b0007` family. It does not build the `477ea600` handlers.
64
+
65
+ **Unresolved.** Which retail model selects `Lng/a` and which selects `Lrg/a` was not tied. `assets/ProductsConfiguration.json` (and the country variants beside it) were not read for that mapping.
66
+
67
+ ### Call stack
68
+
69
+ ```
70
+ Philips Sonicare UI and workers classes4.dex
71
+ Lng/a handle plugin, scan service …0001
72
+ Lrg/a second plugin, scan service e50ba3c0-…
73
+ tuscany.ble.blesync…workermanager.b background scan by advertised name
74
+ ScanIntentReceiver
75
+ NativeScanWorker / NativeConnectWorker
76
+
77
+ CBT handlers classes4.dex
78
+ CBTSonicareServiceHandler service …0001
79
+ CBTRoutineServiceHandler service …0002
80
+ CBTStorageServiceHandler service …0004
81
+ CBTSensorServiceHandler service …0005
82
+ CBTBrushHeadServiceHandler service …0006
83
+ CBTDeviceDiagnosticServicesHandler service …0007
84
+ CBTExtendedServiceHandler service …0008
85
+ CBTBatteryServiceHandler standard Battery service 180F
86
+ CBTDeviceInformationServiceHandler standard Device Information 180A
87
+
88
+ Shared Philips connectivity adapter classes5.dex
89
+ AndroidCentral / Scanner
90
+ PendingIntentScanReceiver
91
+ Lrq/t the connectGatt caller
92
+ Lrq/v BluetoothGattCallback forwarder
93
+ PeripheralImpl service-handler setup and GATT queue
94
+ Lwq/n WaitingUntilBondedState, createBond
95
+ ```
96
+
97
+ `Lar/h` is the app's UUID wrapper. Companion `Lar/h$a.a(String)` builds one from the DEX string. Each handler's `<clinit>` does that, then `sput`s the next static field. The service is the `…00NN` value. The characteristics are the `40xx` / `42xx` / `43xx` / `44xx` values. A read or write method is tied to a field when it `sget`s that field immediately before `new-instance` of the surviving Kotlin `$$inlined$` class.
98
+
99
+ ## 2. Android BLE entry
100
+
101
+ **Observation.** The binary `AndroidManifest.xml` contains these UTF-16 strings: `android.permission.BLUETOOTH`, `android.permission.BLUETOOTH_ADMIN`, `android.permission.BLUETOOTH_CONNECT`, `android.permission.BLUETOOTH_SCAN`, `android.permission.ACCESS_FINE_LOCATION`, `android.permission.ACCESS_COARSE_LOCATION`, `android.permission.ACCESS_BACKGROUND_LOCATION`, and the feature `android.hardware.bluetooth_le`. The same scan found `com.philips.cdp.ohc.tuscany.permission.INTERNAL_BROADCAST`, `com.philips.cdp.ohc.tuscany.ble.blesync.backgroundtask.workermanager.ScanIntentReceiver`, `com.philips.connectivity.bluetooth.adapter.ACTION_BLE_SCAN`, and `com.philips.connectivity.bluetooth.adapter.internal.scanning.PendingIntentScanReceiver`.
102
+
103
+ **Observation.** `com.philips.connectivity.bluetooth.adapter.scanning.ACTION_RESTART_SCAN` is a string in `classes5.dex`. It was not in the manifest UTF-16 runs.
104
+
105
+ **Observation.** The manifest string scan did not find `NEARBY_WIFI_DEVICES` or a companion-device permission. `classes4.dex` does contain the log `Near BY permission not given.`.
106
+
107
+ ### 2.1 Foreground scan
108
+
109
+ **Observation.** `Scanner.v` (`startLeScan`) checks a permission helper (`Lqq/k.a`), that the adapter is enabled (log `Bluetooth adapter not enabled.`, error `SCAN_FAILED_BLUETOOTH_OFF`), and that location is enabled (`Lqq/k.c`, log `Not scanning. Location is disabled.`, error `SCAN_FAILED_LOCATION_OFF`). It then calls `BluetoothLeScanner.startScan(List, ScanSettings, PendingIntent)`. The `PendingIntent` comes from `PendingIntentScanReceiver$a.a`.
110
+
111
+ **Observation.** `Scanner.w` builds `ScanSettings`. The instruction immediately before `setScanMode` is `const/4 v4, #0`.
112
+
113
+ **Observation.** Empty filters can be replaced. `Scanner.i` and `AndroidCentral.A` refer to `isUnfilteredScanningForced`. The shared scanner converts the app's filter objects through `Ltq/a.b`. `ScanFilter` fields present in that type are `serviceUUIDs`, `peripheralAddress`, `name`, and `manufacturerDataFilterOptions`.
114
+
115
+ ### 2.2 Background scan
116
+
117
+ **Observation.** The app-level scanner is `classes4.dex` `tuscany.ble.blesync.backgroundtask.workermanager.b`, separate from the shared `Scanner`. Method `e()` loads `Philips Sonicare` and calls `ScanFilter.Builder.setDeviceName`. That filter is a name filter.
118
+
119
+ **Observation.** Method `g` logs `proceedForScanning > 26` and `ProceedForScanning failed`. The instruction immediately before `setScanMode` is `const/4 v1, #0`. It then calls `b.f()` for the `PendingIntent` and `BluetoothLeScanner.startScan`. Stop-path logs are `Unexpected runtime exception stopping Android O background scanner:`, `NullPointerException stopping Android O background scanner:`, and `SecurityException stopping Android O background scanner:`.
120
+
121
+ **Observation.** `f()` builds the `PendingIntent` with `PendingIntent.getBroadcast`. The request-code immediate is `0x181e5`.
122
+
123
+ **Observation.** `NativeScanWorker` returns without scanning when Bluetooth is off, when the app is in the foreground, when a firmware upgrade is in progress, and when it logs `Background sync stopped after detecting rooted device`. `ScanIntentReceiver` reads `android.bluetooth.le.extra.LIST_SCAN_RESULT` and `android.bluetooth.le.extra.CALLBACK_TYPE`, and logs `PendingIntent broadcast received device address`. `NativeConnectWorker` connects a stored `deviceAddress` when the app is not in the foreground. Periodic scan work stores `scan_filters`.
124
+
125
+ **Inference.** The immediate `0` passed to `ScanSettings.setScanMode` is `ScanSettings.SCAN_MODE_LOW_POWER` in the public Android API. Both the shared scanner and the background worker pass that immediate.
126
+
127
+ ### 2.3 GATT connect
128
+
129
+ **Observation.** The only `BluetoothDevice.connectGatt` caller found is `classes5.dex` `Lrq/t.c` (walk ok, 49 instructions). `Lrq/t` extends `BluetoothGattCallback`. The overload invoked is `connectGatt(Context, boolean autoConnect, BluetoothGattCallback, int transport) -> BluetoothGatt`.
130
+
131
+ The call site, after the permission check `Lnq/a.a` (tags `ConnectivityBluetoothAdapter` / `GattClient`):
132
+
133
+ ```
134
+ @13 const/4 v0, #0 ; returned early when the permission check fails
135
+ @17 iget Lrq/t;->a ; BluetoothDevice
136
+ @19 iget Lrq/t;->b ; Context
137
+ @21 invoke Lrq/t.i ; regs v5, v6
138
+ @24 move-result v3
139
+ @25 const/4 v4, #2
140
+ @26 invoke BluetoothDevice.connectGatt
141
+ ; regs v6, v2, v3, v5, v4
142
+ ```
143
+
144
+ `v6` is the `BluetoothDevice`, `v2` is the `Context`, `v3` is the boolean returned by `Lrq/t.i`, `v5` is the `Lrq/t` instance used as the callback, and `v4` is `2`.
145
+
146
+ **Inference.** `BluetoothDevice.TRANSPORT_LE` is `2`. The transport argument is that constant.
147
+
148
+ **Unresolved.** `Lrq/t.i` was not disassembled. The `autoConnect` boolean is whatever that method returns.
149
+
150
+ **Observation.** On failure the log is `Unable to connect GATT client.` On success it is `GATT client connected.` `Lrq/v` is the callback object and forwards `onConnectionStateChange`, `onServicesDiscovered`, `onCharacteristicRead`, `onCharacteristicWrite`, `onCharacteristicChanged`, `onDescriptorRead`, `onDescriptorWrite`, and `onMtuChanged` to `Lrq/t`. `Lrq/v.b` and `Lrq/w.b` call `Lrq/t.c`.
151
+
152
+ **Observation.** `AndroidCentral` builds a peripheral from an address with `BluetoothAdapter.getRemoteDevice` after `toUpperCase(Locale.ROOT)`. One `AndroidPeripheral` constructor passes `BondInitiator.NONE`. The address constructor takes a bond-initiator argument.
153
+
154
+ **Observation.** After connect, `PeripheralImpl` logs `Connecting.`, `Connected`, `Disconnected`, `Service handler setup complete`, and `No services available!`, and queues GATT reads, writes, and notifications. Descriptor failures log `SERVICE_UUID`, `CHARACTERISTIC_UUID`, and `DESCRIPTOR_UUID`. Reconnect is scheduled with `Reconnect with delay of` and the field `reconnectAfterDisconnect`. A watchdog surrounds GATT requests.
155
+
156
+ ### 2.4 Notifications
157
+
158
+ **Observation.** The Client Characteristic Configuration descriptor `00002902-0000-1000-8000-00805f9b34fb` is built with `UUID.fromString` in `classes5.dex` `Lcom/philips/connectivity/bluetooth/adapter/internal/a` `<clinit>`.
159
+
160
+ **Observation.** CBT notifications go through `CBTServiceHandler.h1` (enable) and `g1` (disable), via `notifyCharacteristicIO`. Explicit enables are in section 6.
161
+
162
+ **Inference.** Many `CharacteristicDescription` constructors are passed the integer `16`. That matches the standard GATT notify property bit `0x10`. The walk did not prove the constructor stores it as the property mask. Nearby `const/4` defaults in an early pass were mis-decoded, so this stays an inference.
163
+
164
+ ## 3. How a device is recognized
165
+
166
+ Two scan paths feed the same connect call.
167
+
168
+ | Path | Filter | Where |
169
+ | --- | --- | --- |
170
+ | Foreground, handle plugin | Service UUID `477ea600-a260-11e4-ae37-0002a5d50001` from `Lng/a.f()` | Shared `Scanner.v`, scan-mode immediate `0`, `PendingIntent` from `PendingIntentScanReceiver` |
171
+ | Foreground, second plugin | Service UUID `e50ba3c0-af04-4564-92ad-fef019489de6` from `Lrg/a.f()` | Same scanner |
172
+ | Background sync | Advertised name `Philips Sonicare` via `setDeviceName` | `workermanager.b`, scan-mode immediate `0`, `PendingIntent` request code `0x181e5` |
173
+
174
+ **Observation.** Plugin ids `10000000-0000-0000-0000-0002a5d50001` and `…0002` are returned by method `g`. They are not the scan-filter service UUIDs. The scan filters use field `k` through method `f`.
175
+
176
+ **Observation.** `ScanIntentReceiver` delivers `LIST_SCAN_RESULT` into the app. `NativeConnectWorker` then connects a stored address. The foreground path reaches `AndroidCentral`, which builds `AndroidPeripheral` for the address and enters `Lrq/t.c`.
177
+
178
+ ## 4. GATT catalog
179
+
180
+ Every `477ea600` string in `classes4.dex` is listed in the tables below. The suffix is the last four hex digits of `477ea600-a260-11e4-ae37-0002a5d5XXXX`, in the case stored in the DEX. There is no `…0003` string.
181
+
182
+ `classes5.dex` also contains the standard UUIDs `00001801-0000-1000-8000-00805f9b34fb` (Generic Attribute) and `00002A05-0000-1000-8000-00805f9b34fb` (Service Changed). They were not assigned to a CBT handler.
183
+
184
+ ### 4.1 Handle service, `CBTSonicareServiceHandler`
185
+
186
+ Superclass `CBTServiceHandler`. Interfaces `Lmh/a` and `Lph/a`. Log tag `SonicareServiceHandler`.
187
+
188
+ | Field | UUID suffix | Role |
189
+ | --- | --- | --- |
190
+ | `l` | `0001` | Service. This is also `Lng/a` field `k`, the foreground scan UUID. |
191
+ | `m` | `4010` | Handle state. The read builds `readHandleState$$inlined$readCharacteristicIO$1` (method `A1`). Enable `i0` (`enableHandleState`) calls `h1` on `m`. Disable `r0` calls `g1`. Change consumer `s1` logs `handle state changed to` and parses `HandleState`. |
192
+ | `n` | `4050` | Handle time. Read method `g` builds `readHandleTime$$inlined$readIntCharacteristicIO$1`. `w1` returns `n` and is used by `writeHandleTime$1`. One log is `readTimeBeforeAssociationAndWriteCurrentTime`. Change consumer `t1` logs `changeConsumer handle time read`. |
193
+ | `o` | `4020` | Available brushing routines. Setup callback `Lcom/philips/cdp/ohc/cbt/services/a0` reaches `r1` and then `u1`, which logs `changeConsumer AVAILABLE_BRUSHING_ROUTINES_CHAR_UUID`. |
194
+ | `p` | `4022` | Routine ids. Method `o` builds `readAvailableRoutineIds`. Method `v` builds `writeRoutineId`. Callback `b0` logs `AVAILABLE_BRUSHING_ROUTINES_IDS_UUID` (`v1`). |
195
+ | `q` | `4040` | Cumulative motor runtime. Method `e0` builds `readCumulativeMotorRuntime`. |
196
+
197
+ **Observation.** The service-description method `K0` registers `m`, `n`, `o`, and `p`. `q` is read by `e0` and is outside that registration list.
198
+
199
+ ### 4.2 Routine service, `CBTRoutineServiceHandler`
200
+
201
+ Log tag `RoutineServiceHandler`. Service field `l` is `0002`.
202
+
203
+ | Field | UUID suffix | Read / write tied by `sget` |
204
+ | --- | --- | --- |
205
+ | `m` | `4090` | `readBrushingTime` (`L1`). |
206
+ | `n` | `4080` | `readRoutineId` (`N1`). |
207
+ | `o` | `4091` | `readRoutineLength` (`O1`). |
208
+ | `p` | `4070` | `readCurrentSessionId` (`e`). |
209
+ | `q` | `40b0` | `readIntensity` (`M1`) and `writeIntensity` (`P`). |
210
+ | `r` | `4082` | `writeRoutineState` (`P1`). |
211
+
212
+ **Observation.** `enableUpdates` method `C1` notifies fields `o`, `p`, `m`, `n`, and `q` (`4091`, `4070`, `4090`, `4080`, `40b0`).
213
+
214
+ **Observation.** Log strings on the notify side: `notifyBrushingTime` / `BRUSHING_TIME_CHAR_UUID` (`D1`), `notifyRoutineNumber` / `ROUTINE_NUMBER_CHAR_UUID` (`G1`), `notifyRoutineLength` (`F1`), `notifySessionId` (`I1`), `INTENSITY_CHAR_UUID` / `notifyIntensity` (`u1` / `E1`), `notifyRoutineState` / `changeConsumer routine state` (`H1` / `v1`).
215
+
216
+ **Unresolved.** `D1`, `E1`, `F1`, `G1`, `H1`, and `I1` were not re-checked for the `sget` that would pin each log to a field. The read and write rows above are the solid mapping. `C1` is the solid enable list.
217
+
218
+ **Observation.** Method `m` logs `feature is not supported` on the routine-id write. Coverage, overpressure, scrubbing, and brushing-type listeners log the same string.
219
+
220
+ ### 4.3 Session storage, `CBTStorageServiceHandler`
221
+
222
+ Log tag `CBTStorageServiceHandler`. Service field `q` is `0004`. Session access is named SACP in the logs. The bytes written to these characteristics were not recovered.
223
+
224
+ | Field | UUID suffix | Role |
225
+ | --- | --- | --- |
226
+ | `k` | `40d0` | `readLatestSessionId` (`z1`). Change consumer `u1` logs `onActiveSessionId` and parses an int with `Ldh/c.g`. |
227
+ | `l` | `40D5` | `writeSessionType` (`r`). |
228
+ | `m` | `40E0` | `writeActiveSessionId` (`O`). Also notified. |
229
+ | `n` | `4110` | `requestSessionData` (`i`), `requestBulkSessionData` (`t`, log `writeSacpToReadBulkSessions`), `cancelSessionReadInProgress` (`k0`, log `writeSacpToCancelSessionReadInProgress`). Change consumer `v1` logs `onSacp`. |
230
+ | `o` | `4100` | Notified. Callback class `storage/j`. Change consumer `x1` logs `onSessionData`. |
231
+ | `p` | `40d2` | `readAvailableSessionCount` (`y1`). |
232
+
233
+ **Observation.** `enableStorageNotifications` (`Q`) calls `h1` on `m`, `o`, and `n` (`40E0`, `4100`, `4110`). Writes use `Characteristic$WriteType.a`. The enum constant behind `a` was not decoded.
234
+
235
+ ### 4.4 Sensor service, `CBTSensorServiceHandler`
236
+
237
+ Service field `l` is `0005`.
238
+
239
+ | Field | UUID suffix | Role |
240
+ | --- | --- | --- |
241
+ | `m` | `4130` | `enableAllNotification` (`i0`) calls `h1` on `m`. |
242
+ | `n` | `4120` | `enableSensorNotification` writes `n` (`o1`). Change consumer `n1` logs `changeConsumer selectedSensorData`. |
243
+
244
+ **Unresolved.** The sensor notification payload was not recovered.
245
+
246
+ ### 4.5 Brush-head service, `CBTBrushHeadServiceHandler`
247
+
248
+ Log tag `SonicareBrushHeadServiceHandler`. Service field `l` is `0006`.
249
+
250
+ | Field | UUID suffix | Role |
251
+ | --- | --- | --- |
252
+ | `m` | `4210` | Registered in `K0` with callback class `Lcom/philips/cdp/ohc/cbt/services/a`. That callback's method `a` calls `CBTBrushHeadServiceHandler.o1`. `o1` has no const-strings. |
253
+ | `n` | `4230` | Method `k` logs `readNfcTagVersion` and constructs `readBrushHeadSerialNumber$$inlined$readCharacteristicIO$1`. `enableUpdates` `l0` and `disableBrushHeadUpdates` `a` call `h1` / `g1` on `n`. Callback class `b` method `a` calls `m1`. `m1` has no const-strings. |
254
+ | `o` | `4220` | `readFactoryMode` (`Y`). |
255
+ | `p` | `4280` | `readLifetimeLimit` (`o0`). |
256
+ | `q` | `4290` | `readLifetimeUsage` (`h0`). |
257
+ | `r` | `42C0` | `readRingId` (`p`). |
258
+
259
+ **Observation.** `K0` callback classes are `m` → `a`, `n` → `b`, `o` → `c`, `p` → `d`, `q` → `e`, `r` → `f`. Those classes were not fully disassembled.
260
+
261
+ **Observation.** Separate change consumers: `q1` logs `changeConsumer nfc tag version read`. `r1` logs `byteArray`, `: changeConsumer serial Number `, and `BLE_CBT`. Further consumers exist for factory mode, lifetime limit, lifetime usage, and ring id (`s1`, `t1`, `u1`, `v1`).
262
+
263
+ **Unresolved.** The read-method name for `4210` was not recovered. Method `k` carries both the `readNfcTagVersion` log and the `readBrushHeadSerialNumber` class name for `4230`, and `q1` / `r1` were not linked to `m` versus `n`.
264
+
265
+ **Observation.** Dental-diary and connected-devices screens also const-string `…4290` and `…4280`. That is display code using the same UUIDs.
266
+
267
+ ### 4.6 Diagnostic service, `CBTDeviceDiagnosticServicesHandler`
268
+
269
+ Service field `j` is `0007`.
270
+
271
+ | Field | UUID suffix | Read |
272
+ | --- | --- | --- |
273
+ | `k` | `4310` | `readErrorValuePersistent` |
274
+ | `l` | `4320` | `readErrorValueVolatile` |
275
+ | `m` | `4330` | `readProximityValue` |
276
+ | `n` | `4340` | `readExternalFlash` |
277
+ | `o` | `4350` | `readPressureSensor` |
278
+ | `p` | `4360` | `readInvalidChargerCount` |
279
+ | `q` | `4370` | `readDebugInformation` |
280
+
281
+ **Observation.** The service description `K0` registers `k`, `l`, and `p`.
282
+
283
+ ### 4.7 Extended service, `CBTExtendedServiceHandler`
284
+
285
+ | Field | UUID suffix | Role |
286
+ | --- | --- | --- |
287
+ | `j` | `0008` | Service. |
288
+ | `k` | `4420` | `readHandleSettings` and `writeHandleSettings`. |
289
+
290
+ **Observation.** Method `I` logs `enableUpdates: not supported for ExtendedServiceHandler` under the tag `BLE_CONDOR`. Change consumer `l1` logs `errorValuePersistent`. The diagnostic handler has a read of the same name on `4310`. These are two services.
291
+
292
+ ### 4.8 Battery
293
+
294
+ **Observation.** `CBTBatteryServiceHandler` extends `Lfr/b` (`BatteryServiceHandler`). `Lfr/b.<clinit>` (walk ok, 27 instructions) stores:
295
+
296
+ | Field | UUID | Role |
297
+ | --- | --- | --- |
298
+ | `k` | `0000180F-0000-1000-8000-00805f9b34fb` | Battery service. |
299
+ | `l` | `00002A19-0000-1000-8000-00805f9b34fb` | Battery Level. |
300
+
301
+ **Observation.** Method `g1` returns field `l`. Companion `Lfr/b$a.a` calls `g1`. Method `b0` logs `readBatteryLevel` and calls that companion. Method `h1` logs `Received battery level ` and parses the bytes with `Ler/a.a` into an integer. `<init>` registers service `k` and characteristic `l`. This handler has no `477ea600` UUID.
302
+
303
+ **Unresolved.** A `const/16` of `#17` sits next to the parser call. The width and layout of the integer were not recovered.
304
+
305
+ ### 4.9 Device Information
306
+
307
+ **Observation.** `CBTDeviceInformationServiceHandler` extends the shared `DeviceInformationServiceHandler`. `<clinit>` `sput` order, from a walk that completed:
308
+
309
+ | Field | UUID | Role |
310
+ | --- | --- | --- |
311
+ | `k` | `0000180A-…` | Device Information service. |
312
+ | `l` | `00002A29-…` | Manufacturer Name. |
313
+ | `m` | `00002A24-…` | Model Number. |
314
+ | `n` | `00002A25-…` | Serial Number. |
315
+ | `o` | `00002A27-…` | Hardware Revision. |
316
+ | `p` | `00002A26-…` | Firmware Revision. |
317
+ | `q` | `00002A28-…` | Software Revision. |
318
+ | `r` | `00002A23-…` | System ID. |
319
+ | `s` | `00002A2A-…` | IEEE certification. |
320
+ | `t` | `00002A50-…` | PnP ID. |
321
+ | `u` | string `deviceInformation` | Log name, not a UUID. |
322
+
323
+ The `…` tail is `0000-1000-8000-00805f9b34fb`, and the strings in the DEX use that capitalisation (`0000180A`, `00002A24`, and so on).
324
+
325
+ **Observation.** Companion method `a` calls static `p1`, which returns field `p` (firmware `2A26`), used by `readFirmwareVersion` (log `firmware`). Companion `b` calls `q1`, which returns field `m` (model `2A24`), used by `readModelNumber` (log `model`). Companion `c` calls `r1`, which returns field `n` (serial `2A25`), used by `readSerialNumber` (log `serial`). Method `w1` logs `SystemID (...) is not equal to actual peripheral address`.
326
+
327
+ ## 5. Bonding, and `createBond`
328
+
329
+ **Observation.** `BluetoothDevice.createBond() -> boolean` is `classes5.dex` method id 68. It takes no arguments. The only caller is `Lwq/n.d` at program counter 118.
330
+
331
+ **Observation.** `Lwq/n.<init>` holds the string `WaitingUntilBondedState`. The superclass is `Lwq/g`. The method reads `getBondState`, registers a bond-status listener through `AndroidCentral.H`, and switches on the `AndroidPeripheral$BondInitiator` ordinal (`Lwq/n$b.a`). The enum `<clinit>` strings are `NONE`, `PERIPHERAL`, and `APP`. `Lwq/n$b.<clinit>` `sget`s `PERIPHERAL` and `APP` for that when-map.
332
+
333
+ Logs in that state:
334
+
335
+ | Log | Meaning carried by the string |
336
+ | --- | --- |
337
+ | `Bond creation started.` | `createBond` was invoked. |
338
+ | `Already bonded, bonding or bond creation failed.` | Bond already present, or creation failed. |
339
+ | `Waiting for peripheral to initiate bonding!` | Initiator `PERIPHERAL`. |
340
+ | `Reached WaitingForBondState but there is no bond initiator, likely getting stuck!` | Initiator `NONE`. |
341
+ | `Bond created.` | Bond reached bonded. |
342
+ | `Bond state changed from` | Broadcast transition. |
343
+ | `Bond lost` | Error `ConnectionError.BOND_LOST`. |
344
+
345
+ **Observation.** Constructors of `Lwq/n` are reached from `Lwq/b.l` and `Lwq/d.g`.
346
+
347
+ **Observation.** The default `AndroidPeripheral.<init>` `sget`s `BondInitiator.NONE`. `Lwq/d.d` (`GattConnectingState`, log `onEnter.`) logs `GattConnectingState: Resetting bondInitiator to NONE (device is unbonded).` when the device is unbonded, `sget`s `NONE`, and calls `Lcom/philips/connectivity/bluetooth/adapter/internal/c.m`. The only callers of `internal/c.m` (method id 4749) are `Lwq/d.m` and `Lwq/d.d`, and both pass `NONE`. `Lwq/d.m` also compares the current initiator with `NONE` and `PERIPHERAL` and calls `getBondState`.
348
+
349
+ **Observation.** A prefix walk of `sget` / `sput` / `iget` / `iput` of fields named `APP`, `PERIPHERAL`, and `NONE` on `BondInitiator` found `APP` in the enum `$values`, the enum `<clinit>` `sput`, and `Lwq/n$b.<clinit>` (the when table). No caller `sget`s `BondInitiator.APP` to store it. Walks that failed before `insns_size` were skipped, so a method that loads `APP` and then fails the walk would have been missed.
350
+
351
+ **Observation.** `assets/handle_feature.json` (`handleFeature`, 6501 bytes) sets `isSupportBtPairing` to false on `featureSet_One` and `featureSet_Two`, and true on `featureSet_Three` through `featureSet_Seven`. `isRPASupported` is true only on `featureSet_Five`, `featureSet_Six`, and `featureSet_Seven`. The string `isSupportBtPairing` is also in the `classes4.dex` pool. No `walk_ok` method const-strings it.
352
+
353
+ **Unresolved.** The store that would put `BondInitiator.APP` into the peripheral was not found, and the feature flag was not shown selecting that enum. The `APP` arm that calls `createBond()` exists and runs if something sets `APP`.
354
+
355
+ **Observation.** `AndroidCentral$pairingRequestBroadcastReceiver$1` handles `android.bluetooth.device.action.PAIRING_REQUEST`. The method refers to `BLUETOOTH_PRIVILEGED`, `setPairingConfirmation`, and `setPin`. The bond-status broadcast is `android.bluetooth.device.action.BOND_STATE_CHANGED`, with extras `BOND_STATE`, `PREVIOUS_BOND_STATE`, and `DEVICE`. This receiver belongs to the shared adapter.
356
+
357
+ **Inference.** The connect path that was recovered writes `NONE` and does not call `createBond()`. Bonding is a separate state (`WaitingUntilBondedState`) that calls `createBond()` for initiator `APP`, waits for initiator `PERIPHERAL`, and logs the stuck warning for initiator `NONE`.
358
+
359
+ ## 6. What the handlers do after connect
360
+
361
+ Once `PeripheralImpl` logs `Service handler setup complete`, the `Lng/a` handlers issue the reads, writes, and notifications in section 4. The order below is the set of operations the bytecode names, not a traced session transcript.
362
+
363
+ 1. Identity and power. Device Information reads model (`2A24`), serial (`2A25`), and firmware (`2A26`). Battery reads `2A19` and parses an integer. Handle time `4050` is read and written (`readTimeBeforeAssociationAndWriteCurrentTime`).
364
+ 2. Handle state `4010` is notified (`enableHandleState`).
365
+ 3. Routine `0002` notifies brushing time, routine length, session id, routine id, and intensity (`C1` on `4090`, `4091`, `4070`, `4080`, `40b0`). Intensity `40b0` and routine state `4082` have write methods. A routine-id write logs `feature is not supported`.
366
+ 4. Storage `0004` notifies active session id `40E0`, session data `4100`, and the SACP characteristic `4110`. Named writes are session type `40D5`, active session id `40E0`, session-data request, bulk session request (`writeSacpToReadBulkSessions`), and cancel (`writeSacpToCancelSessionReadInProgress`). Latest session id `40d0` and available session count `40d2` are reads. `onActiveSessionId` parses an int.
367
+ 5. Sensor `0005` enables notification on `4130` and writes `4120` (`enableSensorNotification`). The change log is `changeConsumer selectedSensorData`.
368
+ 6. Brush head `0006` enables notification on `4230`. Factory mode `4220`, lifetime limit `4280`, lifetime usage `4290`, and ring id `42C0` have read methods. `4210` is registered and its callback calls `o1`.
369
+ 7. Diagnostic `0007` reads the seven characteristics in section 4.6. The description registers three of them (`4310`, `4320`, `4360`).
370
+ 8. Extended `0008` reads and writes handle settings `4420`. `enableUpdates` logs that it is not supported.
371
+
372
+ **Unresolved.** SACP write bytes, the `HandleState` enum values, the sensor notification payload, the battery integer width, and the brush-head value on `4210` were not recovered. `Characteristic$WriteType.a` was not decoded.
373
+
374
+ ## 7. The other UUID families
375
+
376
+ Both families are built by `ByteStreamingServiceHandler` companions and are present because that shared code is in this APK. The scale app's Moonshine wrist tracker uses the `a651` base as well. See the scale manual for that product.
377
+
378
+ ### 7.1 `a651`, registered on the handle plugin
379
+
380
+ **Observation.** `ByteStreamingServiceHandler$a.a` builds these, and `Lng/a.d` registers that handler:
381
+
382
+ | UUID |
383
+ | --- |
384
+ | `a6510001-4074-4131-bce9-56d4261bc7b1` |
385
+ | `a6510002-4074-4131-bce9-56d4261bc7b1` |
386
+ | `a6510003-4074-4131-bce9-56d4261bc7b1` |
387
+ | `a6510004-4074-4131-bce9-56d4261bc7b1` |
388
+ | `a6510005-4074-4131-bce9-56d4261bc7b1` |
389
+ | `a651fff1-4074-4131-bce9-56d4261bc7b1` |
390
+
391
+ The handle scan UUID and the services in section 4 stay on the `477ea600` base.
392
+
393
+ ### 7.2 `e50b`, the second plugin
394
+
395
+ **Observation.** `ByteStreamingServiceHandler$a.b` builds `e50b0001` through `e50b0007` on the base `af04-4564-92ad-fef019489de6`. The service UUID `e50ba3c0-af04-4564-92ad-fef019489de6` is `Lrg/a` field `k` and the condor `BluetoothCommunicationStrategy` service. `Lrg/a.d` registers Device Information plus this byte-streaming handler.
396
+
397
+ ## 8. Questions that were open
398
+
399
+ ### How does the app find the toothbrush?
400
+
401
+ Answered in section 3. The handle plugin's foreground filter is service `477ea600-a260-11e4-ae37-0002a5d50001`. The second plugin's filter is service `e50ba3c0-af04-4564-92ad-fef019489de6`. Background sync filters the advertised name `Philips Sonicare`. Both use `BluetoothLeScanner.startScan` with a `PendingIntent` and scan-mode immediate `0`.
402
+
403
+ ### How does it connect?
404
+
405
+ Answered in section 2.3. `Lrq/t.c` calls `BluetoothDevice.connectGatt(context, autoConnect, callback, 2)`. The callback is the `Lrq/t` instance. Transport `2` is `TRANSPORT_LE`. `autoConnect` is the return value of `Lrq/t.i`, which was not disassembled. `PeripheralImpl` then matches discovered services to the handlers from `Lng/a.d` or `Lrg/a.d`.
406
+
407
+ ### Does the brush path call `BluetoothDevice.createBond`?
408
+
409
+ The only caller is `WaitingUntilBondedState` (`Lwq/n.d`), and it runs for `BondInitiator.APP`. The recovered connect path stores `NONE`: the default peripheral constructor loads `NONE`, and `GattConnectingState` resets an unbonded device to `NONE`. No store of `APP` was found. `isSupportBtPairing` is true for feature sets Three through Seven in `handle_feature.json`, and no `walk_ok` method loads that string. Section 5 has the search limit.
410
+
411
+ ### Which GATT service is the handle?
412
+
413
+ Service `…0001` on `CBTSonicareServiceHandler`, which is the same UUID the handle plugin scans for. Routine, storage, sensor, brush head, diagnostic, and extended services are `…0002`, `…0004`, `…0005`, `…0006`, `…0007`, and `…0008`. Battery is standard `180F` / `2A19`. Identity is standard `180A` with model `2A24`, serial `2A25`, and firmware `2A26`.
414
+
415
+ ## 9. Still unresolved
416
+
417
+ These were searched and are not answered by the methods above.
418
+
419
+ 1. The boolean `Lrq/t.i` returns into `connectGatt` as `autoConnect`.
420
+ 2. Which caller, if any, stores `BondInitiator.APP`, and whether `isSupportBtPairing` selects it. The `APP` arm and the JSON flag both exist. The store between them was not found. Failed walks were skipped.
421
+ 3. The role of brush-head characteristic `4210`. Callback class `a` calls `o1`, and `o1` has no const-strings.
422
+ 4. On `4230`, method `k` logs `readNfcTagVersion` and constructs `readBrushHeadSerialNumber`. Which of `q1` (`nfc tag version`) and `r1` (`serial Number`) belongs to `4210` and which to `4230` was not tied.
423
+ 5. Notify-method-to-field links for routine methods `D1`, `E1`, `F1`, `G1`, `H1`, and `I1`. Read, write, and `C1` enable links are in section 4.2.
424
+ 6. SACP and session write bytes, `HandleState` values, the sensor notification payload, and the battery integer width. `Characteristic$WriteType.a` was not decoded.
425
+ 7. Which product selects plugin `Lng/a` versus `Lrg/a`, and which feature set in `handle_feature.json` a model uses. `ProductsConfiguration.json` was not read.
426
+ 8. The address, name, and manufacturer-data arguments of the plugin `ScanFilter`. The service-UUID set was confirmed.
427
+ 9. The property mask stored from the integer `16` passed into `CharacteristicDescription`.
428
+ 10. Any check against a live brush. Every UUID in section 4 is what the handlers load.
429
+
430
+ ## 10. Evidence index
431
+
432
+ | Fact | Where |
433
+ | --- | --- |
434
+ | Package identity | APKM path, SHA-256, and `info.json` in the header. |
435
+ | Manifest permissions and receivers | Binary `AndroidManifest.xml` UTF-16 runs, section 2. |
436
+ | Handle plugin and scan UUID | `classes4.dex` `Lng/a` `<init>`, fields `j` and `k`, method `f`. |
437
+ | Second plugin and scan UUID | `classes4.dex` `Lrg/a` `<init>`, fields `j` and `k`, method `f`. |
438
+ | Handler list | `Lng/a.d`, `Lrg/a.d`. |
439
+ | Foreground scan | `classes5.dex` `Scanner.v`, `Scanner.w`, `PendingIntentScanReceiver$a.a`. |
440
+ | Background scan | `classes4.dex` `workermanager.b` methods `e`, `g`, `f`; `NativeScanWorker`; `NativeConnectWorker`; `ScanIntentReceiver`. |
441
+ | `connectGatt` | `classes5.dex` `Lrq/t.c`. Callback forwarder `Lrq/v`. |
442
+ | CCCD | `classes5.dex` `Lcom/philips/connectivity/bluetooth/adapter/internal/a` `<clinit>`. |
443
+ | Handle characteristics | `CBTSonicareServiceHandler` `<clinit>` and the read methods in section 4.1. |
444
+ | Routine characteristics | `CBTRoutineServiceHandler` `<clinit>`, read/write methods, `C1`. |
445
+ | Storage / SACP | `CBTStorageServiceHandler` fields `k`–`p`, methods `z1`, `r`, `O`, `i`, `t`, `k0`, `y1`, `Q`. |
446
+ | Sensor | `CBTSensorServiceHandler` fields `m`, `n`, methods `i0`, `o1`. |
447
+ | Brush head | `CBTBrushHeadServiceHandler` fields `m`–`r`, methods `k`, `Y`, `o0`, `h0`, `p`, `l0`. |
448
+ | Diagnostic and extended | `CBTDeviceDiagnosticServicesHandler`, `CBTExtendedServiceHandler` method `I`. |
449
+ | Battery `180F` / `2A19` | `Lfr/b.<clinit>`, `g1`, `b0`, `h1`. |
450
+ | Device Information | `DeviceInformationServiceHandler` `<clinit>`, `p1` / `q1` / `r1`, companions `a` / `b` / `c`. |
451
+ | `createBond` | `classes5.dex` method id 68, caller `Lwq/n.d` at pc 118. |
452
+ | Bond initiator `NONE` | `AndroidPeripheral.<init>`, `Lwq/d.d`, `internal/c.m` callers `Lwq/d.d` and `Lwq/d.m`. |
453
+ | Pairing feature flag | `assets/handle_feature.json` keys `isSupportBtPairing`, `isRPASupported`. |
454
+ | `a651` and `e50b` | `ByteStreamingServiceHandler$a.a` and `$a.b`. |
455
+ | Pairing broadcast | `AndroidCentral$pairingRequestBroadcastReceiver$1`, action `PAIRING_REQUEST`. |
@@ -0,0 +1,31 @@
1
+ [build-system]
2
+ requires = ["hatchling>=1.27"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "openble-sonicare"
7
+ version = "0.1.0"
8
+ description = "Philips Sonicare toothbrush GATT map and advertisement matching."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "Kadima Tech" }]
14
+ keywords = ["bluetooth", "ble", "sonicare", "philips", "toothbrush"]
15
+ classifiers = [
16
+ "Development Status :: 3 - Alpha",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Operating System :: OS Independent",
20
+ "Programming Language :: Python :: 3",
21
+ "Topic :: Home Automation",
22
+ "Topic :: System :: Hardware",
23
+ ]
24
+ dependencies = []
25
+
26
+ [project.urls]
27
+ Source = "https://github.com/kadima-tech/openble/tree/main/packages/openble-sonicare"
28
+ Issues = "https://github.com/kadima-tech/openble/issues"
29
+
30
+ [tool.hatch.build.targets.wheel]
31
+ packages = ["src/openble"]
@@ -0,0 +1,5 @@
1
+ """Philips Sonicare toothbrush GATT map and advertisement matching.
2
+
3
+ ``uuids`` names the characteristics the protocol manual records. Most byte
4
+ layouts are still open; see docs/sonicare-toothbrush-protocol.md.
5
+ """
@@ -0,0 +1,23 @@
1
+ """Match an advertisement to a Sonicare toothbrush."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from openble.sonicare.uuids import CONDOR_SCAN_SERVICE, SONICARE_SCAN_SERVICE
6
+
7
+
8
+ def classify(name: str, service_uuids: list[str]) -> tuple[list[str], list[str]]:
9
+ """Return the kinds (``toothbrush``, ``toothbrush-condor``) and the reasons."""
10
+ kinds: list[str] = []
11
+ reasons: list[str] = []
12
+ lowered = {item.lower() for item in service_uuids}
13
+ if SONICARE_SCAN_SERVICE in lowered:
14
+ kinds.append("toothbrush")
15
+ reasons.append("service 477ea600-…0001")
16
+ if "philips sonicare" in name.lower():
17
+ if "toothbrush" not in kinds:
18
+ kinds.append("toothbrush")
19
+ reasons.append("name Philips Sonicare")
20
+ if CONDOR_SCAN_SERVICE in lowered:
21
+ kinds.append("toothbrush-condor")
22
+ reasons.append("service e50ba3c0-…")
23
+ return kinds, reasons
@@ -0,0 +1,62 @@
1
+ """UUIDs named in the Sonicare protocol manual.
2
+
3
+ Sonicare suffixes are the last four hex digits of
4
+ 477ea600-a260-11e4-ae37-0002a5d5. Comparison is case-insensitive.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ CBT_PREFIX = "477ea600-a260-11e4-ae37-0002a5d5"
10
+
11
+ SONICARE_SCAN_SERVICE = CBT_PREFIX + "0001"
12
+ CONDOR_SCAN_SERVICE = "e50ba3c0-af04-4564-92ad-fef019489de6"
13
+
14
+ # suffix -> (name, value kind). Kinds: hex, int, utf8.
15
+ # int means the manual says the app parses an integer. Endianness is not recorded.
16
+ CBT_CHARS = {
17
+ "4010": ("handle state", "hex"),
18
+ "4050": ("handle time", "int"),
19
+ "4020": ("available brushing routines", "hex"),
20
+ "4022": ("routine ids", "hex"),
21
+ "4040": ("cumulative motor runtime", "hex"),
22
+ "4090": ("brushing time", "hex"),
23
+ "4080": ("routine id", "hex"),
24
+ "4091": ("routine length", "hex"),
25
+ "4070": ("current session id", "hex"),
26
+ "40b0": ("intensity", "hex"),
27
+ "4082": ("routine state", "hex"),
28
+ "40d0": ("latest session id", "int"),
29
+ "40d5": ("session type", "hex"),
30
+ "40e0": ("active session id", "hex"),
31
+ "4110": ("sacp", "hex"),
32
+ "4100": ("session data", "hex"),
33
+ "40d2": ("available session count", "hex"),
34
+ "4130": ("sensor notification", "hex"),
35
+ "4120": ("selected sensor data", "hex"),
36
+ "4210": ("brush head 4210", "hex"),
37
+ "4230": ("brush head serial or nfc tag version", "hex"),
38
+ "4220": ("factory mode", "hex"),
39
+ "4280": ("lifetime limit", "hex"),
40
+ "4290": ("lifetime usage", "hex"),
41
+ "42c0": ("ring id", "hex"),
42
+ "4310": ("error value persistent", "hex"),
43
+ "4320": ("error value volatile", "hex"),
44
+ "4330": ("proximity", "hex"),
45
+ "4340": ("external flash", "hex"),
46
+ "4350": ("pressure sensor", "hex"),
47
+ "4360": ("invalid charger count", "hex"),
48
+ "4370": ("debug information", "hex"),
49
+ "4420": ("handle settings", "hex"),
50
+ }
51
+
52
+
53
+ def cbt(suffix: str) -> str:
54
+ return CBT_PREFIX + suffix.lower()
55
+
56
+
57
+ def cbt_suffix(uuid: str) -> str | None:
58
+ """Return the four-digit Sonicare suffix, or None for any other uuid."""
59
+ text = uuid.lower()
60
+ if text.startswith(CBT_PREFIX):
61
+ return text[-4:]
62
+ return None
@@ -0,0 +1,38 @@
1
+ import unittest
2
+
3
+ from openble.sonicare.identify import classify
4
+ from openble.sonicare.uuids import CONDOR_SCAN_SERVICE, cbt, cbt_suffix
5
+
6
+
7
+ class ClassifyTests(unittest.TestCase):
8
+ def test_sonicare_service_and_name(self):
9
+ kinds, reasons = classify("Philips Sonicare", [cbt("0001")])
10
+ self.assertEqual(kinds, ["toothbrush"])
11
+ self.assertEqual(len(reasons), 2)
12
+
13
+ def test_name_alone(self):
14
+ kinds, _reasons = classify("Philips Sonicare", [])
15
+ self.assertEqual(kinds, ["toothbrush"])
16
+
17
+ def test_condor_service(self):
18
+ kinds, _reasons = classify("", [CONDOR_SCAN_SERVICE.upper()])
19
+ self.assertEqual(kinds, ["toothbrush-condor"])
20
+
21
+ def test_other_ble_device_is_ignored(self):
22
+ kinds, reasons = classify(
23
+ "Headphones", ["0000180f-0000-1000-8000-00805f9b34fb"]
24
+ )
25
+ self.assertEqual(kinds, [])
26
+ self.assertEqual(reasons, [])
27
+
28
+
29
+ class UuidTests(unittest.TestCase):
30
+ def test_suffix_round_trip(self):
31
+ self.assertEqual(cbt_suffix(cbt("40D0").upper()), "40d0")
32
+
33
+ def test_suffix_of_other_uuid(self):
34
+ self.assertIsNone(cbt_suffix("0000180f-0000-1000-8000-00805f9b34fb"))
35
+
36
+
37
+ if __name__ == "__main__":
38
+ unittest.main()