mirror of
https://codeberg.org/Freeyourgadget/Gadgetbridge.git
synced 2026-07-27 22:10:40 +02:00
Add Ollee Watch One support
Ollee Watch One is a Nordic-UART (nRF) analog smartwatch. This adds a new
all-Kotlin `ollee` device family providing:
- time + location sync
- a single alarm (weekday repetition; one-shot mapped to its next-fire
weekday, as the watch has no true single-fire mode)
- world time (one zone, with the world-time face enable and offset register)
- notification count badge
- battery voltage
- activity step sync (on-watch record drain -> activity samples)
The device family follows the existing all-Kotlin coordinator/support
pattern: OlleeDeviceCoordinator (capabilities + discovery), a thin
OlleeDeviceSupport, and per-feature pure-logic files (frame codec, alarm
record builder, weekday/world-time register composer, faces table, activity
records codec), each covered by JVM unit tests.
The wire protocol was worked out clean-room from HCI snoop logs and BLE
probes of my own watch and companion watch faces; nothing here derives from
decompiled or proprietary sources.
Hardware-verified on an Ollee Watch One (firmware 01.05.0000.01.10):
connect/init, battery voltage, firmware version, time+location sync,
activity step sync, world time, and the alarm (set, acknowledged by the
watch, shown on the Alarm face, and physically ringing at the set time).
This commit is contained in:
committed by
José Rebelo
parent
fe3770a79e
commit
fd1ffa28da
@@ -240,6 +240,8 @@ public class GBDaoGenerator {
|
||||
addUltrahumanActivitySample(schema, user, device);
|
||||
sampleProvidersToGenerate.add(addUltrahumanDeviceStateSample(schema, user, device));
|
||||
|
||||
addOlleeActivitySample(schema, user, device);
|
||||
|
||||
Entity huaweiWorkoutSummary = addHuaweiWorkoutSummarySample(schema, user, device);
|
||||
addHuaweiWorkoutSummaryAdditionalValuesSample(schema, huaweiWorkoutSummary);
|
||||
addHuaweiWorkoutDataSample(schema, huaweiWorkoutSummary);
|
||||
@@ -1324,6 +1326,15 @@ public class GBDaoGenerator {
|
||||
return activitySample;
|
||||
}
|
||||
|
||||
private static Entity addOlleeActivitySample(Schema schema, Entity user, Entity device) {
|
||||
Entity activitySample = addEntity(schema, "OlleeActivitySample");
|
||||
activitySample.implementsSerializable();
|
||||
addCommonActivitySampleProperties("AbstractActivitySample", activitySample, user, device);
|
||||
activitySample.addIntProperty(SAMPLE_RAW_KIND).notNull().codeBeforeGetterAndSetter(OVERRIDE);
|
||||
activitySample.addIntProperty(SAMPLE_STEPS).notNull().codeBeforeGetterAndSetter(OVERRIDE);
|
||||
return activitySample;
|
||||
}
|
||||
|
||||
private static Entity addLefunActivitySample(Schema schema, Entity user, Entity device) {
|
||||
Entity activitySample = addEntity(schema, "LefunActivitySample");
|
||||
activitySample.implementsSerializable();
|
||||
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.devices.ollee
|
||||
|
||||
import de.greenrobot.dao.AbstractDao
|
||||
import de.greenrobot.dao.Property
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractSampleProvider
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.OlleeActivitySample
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.OlleeActivitySampleDao
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
|
||||
|
||||
class OlleeActivitySampleProvider(device: GBDevice, session: DaoSession) :
|
||||
AbstractSampleProvider<OlleeActivitySample>(device, session) {
|
||||
|
||||
override fun normalizeType(rawType: Int): ActivityKind = ActivityKind.fromCode(rawType)
|
||||
|
||||
override fun toRawActivityKind(activityKind: ActivityKind): Int = activityKind.code
|
||||
|
||||
override fun normalizeIntensity(rawIntensity: Int): Float = rawIntensity.toFloat()
|
||||
|
||||
override fun createActivitySample(): OlleeActivitySample = OlleeActivitySample()
|
||||
|
||||
override fun getSampleDao(): AbstractDao<OlleeActivitySample, *> = getSession().getOlleeActivitySampleDao()
|
||||
|
||||
override fun getRawKindSampleProperty(): Property? = OlleeActivitySampleDao.Properties.RawKind
|
||||
|
||||
override fun getTimestampSampleProperty(): Property = OlleeActivitySampleDao.Properties.Timestamp
|
||||
|
||||
override fun getDeviceIdentifierSampleProperty(): Property = OlleeActivitySampleDao.Properties.DeviceId
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.devices.ollee
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
object OlleeConstants {
|
||||
// Nordic UART Service
|
||||
val UUID_SERVICE_NUS: UUID = UUID.fromString("6e400001-b5a3-f393-e0a9-e50e24dcca9e")
|
||||
/** app -> watch writes */
|
||||
val UUID_CHARACTERISTIC_RX: UUID = UUID.fromString("6e400002-b5a3-f393-e0a9-e50e24dcca9e")
|
||||
/** watch -> app notifies */
|
||||
val UUID_CHARACTERISTIC_TX: UUID = UUID.fromString("6e400003-b5a3-f393-e0a9-e50e24dcca9e")
|
||||
|
||||
const val RESPONSE_TARGET_OFFSET: Int = 0x20
|
||||
|
||||
const val TARGET_SET_CLOCK: Int = 0x23
|
||||
const val TARGET_ALARM: Int = 0x25
|
||||
const val TARGET_GET_ALARM: Int = 0x2B
|
||||
const val TARGET_VERSION: Int = 0x2A
|
||||
const val TARGET_WEEKDAYS: Int = 0x34
|
||||
const val TARGET_GET_WEEKDAYS: Int = 0x35
|
||||
const val TARGET_SET_FACES: Int = 0x36
|
||||
const val TARGET_GET_FACES: Int = 0x37
|
||||
|
||||
// Activity records drain
|
||||
const val TARGET_ACTIVITY_COUNT: Int = 0x27
|
||||
const val TARGET_ACTIVITY_RECORD: Int = 0x28
|
||||
const val TARGET_ACTIVITY_COMMIT: Int = 0x2D
|
||||
|
||||
/** Trailing big-endian uint16 millivolt field inside the 0x4A version reply. */
|
||||
const val VERSION_REPLY_VOLTAGE_OFFSET: Int = 34
|
||||
|
||||
/** World Time face record ID in the 02 36/37 faces table. */
|
||||
const val FACE_ID_WORLD_TIME: Int = 0x06
|
||||
}
|
||||
+78
@@ -0,0 +1,78 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.devices.ollee
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.R
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettings
|
||||
import nodomain.freeyourgadget.gadgetbridge.activities.devicesettings.DeviceSpecificSettingsScreen
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.AbstractBLEDeviceCoordinator
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.DeviceCoordinator
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.SampleProvider
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.AbstractActivitySample
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.DaoSession
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.DeviceSupport
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.devices.ollee.OlleeDeviceSupport
|
||||
import java.util.regex.Pattern
|
||||
|
||||
class OlleeDeviceCoordinator : AbstractBLEDeviceCoordinator() {
|
||||
override fun getSupportedDeviceName(): Pattern {
|
||||
// The watch advertises as "Ollee Watch" (verified on hardware)
|
||||
return Pattern.compile("^Ollee.*")
|
||||
}
|
||||
|
||||
override fun getManufacturer(): String = "Ollee"
|
||||
|
||||
override fun getDeviceKind(device: GBDevice): DeviceCoordinator.DeviceKind =
|
||||
DeviceCoordinator.DeviceKind.WATCH
|
||||
|
||||
override fun getDeviceSupportClass(device: GBDevice): Class<out DeviceSupport> =
|
||||
OlleeDeviceSupport::class.java
|
||||
|
||||
override fun getDeviceNameResource(): Int = R.string.devicetype_ollee_watch_one
|
||||
|
||||
override fun getDefaultIconResource(): Int = R.drawable.ic_device_default
|
||||
|
||||
override fun getBondingStyle(): Int = BONDING_STYLE_NONE
|
||||
|
||||
override fun getAlarmSlotCount(device: GBDevice): Int = 1
|
||||
|
||||
override fun getWorldClocksSlotCount(): Int = 1
|
||||
|
||||
override fun getWorldClocksLabelLength(): Int = 0
|
||||
|
||||
override fun getDeviceSpecificSettings(device: GBDevice): DeviceSpecificSettings {
|
||||
// Surfaces the world-clock configuration screen; without this the single world-clock
|
||||
// slot is unreachable from the UI.
|
||||
val settings = DeviceSpecificSettings()
|
||||
settings.addRootScreen(DeviceSpecificSettingsScreen.DATE_TIME)
|
||||
.add(R.xml.devicesettings_world_clocks)
|
||||
return settings
|
||||
}
|
||||
|
||||
override fun supportsActivityTracking(device: GBDevice): Boolean = true
|
||||
|
||||
override fun supportsDataFetching(device: GBDevice): Boolean = true
|
||||
|
||||
// Defaults for both follow supportsActivityTracking — the watch reports neither.
|
||||
override fun supportsSleepMeasurement(device: GBDevice): Boolean = false
|
||||
|
||||
override fun supportsActivityDistance(device: GBDevice): Boolean = false
|
||||
|
||||
override fun getSampleProvider(device: GBDevice, session: DaoSession): SampleProvider<out AbstractActivitySample> =
|
||||
OlleeActivitySampleProvider(device, session)
|
||||
}
|
||||
@@ -401,6 +401,7 @@ import nodomain.freeyourgadget.gadgetbridge.devices.nothing.Ear2Coordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.nothing.EarACoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.nothing.EarStickCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.nut.NutCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.ollee.OlleeDeviceCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.onemoresonoflow.OneMoreSonoFlowCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.onetouch.OneTouchCoordinator;
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.oppo.OppoEncoAir2Coordinator;
|
||||
@@ -1027,6 +1028,7 @@ public enum DeviceType {
|
||||
IGPSPORT_IGS630S(IGPSportiGS630SCoordinator.class),
|
||||
IGPSPORT_IGS800(IGPSportiGS800Coordinator.class),
|
||||
IGPSPORT_BINAVI_AIR(IGPSportBiNaviAirCoordinator.class),
|
||||
OLLEE_WATCH_ONE(OlleeDeviceCoordinator.class),
|
||||
TEST(TestDeviceCoordinator.class);
|
||||
|
||||
private DeviceCoordinator coordinator;
|
||||
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
object OlleeActivityRecords {
|
||||
|
||||
data class Record(val typeFlags: Long, val startTs: Long, val endTs: Long, val steps: Long)
|
||||
|
||||
/** Parses the 0x47 count reply (u32 BE). Throws if payload shorter than 4 bytes. */
|
||||
fun parseCount(payload: ByteArray): Long {
|
||||
require(payload.size >= 4) { "Payload must be at least 4 bytes, got ${payload.size}" }
|
||||
return readU32BE(payload, 0)
|
||||
}
|
||||
|
||||
/** Parses one 0x48 record reply: exactly 16 bytes = four u32 BE fields. Throws on wrong length. */
|
||||
fun parseRecord(payload: ByteArray): Record {
|
||||
require(payload.size == 16) { "Payload must be exactly 16 bytes, got ${payload.size}" }
|
||||
val typeFlags = readU32BE(payload, 0)
|
||||
val startTs = readU32BE(payload, 4)
|
||||
val endTs = readU32BE(payload, 8)
|
||||
val steps = readU32BE(payload, 12)
|
||||
return Record(typeFlags, startTs, endTs, steps)
|
||||
}
|
||||
|
||||
private fun readU32BE(data: ByteArray, offset: Int): Long {
|
||||
return ((data[offset].toInt() and 0xFF).toLong() shl 24) or
|
||||
((data[offset + 1].toInt() and 0xFF).toLong() shl 16) or
|
||||
((data[offset + 2].toInt() and 0xFF).toLong() shl 8) or
|
||||
(data[offset + 3].toInt() and 0xFF).toLong()
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
object OlleeAlarm {
|
||||
|
||||
/**
|
||||
* 33-byte 02 25 record: 13-byte settings block [enable, hourlyChime, snoozeEnable, hour, minute,
|
||||
* dayMask, chime, snoozeMin, playNow, hourMaskLo, hourMaskMid, hourMaskHi, 0xFF] + 20-byte trailer
|
||||
* (five LE u32 0x0000000C). Watch-only fields copied from [existing] (the 12-byte 0x4B read-back,
|
||||
* or null); [gbRepetitionMask] is GB's Alarm mask (MON=1..SUN=64, 0=once); playNow is always 0.
|
||||
*/
|
||||
fun buildRecord(
|
||||
enabled: Boolean,
|
||||
hour: Int,
|
||||
minute: Int,
|
||||
gbRepetitionMask: Int,
|
||||
snooze: Boolean,
|
||||
existing: ByteArray?
|
||||
): ByteArray {
|
||||
require(hour in 0..23) { "hour must be 0..23 (got $hour)" }
|
||||
require(minute in 0..59) { "minute must be 0..59 (got $minute)" }
|
||||
|
||||
val record = ByteArray(33)
|
||||
|
||||
// [0] enable
|
||||
record[0] = if (enabled) 0x01 else 0x00
|
||||
|
||||
// [1] hourlyChime — preserve from existing, default on
|
||||
record[1] = if (existing != null && existing.size > 1) {
|
||||
existing[1]
|
||||
} else {
|
||||
0x01 // default: hourly chime on
|
||||
}
|
||||
|
||||
// [2] snoozeEnable
|
||||
record[2] = if (snooze) 0x01 else 0x00
|
||||
|
||||
// [3] hour
|
||||
record[3] = hour.toByte()
|
||||
|
||||
// [4] minute
|
||||
record[4] = minute.toByte()
|
||||
|
||||
// [5] dayMask — convert from GB to watch layout
|
||||
record[5] = toWatchDayMask(gbRepetitionMask).toByte()
|
||||
|
||||
// [6] chime — preserve from existing, default 0x00
|
||||
record[6] = if (existing != null && existing.size > 6) {
|
||||
existing[6]
|
||||
} else {
|
||||
0x00 // default: classic chime
|
||||
}
|
||||
|
||||
// [7] snoozeMin — preserve from existing, default 5
|
||||
record[7] = if (existing != null && existing.size > 7) {
|
||||
existing[7]
|
||||
} else {
|
||||
0x05 // default: 5 minute snooze
|
||||
}
|
||||
|
||||
// [8] playNow — always 0 from GB
|
||||
record[8] = 0x00
|
||||
|
||||
// [9] hourMaskLo — preserve from existing, default 0xC0 (6:00-19:00 active-hours mask)
|
||||
record[9] = if (existing != null && existing.size > 8) {
|
||||
existing[8] // read-back byte 8 -> record byte 9
|
||||
} else {
|
||||
0xC0.toByte()
|
||||
}
|
||||
|
||||
// [10] hourMaskMid — preserve from existing, default 0xFF
|
||||
record[10] = if (existing != null && existing.size > 9) {
|
||||
existing[9] // read-back byte 9 -> record byte 10
|
||||
} else {
|
||||
0xFF.toByte()
|
||||
}
|
||||
|
||||
// [11] hourMaskHi — preserve from existing, default 0x0F
|
||||
record[11] = if (existing != null && existing.size > 10) {
|
||||
existing[10] // read-back byte 10 -> record byte 11
|
||||
} else {
|
||||
0x0F
|
||||
}
|
||||
|
||||
// [12] terminator
|
||||
record[12] = 0xFF.toByte()
|
||||
|
||||
// [13..32] trailing block — the official app writes 20 more bytes after the 13-byte
|
||||
// settings block, five little-endian u32 = 0x0000000C each. The watch ignores a 13-byte
|
||||
// (truncated) record; only the full 33-byte record is accepted. (RE 2026-07-12.)
|
||||
for (i in 0 until 5) {
|
||||
record[13 + i * 4] = 0x0C
|
||||
record[14 + i * 4] = 0x00
|
||||
record[15 + i * 4] = 0x00
|
||||
record[16 + i * 4] = 0x00
|
||||
}
|
||||
|
||||
return record
|
||||
}
|
||||
|
||||
/**
|
||||
* GB day-mask (MON=1..SUN=64) -> watch layout (bit1=Mon..bit7=Sun, 1=active, bit0 unused).
|
||||
* A single left shift: watch_mask = gb_mask << 1.
|
||||
*/
|
||||
fun toWatchDayMask(gbRepetitionMask: Int): Int {
|
||||
return gbRepetitionMask shl 1
|
||||
}
|
||||
|
||||
/**
|
||||
* Watch day-mask for a one-shot alarm (GB repetition 0). The watch has no single-fire mode
|
||||
* (mask 0x00 = never rings), so this arms the next-fire weekday — today if HH:MM is still ahead,
|
||||
* else tomorrow; repeats weekly until changed. [nowDayOfWeekIso] is ISO 1=Mon..7=Sun.
|
||||
*/
|
||||
fun oneShotWatchDayMask(hour: Int, minute: Int, nowDayOfWeekIso: Int, nowHour: Int, nowMinute: Int): Int {
|
||||
require(nowDayOfWeekIso in 1..7) { "ISO day of week must be 1..7 (got $nowDayOfWeekIso)" }
|
||||
val firesToday = hour > nowHour || (hour == nowHour && minute > nowMinute)
|
||||
val fireDayIso = if (firesToday) nowDayOfWeekIso else (nowDayOfWeekIso % 7) + 1
|
||||
return 1 shl fireDayIso // watch layout: bit1=Mon..bit7=Sun
|
||||
}
|
||||
}
|
||||
+363
@@ -0,0 +1,363 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
import android.bluetooth.BluetoothGatt
|
||||
import android.bluetooth.BluetoothGattCharacteristic
|
||||
import nodomain.freeyourgadget.gadgetbridge.GBApplication
|
||||
import nodomain.freeyourgadget.gadgetbridge.R
|
||||
import nodomain.freeyourgadget.gadgetbridge.database.DBHelper
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventBatteryInfo
|
||||
import nodomain.freeyourgadget.gadgetbridge.deviceevents.GBDeviceEventVersionInfo
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.ollee.OlleeActivitySampleProvider
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.ollee.OlleeConstants
|
||||
import nodomain.freeyourgadget.gadgetbridge.entities.OlleeActivitySample
|
||||
import nodomain.freeyourgadget.gadgetbridge.impl.GBDevice
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.ActivityKind
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.Alarm
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.NotificationSpec
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.RecordedDataTypes
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.WorldClock
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.AbstractBTLESingleDeviceSupport
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.TransactionBuilder
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.GB
|
||||
import nodomain.freeyourgadget.gadgetbridge.webview.CurrentPosition
|
||||
import org.slf4j.LoggerFactory
|
||||
import java.util.Calendar
|
||||
import java.util.TimeZone
|
||||
|
||||
class OlleeDeviceSupport : AbstractBTLESingleDeviceSupport(LOG) {
|
||||
private val reassembler = OlleeFrameReassembler()
|
||||
private val composer = WeekdayRegisterComposer()
|
||||
private val activeNotifications = mutableSetOf<Int>()
|
||||
private var lastAlarmReadback: ByteArray? = null
|
||||
private var lastFacesReadback: ByteArray? = null
|
||||
|
||||
// The 02 34 register carries the World Time offset alongside the badge text; until the
|
||||
// 0x55 read-back seeds the composer's offset, any register write would push offset 0 and
|
||||
// silently reset the watch's World Time. Gates pushBadge(), not just the init sequence.
|
||||
private var weekdaysSeeded = false
|
||||
|
||||
// True while the lockstep init read chain (version -> alarm -> weekdays -> faces) runs.
|
||||
private var initReadChain = false
|
||||
|
||||
// Activity records drain (02 27 count -> 02 28 x N -> persist -> 02 2D commit). Reads are
|
||||
// non-destructive until the 2D commit (probe-verified), so aborting mid-drain is safe.
|
||||
private var draining = false
|
||||
private var drainExpected = 0L
|
||||
private val drainRecords = mutableListOf<OlleeActivityRecords.Record>()
|
||||
|
||||
init {
|
||||
addSupportedService(OlleeConstants.UUID_SERVICE_NUS)
|
||||
}
|
||||
|
||||
override fun useAutoConnect(): Boolean = true
|
||||
|
||||
override fun initializeDevice(builder: TransactionBuilder): TransactionBuilder {
|
||||
reassembler.reset()
|
||||
activeNotifications.clear()
|
||||
composer.badgeCount = 0
|
||||
weekdaysSeeded = false
|
||||
draining = false
|
||||
drainExpected = 0
|
||||
drainRecords.clear()
|
||||
builder.setDeviceState(GBDevice.State.INITIALIZING)
|
||||
builder.notify(OlleeConstants.UUID_CHARACTERISTIC_TX, true)
|
||||
// The watch silently drops reads that arrive while a reply is in flight (hardware: of four
|
||||
// pipelined reads only the first 2-3 answer). So reads run in lockstep — each readback
|
||||
// handler in handleFrame() issues the next init read. Writes are fire-and-forget.
|
||||
initReadChain = true
|
||||
writeFrame(builder, OlleeProtocol.readRequest(OlleeConstants.TARGET_VERSION))
|
||||
if (GBApplication.getPrefs().syncTime()) {
|
||||
sendSetClock(builder)
|
||||
}
|
||||
builder.setDeviceState(GBDevice.State.INITIALIZED)
|
||||
return builder
|
||||
}
|
||||
|
||||
override fun onSetTime() {
|
||||
if (!GBApplication.getPrefs().syncTime()) return
|
||||
val builder = createTransactionBuilder("set clock")
|
||||
sendSetClock(builder)
|
||||
builder.queue()
|
||||
}
|
||||
|
||||
private fun sendSetClock(builder: TransactionBuilder) {
|
||||
val nowMs = System.currentTimeMillis()
|
||||
val location = CurrentPosition().lastKnownLocation
|
||||
writeFrame(builder, OlleeProtocol.buildSetClock(
|
||||
nowEpochSec = nowMs / 1000L,
|
||||
utcOffsetSec = TimeZone.getDefault().getOffset(nowMs) / 1000,
|
||||
latE3 = (location.latitude * 1000).toInt(),
|
||||
lonE3 = (location.longitude * 1000).toInt()
|
||||
))
|
||||
}
|
||||
|
||||
override fun onCharacteristicChanged(
|
||||
gatt: BluetoothGatt,
|
||||
characteristic: BluetoothGattCharacteristic,
|
||||
value: ByteArray
|
||||
): Boolean {
|
||||
if (characteristic.uuid != OlleeConstants.UUID_CHARACTERISTIC_TX) return super.onCharacteristicChanged(gatt, characteristic, value)
|
||||
// The watch pipelines replies; one fragment can complete more than one frame.
|
||||
var frame = reassembler.accept(value)
|
||||
while (frame != null) {
|
||||
handleFrame(frame)
|
||||
frame = reassembler.pending()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun handleFrame(frame: Frame) {
|
||||
when (frame.target) {
|
||||
OlleeConstants.TARGET_VERSION + OlleeConstants.RESPONSE_TARGET_OFFSET -> {
|
||||
handleVersionReply(frame.payload)
|
||||
if (initReadChain) sendRead("alarm readback", OlleeConstants.TARGET_GET_ALARM)
|
||||
}
|
||||
OlleeConstants.TARGET_GET_ALARM + OlleeConstants.RESPONSE_TARGET_OFFSET -> {
|
||||
LOG.debug("Alarm readback ({} bytes)", frame.payload.size)
|
||||
lastAlarmReadback = frame.payload
|
||||
if (initReadChain) sendRead("weekday register", OlleeConstants.TARGET_GET_WEEKDAYS)
|
||||
}
|
||||
OlleeConstants.TARGET_GET_WEEKDAYS + OlleeConstants.RESPONSE_TARGET_OFFSET -> {
|
||||
LOG.debug("Weekday register readback ({} bytes)", frame.payload.size)
|
||||
composer.seedFromReadback(frame.payload)
|
||||
weekdaysSeeded = true
|
||||
// Offset now seeded: safe to restore the weekday table (clears any stale badge
|
||||
// left on the watch from before the reconnect).
|
||||
pushBadge()
|
||||
if (initReadChain) sendRead("faces table", OlleeConstants.TARGET_GET_FACES)
|
||||
}
|
||||
OlleeConstants.TARGET_GET_FACES + OlleeConstants.RESPONSE_TARGET_OFFSET -> {
|
||||
LOG.debug("Faces table readback ({} bytes)", frame.payload.size)
|
||||
lastFacesReadback = frame.payload
|
||||
initReadChain = false
|
||||
}
|
||||
OlleeConstants.TARGET_ACTIVITY_COUNT + OlleeConstants.RESPONSE_TARGET_OFFSET ->
|
||||
handleActivityCount(frame.payload)
|
||||
OlleeConstants.TARGET_ACTIVITY_RECORD + OlleeConstants.RESPONSE_TARGET_OFFSET ->
|
||||
handleActivityRecord(frame.payload)
|
||||
OlleeConstants.TARGET_ACTIVITY_COMMIT + OlleeConstants.RESPONSE_TARGET_OFFSET ->
|
||||
LOG.debug("Activity commit acknowledged") // not sent by current firmware
|
||||
else -> LOG.debug("Unhandled Ollee frame 0x{}", Integer.toHexString(frame.target))
|
||||
}
|
||||
}
|
||||
|
||||
override fun onFetchRecordedData(dataTypes: Int) {
|
||||
if (dataTypes and RecordedDataTypes.TYPE_ACTIVITY == 0) return
|
||||
if (draining) return
|
||||
draining = true
|
||||
drainExpected = 0
|
||||
drainRecords.clear()
|
||||
device.setBusyTask(R.string.busy_task_fetch_activity_data, context)
|
||||
device.sendDeviceUpdateIntent(context)
|
||||
sendRead("activity count", OlleeConstants.TARGET_ACTIVITY_COUNT)
|
||||
}
|
||||
|
||||
private fun handleActivityCount(payload: ByteArray) {
|
||||
if (!draining) return
|
||||
drainExpected = try {
|
||||
OlleeActivityRecords.parseCount(payload)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
LOG.error("Bad activity count payload", e)
|
||||
endDrain()
|
||||
return
|
||||
}
|
||||
LOG.debug("Activity drain: {} pending records", drainExpected)
|
||||
if (drainExpected == 0L) {
|
||||
endDrain()
|
||||
} else {
|
||||
sendRead("activity record", OlleeConstants.TARGET_ACTIVITY_RECORD)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleActivityRecord(payload: ByteArray) {
|
||||
if (!draining) return
|
||||
val record = try {
|
||||
OlleeActivityRecords.parseRecord(payload)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
// Abort without the 2D commit: the watch queue is untouched and the next
|
||||
// drain re-delivers everything.
|
||||
LOG.error("Bad activity record payload", e)
|
||||
endDrain()
|
||||
return
|
||||
}
|
||||
if (record.typeFlags != 0L) {
|
||||
LOG.info("Activity record with unknown type/flags 0x{}", java.lang.Long.toHexString(record.typeFlags))
|
||||
}
|
||||
drainRecords.add(record)
|
||||
if (drainRecords.size < drainExpected) {
|
||||
sendRead("activity record", OlleeConstants.TARGET_ACTIVITY_RECORD)
|
||||
} else if (persistActivitySamples(drainRecords)) {
|
||||
// Persisted first — only now is it safe to let the watch clear its queue.
|
||||
LOG.info("Activity drain complete ({} records persisted)", drainRecords.size)
|
||||
sendRead("activity commit", OlleeConstants.TARGET_ACTIVITY_COMMIT)
|
||||
// The commit is not acknowledged on current firmware (verified on hardware),
|
||||
// so the drain ends once the commit is queued.
|
||||
endDrain()
|
||||
} else {
|
||||
endDrain()
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistActivitySamples(records: List<OlleeActivityRecords.Record>): Boolean {
|
||||
try {
|
||||
GBApplication.acquireDB().use { dbHandler ->
|
||||
val session = dbHandler.daoSession
|
||||
val dbUser = DBHelper.getUser(session)
|
||||
val dbDevice = DBHelper.getDevice(device, session)
|
||||
val provider = OlleeActivitySampleProvider(device, session)
|
||||
val samples = records.map { record ->
|
||||
OlleeActivitySample().apply {
|
||||
timestamp = record.startTs.toInt()
|
||||
steps = record.steps.toInt()
|
||||
// Idle intervals arrive as zero-step records — don't paint them
|
||||
// as activity on the charts.
|
||||
rawKind = if (record.steps > 0) {
|
||||
ActivityKind.ACTIVITY.code
|
||||
} else {
|
||||
ActivityKind.NOT_MEASURED.code
|
||||
}
|
||||
setDevice(dbDevice)
|
||||
setUser(dbUser)
|
||||
setProvider(provider)
|
||||
}
|
||||
}
|
||||
provider.addGBActivitySamples(samples)
|
||||
}
|
||||
return true
|
||||
} catch (e: Exception) {
|
||||
LOG.error("Error saving activity samples", e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun endDrain() {
|
||||
draining = false
|
||||
drainExpected = 0
|
||||
drainRecords.clear()
|
||||
if (device.isBusy) {
|
||||
device.unsetBusyTask()
|
||||
device.sendDeviceUpdateIntent(context)
|
||||
}
|
||||
GB.signalActivityDataFinish(device)
|
||||
}
|
||||
|
||||
/** Writes [frame] to the RX characteristic, fragmented to the connection's max ATT chunk. */
|
||||
private fun writeFrame(builder: TransactionBuilder, frame: ByteArray) {
|
||||
builder.writeChunkedData(
|
||||
getCharacteristic(OlleeConstants.UUID_CHARACTERISTIC_RX),
|
||||
frame,
|
||||
OlleeProtocol.MAX_ATT_CHUNK
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendRead(taskName: String, target: Int) {
|
||||
val builder = createTransactionBuilder(taskName)
|
||||
writeFrame(builder, OlleeProtocol.readRequest(target))
|
||||
builder.queue()
|
||||
}
|
||||
|
||||
override fun onSetAlarms(alarms: ArrayList<out Alarm>) {
|
||||
val alarm = alarms.firstOrNull() ?: return
|
||||
// A deleted alarm arrives as unused (possibly still flagged enabled) — write it disabled.
|
||||
val enabled = alarm.enabled && !alarm.unused
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = enabled,
|
||||
hour = alarm.hour,
|
||||
minute = alarm.minute,
|
||||
gbRepetitionMask = alarm.repetition,
|
||||
snooze = alarm.snooze,
|
||||
existing = lastAlarmReadback
|
||||
)
|
||||
if (enabled && alarm.repetition == 0) {
|
||||
// One-shot: watch mask 0x00 would disable the alarm outright — arm the next-fire
|
||||
// weekday instead (rings weekly until changed; documented device limitation).
|
||||
val now = Calendar.getInstance()
|
||||
val isoDay = ((now.get(Calendar.DAY_OF_WEEK) + 5) % 7) + 1 // SUNDAY=1.. -> ISO 1=Mon..7=Sun
|
||||
record[5] = OlleeAlarm.oneShotWatchDayMask(
|
||||
alarm.hour, alarm.minute, isoDay,
|
||||
now.get(Calendar.HOUR_OF_DAY), now.get(Calendar.MINUTE)
|
||||
).toByte()
|
||||
}
|
||||
val builder = createTransactionBuilder("set alarm")
|
||||
writeFrame(builder, OlleeProtocol.buildPacket(OlleeConstants.TARGET_ALARM, record))
|
||||
writeFrame(builder, OlleeProtocol.readRequest(OlleeConstants.TARGET_GET_ALARM)) // refresh RMW cache
|
||||
builder.queue()
|
||||
}
|
||||
|
||||
override fun onSetWorldClocks(clocks: ArrayList<out WorldClock>) {
|
||||
val clock = clocks.firstOrNull { it.enabled == true }
|
||||
val builder = createTransactionBuilder("set world clock")
|
||||
if (clock != null) {
|
||||
val tz = TimeZone.getTimeZone(clock.timeZoneId)
|
||||
composer.worldTimeOffsetSec = tz.getOffset(System.currentTimeMillis()) / 1000
|
||||
setWorldTimeFace(builder, enabled = true)
|
||||
writeFrame(builder, OlleeProtocol.buildPacket(OlleeConstants.TARGET_WEEKDAYS, composer.composePayload()))
|
||||
} else {
|
||||
// Zone removed: hide the World Time face. The stale offset in the register is
|
||||
// harmless once the face is hidden, so no register write is needed.
|
||||
setWorldTimeFace(builder, enabled = false)
|
||||
}
|
||||
builder.queue()
|
||||
}
|
||||
|
||||
/** Flips the World Time face via faces-table RMW; no-op when already in [enabled] state. */
|
||||
private fun setWorldTimeFace(builder: TransactionBuilder, enabled: Boolean) {
|
||||
val faces = lastFacesReadback ?: return
|
||||
val current = OlleeFacesTable.isFaceEnabled(faces, OlleeConstants.FACE_ID_WORLD_TIME) ?: return
|
||||
if (current == enabled) return
|
||||
val updated = OlleeFacesTable.withFaceEnabled(faces, OlleeConstants.FACE_ID_WORLD_TIME, enabled)
|
||||
writeFrame(builder, OlleeProtocol.buildPacket(OlleeConstants.TARGET_SET_FACES, updated))
|
||||
lastFacesReadback = updated
|
||||
}
|
||||
|
||||
override fun onNotification(notificationSpec: NotificationSpec) {
|
||||
if (activeNotifications.add(notificationSpec.id)) pushBadge()
|
||||
}
|
||||
|
||||
override fun onDeleteNotification(id: Int) {
|
||||
// Dismissals for notifications we never tracked (predating the connection) are no-ops.
|
||||
if (activeNotifications.remove(id)) pushBadge()
|
||||
}
|
||||
|
||||
private fun pushBadge() {
|
||||
if (!weekdaysSeeded) return // seed handler pushes the pending count once the offset is known
|
||||
composer.badgeCount = activeNotifications.size
|
||||
val builder = createTransactionBuilder("badge ${composer.badgeCount}")
|
||||
writeFrame(builder, OlleeProtocol.buildPacket(OlleeConstants.TARGET_WEEKDAYS, composer.composePayload()))
|
||||
builder.queue()
|
||||
}
|
||||
|
||||
private fun handleVersionReply(payload: ByteArray) {
|
||||
OlleeProtocol.parseFirmwareVersion(payload)?.let { fw ->
|
||||
val version = GBDeviceEventVersionInfo()
|
||||
version.fwVersion = fw
|
||||
evaluateGBDeviceEvent(version)
|
||||
}
|
||||
OlleeProtocol.parseVoltageMillivolts(payload)?.let { mv ->
|
||||
val battery = GBDeviceEventBatteryInfo()
|
||||
battery.level = GBDevice.BATTERY_UNKNOWN.toInt()
|
||||
battery.voltage = mv / 1000f
|
||||
evaluateGBDeviceEvent(battery)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG = LoggerFactory.getLogger(OlleeDeviceSupport::class.java)
|
||||
}
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
/**
|
||||
* 02 37 read / 02 36 write faces table: header then 6-byte records [ID, 01, ENABLED, 01, ??, SLOT].
|
||||
* Read-modify-write helper: parse a read-back payload, flip one face's ENABLED byte, write back.
|
||||
*/
|
||||
object OlleeFacesTable {
|
||||
|
||||
private const val HEADER_SIZE = 6
|
||||
private const val RECORD_SIZE = 6
|
||||
private const val RECORD_ID_INDEX = 0
|
||||
private const val RECORD_ENABLED_INDEX = 2
|
||||
|
||||
/**
|
||||
* Copy of [getFacesReplyPayload] (from a 02 37 read) with [faceId]'s ENABLED byte set to
|
||||
* [enabled]; all other bytes identical. Returned unchanged if [faceId] has no record.
|
||||
*/
|
||||
fun withFaceEnabled(getFacesReplyPayload: ByteArray, faceId: Int, enabled: Boolean): ByteArray {
|
||||
val enabledByte = if (enabled) 0x01.toByte() else 0x00.toByte()
|
||||
|
||||
// Start with a copy of the payload
|
||||
val result = getFacesReplyPayload.copyOf()
|
||||
|
||||
// Iterate through records starting after the 6-byte header
|
||||
var offset = HEADER_SIZE
|
||||
while (offset + RECORD_SIZE <= result.size) {
|
||||
val recordId = result[offset + RECORD_ID_INDEX].toInt() and 0xFF
|
||||
if (recordId == faceId) {
|
||||
// Found the matching record; flip the ENABLED byte
|
||||
result[offset + RECORD_ENABLED_INDEX] = enabledByte
|
||||
return result
|
||||
}
|
||||
offset += RECORD_SIZE
|
||||
}
|
||||
|
||||
// Face not found; return the payload unchanged
|
||||
return result
|
||||
}
|
||||
|
||||
/** ENABLED state of [faceId] in a 02 37 payload; null if no record or payload too short. */
|
||||
fun isFaceEnabled(getFacesReplyPayload: ByteArray, faceId: Int): Boolean? {
|
||||
// Iterate through records starting after the 6-byte header
|
||||
var offset = HEADER_SIZE
|
||||
while (offset + RECORD_SIZE <= getFacesReplyPayload.size) {
|
||||
val recordId = getFacesReplyPayload[offset + RECORD_ID_INDEX].toInt() and 0xFF
|
||||
if (recordId == faceId) {
|
||||
// Found the matching record; read the ENABLED byte
|
||||
val enabledByte = getFacesReplyPayload[offset + RECORD_ENABLED_INDEX].toInt() and 0xFF
|
||||
return enabledByte != 0x00
|
||||
}
|
||||
offset += RECORD_SIZE
|
||||
}
|
||||
|
||||
// Face not found (either unknown ID or incomplete record); return null
|
||||
return null
|
||||
}
|
||||
}
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.devices.ollee.OlleeConstants
|
||||
import nodomain.freeyourgadget.gadgetbridge.service.btle.BLETypeConversions
|
||||
import nodomain.freeyourgadget.gadgetbridge.util.CheckSums
|
||||
import org.slf4j.LoggerFactory
|
||||
|
||||
object OlleeProtocol {
|
||||
|
||||
/** Requested bytes per characteristic write, passed to TransactionBuilder.writeChunkedData. */
|
||||
const val MAX_ATT_CHUNK: Int = 20
|
||||
|
||||
/** Set-clock payload field offsets (all fields little-endian) */
|
||||
private const val NOW_SEC_OFFSET = 0
|
||||
private const val OFFSET_SEC_OFFSET = 4
|
||||
private const val LAT_E3_OFFSET = 8
|
||||
private const val LON_E3_OFFSET = 12
|
||||
private const val CONSTANT_FLAG_OFFSET = 16
|
||||
private const val CONSTANT_WORD_OFFSET = 18
|
||||
private const val SET_CLOCK_PAYLOAD_SIZE = 20
|
||||
|
||||
/** Constant values in set-clock payload */
|
||||
private const val CONSTANT_FLAG = 0x0003
|
||||
private const val CONSTANT_WORD = 0xFFFF
|
||||
|
||||
/** Byte manipulation constants */
|
||||
private const val BYTE_MASK = 0xFF
|
||||
private const val SHIFT_BYTE_1 = 8
|
||||
|
||||
/** Minimum frame length: 00, len, AA, 55, crcHi, crcLo, cmd, target */
|
||||
internal const val MIN_FRAME_LENGTH = 8
|
||||
|
||||
/** CRC-16/CCITT-FALSE (init 0xFFFF) over the input bytes, via CheckSums.getCRC16. */
|
||||
fun crc16(data: ByteArray): Int = CheckSums.getCRC16(data)
|
||||
|
||||
/**
|
||||
* Frames raw [payload] to [target]: 00 len AA 55 crcHi crcLo 02 target payload…
|
||||
* (len = inner.size + 4; CRC-16/CCITT-FALSE over the inner bytes).
|
||||
*/
|
||||
fun buildPacket(target: Int, payload: ByteArray): ByteArray {
|
||||
require(target in 0..0xFF) { "target must be a single byte (got $target)" }
|
||||
|
||||
val inner = byteArrayOf(0x02, target.toByte()) + payload
|
||||
// LEN is a single byte; guard against a payload large enough to truncate it
|
||||
require(inner.size + 4 <= 0xFF) { "payload too large for single-byte LEN (inner ${inner.size})" }
|
||||
val crc = crc16(inner)
|
||||
|
||||
return byteArrayOf(
|
||||
0x00,
|
||||
(inner.size + 4).toByte(),
|
||||
0xaa.toByte(),
|
||||
0x55,
|
||||
(crc shr 8).toByte(),
|
||||
(crc and 0xFF).toByte()
|
||||
) + inner
|
||||
}
|
||||
|
||||
/** The 02 <target> read-request frame (no payload). Reply arrives with target +0x20. */
|
||||
fun readRequest(target: Int): ByteArray = buildPacket(target, ByteArray(0))
|
||||
|
||||
/**
|
||||
* Set-clock write (target 0x23): wall time, signed UTC offset (sec), lat/lon in degrees×1000
|
||||
* (truncated toward zero). All fields little-endian; returns the complete framed packet.
|
||||
*/
|
||||
fun buildSetClock(nowEpochSec: Long, utcOffsetSec: Int, latE3: Int, lonE3: Int): ByteArray {
|
||||
val nowSec = nowEpochSec.toInt()
|
||||
val payload = ByteArray(SET_CLOCK_PAYLOAD_SIZE)
|
||||
|
||||
// [0:4] nowSec as LE uint32
|
||||
BLETypeConversions.writeUint32(payload, NOW_SEC_OFFSET, nowSec)
|
||||
|
||||
// [4:8] utcOffsetSec as LE int32 (signed, two's complement via shr)
|
||||
BLETypeConversions.writeUint32(payload, OFFSET_SEC_OFFSET, utcOffsetSec)
|
||||
|
||||
// [8:12] latE3 as LE int32 (signed)
|
||||
BLETypeConversions.writeUint32(payload, LAT_E3_OFFSET, latE3)
|
||||
|
||||
// [12:16] lonE3 as LE int32 (signed)
|
||||
BLETypeConversions.writeUint32(payload, LON_E3_OFFSET, lonE3)
|
||||
|
||||
// [16:18] CONSTANT_FLAG (0x0003) as LE
|
||||
BLETypeConversions.writeUint16(payload, CONSTANT_FLAG_OFFSET, CONSTANT_FLAG)
|
||||
|
||||
// [18:20] CONSTANT_WORD (0xFFFF)
|
||||
BLETypeConversions.writeUint16(payload, CONSTANT_WORD_OFFSET, CONSTANT_WORD)
|
||||
|
||||
return buildPacket(OlleeConstants.TARGET_SET_CLOCK, payload)
|
||||
}
|
||||
|
||||
/**
|
||||
* Battery millivolts from a version-reply (0x4A) payload: big-endian uint16 at
|
||||
* VERSION_REPLY_VOLTAGE_OFFSET (34). Null if too short.
|
||||
*/
|
||||
fun parseVoltageMillivolts(versionReplyPayload: ByteArray): Int? {
|
||||
val offset = OlleeConstants.VERSION_REPLY_VOLTAGE_OFFSET
|
||||
if (versionReplyPayload.size < offset + 2) return null
|
||||
val hi = versionReplyPayload[offset].toInt() and BYTE_MASK
|
||||
val lo = versionReplyPayload[offset + 1].toInt() and BYTE_MASK
|
||||
return (hi shl SHIFT_BYTE_1) or lo
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCII firmware version from a version-reply (0x4A) payload: 16 chars at offset 8
|
||||
* (e.g. "01.05.0000.01.10"). Null if too short or not printable ASCII.
|
||||
*/
|
||||
fun parseFirmwareVersion(versionReplyPayload: ByteArray): String? {
|
||||
val end = VERSION_STRING_OFFSET + VERSION_STRING_LENGTH
|
||||
if (versionReplyPayload.size < end) return null
|
||||
val chars = versionReplyPayload.copyOfRange(VERSION_STRING_OFFSET, end)
|
||||
if (chars.any { it < 0x20 || it > 0x7E }) return null
|
||||
return String(chars, Charsets.US_ASCII).trim()
|
||||
}
|
||||
|
||||
private const val VERSION_STRING_OFFSET = 8
|
||||
private const val VERSION_STRING_LENGTH = 16
|
||||
}
|
||||
|
||||
/**
|
||||
* Reassembles fragmented 20-byte BLE notifications into complete frames. Feed each fragment to
|
||||
* [accept], which returns a [Frame] once LEN is satisfied and CRC is valid; stray leading bytes that
|
||||
* cannot begin a valid frame are dropped.
|
||||
*/
|
||||
class OlleeFrameReassembler {
|
||||
private companion object {
|
||||
private val LOG = LoggerFactory.getLogger(OlleeFrameReassembler::class.java)
|
||||
}
|
||||
|
||||
private var buffer = ByteArray(256)
|
||||
private var size = 0
|
||||
|
||||
/**
|
||||
* Accepts a fragment, returning a complete [Frame] if ready else null (leading junk dropped).
|
||||
* The watch pipelines replies: one 20-byte fragment can carry the tail of one frame and the head
|
||||
* of the next, so the caller must drain [pending] until null after each accepted fragment.
|
||||
*/
|
||||
fun accept(chunk: ByteArray): Frame? {
|
||||
// Grow buffer if needed
|
||||
if (size + chunk.size > buffer.size) {
|
||||
val newSize = (buffer.size * 2).coerceAtLeast(size + chunk.size)
|
||||
val newBuffer = ByteArray(newSize)
|
||||
System.arraycopy(buffer, 0, newBuffer, 0, size)
|
||||
buffer = newBuffer
|
||||
}
|
||||
|
||||
// Append chunk to buffer
|
||||
System.arraycopy(chunk, 0, buffer, size, chunk.size)
|
||||
size += chunk.size
|
||||
|
||||
return extractFrame()
|
||||
}
|
||||
|
||||
/** Returns the next complete frame already buffered, if any. Call until null. */
|
||||
fun pending(): Frame? = extractFrame()
|
||||
|
||||
private fun extractFrame(): Frame? {
|
||||
while (true) {
|
||||
var pos = 0
|
||||
|
||||
// Drop leading bytes that can't begin a valid frame, re-syncing to the next candidate
|
||||
while (pos < size && buffer[pos] != 0x00.toByte()) {
|
||||
pos++
|
||||
}
|
||||
|
||||
// Check for AA 55 magic once 4 bytes are available
|
||||
while (pos + 4 <= size && (buffer[pos + 2] != 0xAA.toByte() || buffer[pos + 3] != 0x55.toByte())) {
|
||||
pos++
|
||||
while (pos < size && buffer[pos] != 0x00.toByte()) {
|
||||
pos++
|
||||
}
|
||||
}
|
||||
|
||||
// Frame length is LEN + 2 bytes (00 LEN AA 55 CRC CRC ... )
|
||||
if (pos + 4 > size) {
|
||||
compact(pos)
|
||||
return null
|
||||
}
|
||||
val len = buffer[pos + 1].toInt() and 0xFF
|
||||
val total = len + 2
|
||||
if (pos + total > size) {
|
||||
compact(pos)
|
||||
return null
|
||||
}
|
||||
|
||||
val frameBytes = buffer.copyOfRange(pos, pos + total)
|
||||
compact(pos + total)
|
||||
|
||||
val frame = parseFrameRaw(frameBytes)
|
||||
// On CRC/header failure the bad frame is consumed; keep scanning — a valid
|
||||
// frame may already be buffered behind it.
|
||||
if (frame != null) return frame
|
||||
}
|
||||
}
|
||||
|
||||
/** Discards everything before [from], keeping any partial frame that follows. */
|
||||
private fun compact(from: Int) {
|
||||
if (from <= 0) return
|
||||
val keep = size - from
|
||||
if (keep > 0) {
|
||||
System.arraycopy(buffer, from, buffer, 0, keep)
|
||||
}
|
||||
size = keep
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
size = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a complete framed message (00 LEN AA 55 crcHi crcLo 02 target payload…) into a [Frame].
|
||||
* Null if too short, missing the AA 55 magic, or CRC mismatch.
|
||||
*/
|
||||
private fun parseFrameRaw(bytes: ByteArray): Frame? {
|
||||
val hasValidHeader = bytes.size >= OlleeProtocol.MIN_FRAME_LENGTH &&
|
||||
bytes[2] == 0xAA.toByte() && bytes[3] == 0x55.toByte()
|
||||
if (!hasValidHeader) {
|
||||
LOG.warn("Dropping frame with invalid header ({} bytes)", bytes.size)
|
||||
return null
|
||||
}
|
||||
|
||||
val crcField = ((bytes[4].toInt() and 0xFF) shl 8) or (bytes[5].toInt() and 0xFF)
|
||||
val inner = bytes.copyOfRange(6, bytes.size)
|
||||
|
||||
val computedCrc = OlleeProtocol.crc16(inner)
|
||||
if (computedCrc != crcField) {
|
||||
LOG.warn("Dropping frame with CRC mismatch (field=0x{}, computed=0x{})",
|
||||
Integer.toHexString(crcField), Integer.toHexString(computedCrc))
|
||||
return null
|
||||
}
|
||||
|
||||
val target = inner[1].toInt() and 0xFF
|
||||
val payload = inner.copyOfRange(2, inner.size)
|
||||
return Frame(target, payload)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A parsed frame from the watch: the target byte and the payload (without framing or CRC).
|
||||
*/
|
||||
data class Frame(val target: Int, val payload: ByteArray)
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
/**
|
||||
* Single writer for the shared 02 34 register (4-byte BE two's-complement World Time offset + 14
|
||||
* ASCII chars = 7 two-char weekday/badge cells). Always composes the FULL payload: the register
|
||||
* carries both halves, and a partial write silently resets the watch's World Time (seen on hardware).
|
||||
*/
|
||||
class WeekdayRegisterComposer {
|
||||
|
||||
companion object {
|
||||
private const val HEADER_SIZE = 4
|
||||
private const val WEEKDAY_SLOT_COUNT = 7
|
||||
private const val CHARS_PER_SLOT = 2
|
||||
private const val TEXT_SIZE = WEEKDAY_SLOT_COUNT * CHARS_PER_SLOT // 14
|
||||
private const val PAYLOAD_SIZE = HEADER_SIZE + TEXT_SIZE // 18
|
||||
|
||||
private const val MAX_SINGLE_DIGIT_COUNT = 9
|
||||
private const val MAX_TWO_DIGIT_COUNT = 11
|
||||
|
||||
private const val BYTE_MASK = 0xFF
|
||||
private const val SHIFT_24 = 24
|
||||
private const val SHIFT_16 = 16
|
||||
private const val SHIFT_8 = 8
|
||||
|
||||
/** The captured default weekday table (Mon..Sun). */
|
||||
private val REAL_WEEKDAYS = listOf("MO", "TU", "WE", "TH", "FR", "SA", "SU")
|
||||
|
||||
/**
|
||||
* Formats a count into the 2-cell badge slot (2 ASCII chars). The right cell garbles most
|
||||
* digits (hardware-verified), so 1..9 -> "N " (blank right), 10/11 -> "10"/"11" (0/1 render
|
||||
* cleanly), 12+ -> "11" ("11 or more").
|
||||
*/
|
||||
fun badgeCell(count: Int): String = when {
|
||||
count <= MAX_SINGLE_DIGIT_COUNT -> "$count "
|
||||
count <= MAX_TWO_DIGIT_COUNT -> count.toString()
|
||||
else -> "11"
|
||||
}
|
||||
}
|
||||
|
||||
/** World Time UTC offset in seconds (big-endian two's-complement in the payload). */
|
||||
var worldTimeOffsetSec: Int = 0
|
||||
|
||||
/** Badge count to display (0 = no badge, weekdays shown). */
|
||||
var badgeCount: Int = 0
|
||||
|
||||
/**
|
||||
* Composes the full 02 34 payload: 4-byte BE offset header + 14 ASCII chars (weekdays, or the
|
||||
* badge cell repeated). The header is preserved across badge changes — the invariant that
|
||||
* prevents the offset-clobber regression.
|
||||
*/
|
||||
fun composePayload(): ByteArray {
|
||||
val header = encodeOffsetHeader(worldTimeOffsetSec)
|
||||
val textCells = if (badgeCount == 0) {
|
||||
REAL_WEEKDAYS
|
||||
} else {
|
||||
List(WEEKDAY_SLOT_COUNT) { badgeCell(badgeCount) }
|
||||
}
|
||||
val text = textCells.joinToString("").toByteArray(Charsets.US_ASCII)
|
||||
return header + text
|
||||
}
|
||||
|
||||
/** Seeds [worldTimeOffsetSec] from the 0x55 weekday-register read-back; leaves badge unchanged. */
|
||||
fun seedFromReadback(getWeekdaysReplyPayload: ByteArray) {
|
||||
if (getWeekdaysReplyPayload.size >= HEADER_SIZE) {
|
||||
val decodedOffset = decodeOffsetHeader(
|
||||
getWeekdaysReplyPayload.copyOfRange(0, HEADER_SIZE)
|
||||
)
|
||||
if (decodedOffset != null) {
|
||||
worldTimeOffsetSec = decodedOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Encodes [offsetSeconds] to a 4-byte big-endian two's-complement representation. */
|
||||
private fun encodeOffsetHeader(offsetSeconds: Int): ByteArray {
|
||||
return byteArrayOf(
|
||||
((offsetSeconds shr SHIFT_24) and BYTE_MASK).toByte(),
|
||||
((offsetSeconds shr SHIFT_16) and BYTE_MASK).toByte(),
|
||||
((offsetSeconds shr SHIFT_8) and BYTE_MASK).toByte(),
|
||||
(offsetSeconds and BYTE_MASK).toByte(),
|
||||
)
|
||||
}
|
||||
|
||||
/** 4-byte BE two's-complement offset -> signed seconds; null on wrong size or > ±18h. */
|
||||
private fun decodeOffsetHeader(header: ByteArray): Int? {
|
||||
if (header.size != HEADER_SIZE) return null
|
||||
val value = (header[0].toInt() shl SHIFT_24) or
|
||||
((header[1].toInt() and BYTE_MASK) shl SHIFT_16) or
|
||||
((header[2].toInt() and BYTE_MASK) shl SHIFT_8) or
|
||||
(header[3].toInt() and BYTE_MASK)
|
||||
// Plausibility check: ±18 hours = ±64_800 seconds (ISO 8601 bound)
|
||||
val MAX_OFFSET_SEC = 64_800
|
||||
return value.takeIf { it in -MAX_OFFSET_SEC..MAX_OFFSET_SEC }
|
||||
}
|
||||
}
|
||||
@@ -4734,6 +4734,7 @@
|
||||
<string name="service_notification_collector_service_title">Gadgetbridge Notification Monitor</string>
|
||||
<string name="service_notification_collector_service_text">Monitoring notifications in background</string>
|
||||
<string name="devicetype_imiki_frame_2" translatable="false">IMIKI Frame 2</string>
|
||||
<string name="devicetype_ollee_watch_one" translatable="false">Ollee Watch One</string>
|
||||
<string name="devicetype_oukitel_bt103" translatable="false">Oukitel BT103</string>
|
||||
<string name="devicetype_y6" translatable="false">Y6</string>
|
||||
<string name="devicetype_y66" translatable="false">Y66</string>
|
||||
|
||||
@@ -221,6 +221,7 @@ public class BluetoothNameTest extends TestBase {
|
||||
put("BPW4500", DeviceType.BRAUN_BPW4500); // #5886
|
||||
put("MATSON Monitor", DeviceType.BM2_BATTERY_MONITOR); // #6212
|
||||
put("BM6", DeviceType.BM6_BATTERY_MONITOR); // #6236
|
||||
put("Ollee Watch", DeviceType.OLLEE_WATCH_ONE); // #6411
|
||||
put("Xiaomi Smart Band 10 Pro AB01", DeviceType.MIBAND10PRO); // #6248
|
||||
put("SmartShunt HQ2303UCHFV", DeviceType.VICTRON_SMARTSHUNT); // #6263
|
||||
put("Soundcore Q30", DeviceType.SOUNDCORE_Q30); // #6396
|
||||
|
||||
+139
@@ -0,0 +1,139 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
||||
class OlleeActivityRecordsTest {
|
||||
|
||||
companion object {
|
||||
fun hexToByteArray(hex: String): ByteArray {
|
||||
val cleaned = hex.replace(" ", "").replace("\n", "")
|
||||
require(cleaned.length % 2 == 0) { "Hex string must have even length" }
|
||||
return ByteArray(cleaned.length / 2) { i ->
|
||||
cleaned.substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseCount() {
|
||||
val payload = hexToByteArray("00000003")
|
||||
assertEquals(3L, OlleeActivityRecords.parseCount(payload))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseSnoopCaptureRecords() {
|
||||
// Snoop capture records
|
||||
val record1Hex = "000000006a51ec446a51ec4800000000"
|
||||
val record1 = OlleeActivityRecords.parseRecord(hexToByteArray(record1Hex))
|
||||
assertEquals(0L, record1.typeFlags)
|
||||
assertEquals(0x6a51ec44L, record1.startTs)
|
||||
assertEquals(0x6a51ec48L, record1.endTs)
|
||||
assertEquals(0L, record1.steps)
|
||||
|
||||
val record2Hex = "000000006a51ec486a51ecc400000014"
|
||||
val record2 = OlleeActivityRecords.parseRecord(hexToByteArray(record2Hex))
|
||||
assertEquals(0L, record2.typeFlags)
|
||||
assertEquals(0x6a51ec48L, record2.startTs)
|
||||
assertEquals(0x6a51ecc4L, record2.endTs)
|
||||
assertEquals(20L, record2.steps)
|
||||
|
||||
val record3Hex = "000000006a51ecc46a51ed4f0000001a"
|
||||
val record3 = OlleeActivityRecords.parseRecord(hexToByteArray(record3Hex))
|
||||
assertEquals(0L, record3.typeFlags)
|
||||
assertEquals(0x6a51ecc4L, record3.startTs)
|
||||
assertEquals(0x6a51ed4fL, record3.endTs)
|
||||
assertEquals(26L, record3.steps)
|
||||
|
||||
// Verify steps sum to 46
|
||||
assertEquals(46L, record1.steps + record2.steps + record3.steps)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseReplayCaptureRecords() {
|
||||
// Replay capture records
|
||||
val record1Hex = "000000006a51ed4f6a51f800000000aa"
|
||||
val record1 = OlleeActivityRecords.parseRecord(hexToByteArray(record1Hex))
|
||||
assertEquals(170L, record1.steps)
|
||||
assertEquals(0x6a51ed4fL, record1.startTs)
|
||||
assertEquals(0x6a51f800L, record1.endTs)
|
||||
|
||||
val record2Hex = "000000006a51f8006a52061000000000"
|
||||
val record2 = OlleeActivityRecords.parseRecord(hexToByteArray(record2Hex))
|
||||
assertEquals(0L, record2.steps)
|
||||
val interval2 = record2.endTs - record2.startTs
|
||||
assertEquals(3600L, interval2)
|
||||
|
||||
val record3Hex = "000000006a5206106a52142000000000"
|
||||
val record3 = OlleeActivityRecords.parseRecord(hexToByteArray(record3Hex))
|
||||
assertEquals(0L, record3.steps)
|
||||
val interval3 = record3.endTs - record3.startTs
|
||||
assertEquals(3600L, interval3)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testChainProperty() {
|
||||
// Snoop capture chain property
|
||||
val records = listOf(
|
||||
OlleeActivityRecords.parseRecord(hexToByteArray("000000006a51ec446a51ec4800000000")),
|
||||
OlleeActivityRecords.parseRecord(hexToByteArray("000000006a51ec486a51ecc400000014")),
|
||||
OlleeActivityRecords.parseRecord(hexToByteArray("000000006a51ecc46a51ed4f0000001a"))
|
||||
)
|
||||
|
||||
for (i in 0 until records.size - 1) {
|
||||
assertEquals(records[i].endTs, records[i + 1].startTs)
|
||||
}
|
||||
|
||||
// Replay capture chain property
|
||||
val replayRecords = listOf(
|
||||
OlleeActivityRecords.parseRecord(hexToByteArray("000000006a51ed4f6a51f800000000aa")),
|
||||
OlleeActivityRecords.parseRecord(hexToByteArray("000000006a51f8006a52061000000000")),
|
||||
OlleeActivityRecords.parseRecord(hexToByteArray("000000006a5206106a52142000000000"))
|
||||
)
|
||||
|
||||
for (i in 0 until replayRecords.size - 1) {
|
||||
assertEquals(replayRecords[i].endTs, replayRecords[i + 1].startTs)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRecordWith15BytesThrows() {
|
||||
val payload = hexToByteArray("000000006a51ec446a51ec48000000") // 15 bytes
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
OlleeActivityRecords.parseRecord(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseCountWith3BytesThrows() {
|
||||
val payload = hexToByteArray("000000") // 3 bytes
|
||||
assertThrows(IllegalArgumentException::class.java) {
|
||||
OlleeActivityRecords.parseCount(payload)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseRecordUnsignedHighBitValues() {
|
||||
// Record with steps field "80000001" -> 2147483649L
|
||||
val recordHex = "00000000000000000000000080000001"
|
||||
val record = OlleeActivityRecords.parseRecord(hexToByteArray(recordHex))
|
||||
assertEquals(2147483649L, record.steps)
|
||||
}
|
||||
}
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
import nodomain.freeyourgadget.gadgetbridge.model.Alarm
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
|
||||
class OlleeAlarmTest {
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
require(length % 2 == 0) { "Hex string must have even length" }
|
||||
return ByteArray(length / 2) { i ->
|
||||
substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (a) GB daily mask 127 (bits 0-6) maps to the watch's every-day mask 0xFE (bits 1-7),
|
||||
* matching FreeOllee's daily-alarm vector.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordWithGbAlarmDailyProducesWatchMask0xFE() {
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 13,
|
||||
minute = 30,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(33, record.size)
|
||||
assertEquals(0xFE, record[5].toInt() and 0xFF) // dayMask at index 5
|
||||
}
|
||||
|
||||
/** toWatchDayMask maps GB MON=1..SUN=64 to the watch's bit1=Mon..bit7=Sun (1=active, bit0 unused). */
|
||||
@Test
|
||||
fun toWatchDayMaskMapsAllIndividualGbDaysCorrectly() {
|
||||
assertEquals(0x02, OlleeAlarm.toWatchDayMask(Alarm.ALARM_MON.toInt())) // MON=1 -> bit1
|
||||
assertEquals(0x04, OlleeAlarm.toWatchDayMask(Alarm.ALARM_TUE.toInt())) // TUE=2 -> bit2
|
||||
assertEquals(0x08, OlleeAlarm.toWatchDayMask(Alarm.ALARM_WED.toInt())) // WED=4 -> bit3
|
||||
assertEquals(0x10, OlleeAlarm.toWatchDayMask(Alarm.ALARM_THU.toInt())) // THU=8 -> bit4
|
||||
assertEquals(0x20, OlleeAlarm.toWatchDayMask(Alarm.ALARM_FRI.toInt())) // FRI=16 -> bit5
|
||||
assertEquals(0x40, OlleeAlarm.toWatchDayMask(Alarm.ALARM_SAT.toInt())) // SAT=32 -> bit6
|
||||
assertEquals(0x80, OlleeAlarm.toWatchDayMask(Alarm.ALARM_SUN.toInt())) // SUN=64 -> bit7
|
||||
}
|
||||
|
||||
/**
|
||||
* (b) With existing = a capture-confirmed 12-byte 0x4B read-back (no playNow), the built record
|
||||
* preserves the watch-only bytes verbatim at record indexes 1, 6, 7, 9, 10, 11.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordPreservesWatchOnlyFieldsFromExistingPayload() {
|
||||
val existing = "00010017007E0A05C0FF0FFF".hexToByteArray() // 12 bytes (read-back format, no playNow)
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 7,
|
||||
minute = 15,
|
||||
gbRepetitionMask = Alarm.ALARM_MON.toInt() or Alarm.ALARM_WED.toInt() or Alarm.ALARM_FRI.toInt(),
|
||||
snooze = false,
|
||||
existing = existing
|
||||
)
|
||||
|
||||
// Map read-back offsets (12 bytes, no playNow) to record offsets (13 bytes with playNow):
|
||||
// Read-back byte 1 (hourlyChime) -> record byte 1
|
||||
assertEquals(existing[1], record[1])
|
||||
// Read-back byte 6 (chime) -> record byte 6
|
||||
assertEquals(existing[6], record[6])
|
||||
// Read-back byte 7 (snoozeMin) -> record byte 7
|
||||
assertEquals(existing[7], record[7])
|
||||
// Read-back byte 8 (hourMaskLo) -> record byte 9
|
||||
assertEquals(existing[8], record[9])
|
||||
// Read-back byte 9 (hourMaskMid) -> record byte 10
|
||||
assertEquals(existing[9], record[10])
|
||||
// Read-back byte 10 (hourMaskHi) -> record byte 11
|
||||
assertEquals(existing[10], record[11])
|
||||
}
|
||||
|
||||
/**
|
||||
* (c) playNow byte (index 8) is always 0.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordAlwaysSetsPlayNowTo0() {
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 7,
|
||||
minute = 5,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(0x00.toByte(), record[8])
|
||||
}
|
||||
|
||||
/**
|
||||
* (d) record length is always 33, byte 12 is the 0xFF settings-block terminator, and the
|
||||
* 20-byte trailing block is five LE u32 0x0000000C.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordHasLength33AndTerminator0xFF() {
|
||||
val record1 = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 0,
|
||||
minute = 0,
|
||||
gbRepetitionMask = 0,
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(33, record1.size)
|
||||
assertEquals(0xFF.toByte(), record1[12])
|
||||
assertTrailingBlock(record1)
|
||||
|
||||
val record2 = OlleeAlarm.buildRecord(
|
||||
enabled = false,
|
||||
hour = 23,
|
||||
minute = 59,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = true,
|
||||
existing = "00010017007E0A05C0FF0FFF".hexToByteArray()
|
||||
)
|
||||
assertEquals(33, record2.size)
|
||||
assertEquals(0xFF.toByte(), record2[12])
|
||||
assertTrailingBlock(record2)
|
||||
}
|
||||
|
||||
private fun assertTrailingBlock(record: ByteArray) {
|
||||
for (i in 0 until 5) {
|
||||
assertEquals(0x0C, record[13 + i * 4].toInt() and 0xFF)
|
||||
assertEquals(0x00, record[14 + i * 4].toInt() and 0xFF)
|
||||
assertEquals(0x00, record[15 + i * 4].toInt() and 0xFF)
|
||||
assertEquals(0x00, record[16 + i * 4].toInt() and 0xFF)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the record contains the input hour and minute at the correct positions.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordSetsHourAndMinuteCorrectly() {
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 14,
|
||||
minute = 45,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(14, record[3].toInt() and 0xFF) // hour at index 3
|
||||
assertEquals(45, record[4].toInt() and 0xFF) // minute at index 4
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that the record contains the input enabled flag at the correct position.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordSetsEnableByteCorrectly() {
|
||||
val enabledRecord = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 7,
|
||||
minute = 30,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(0x01.toByte(), enabledRecord[0])
|
||||
|
||||
val disabledRecord = OlleeAlarm.buildRecord(
|
||||
enabled = false,
|
||||
hour = 7,
|
||||
minute = 30,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(0x00.toByte(), disabledRecord[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that snooze enable byte is set correctly (matching FreeOllee behavior).
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordSetsSnoozeByte() {
|
||||
val snoozeRecord = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 7,
|
||||
minute = 30,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = true,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(0x01.toByte(), snoozeRecord[2])
|
||||
|
||||
val noSnoozeRecord = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 7,
|
||||
minute = 30,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(0x00.toByte(), noSnoozeRecord[2])
|
||||
}
|
||||
|
||||
/**
|
||||
* Test that when existing is null, watch-only fields use sensible defaults.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordUsesDefaultsWhenExistingIsNull() {
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 7,
|
||||
minute = 30,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
// When no existing payload, expect defaults:
|
||||
// hourlyChime=1 (on), chime=0, snoozeMin=5, hourMask=0xC0FF0F (6:00-19:00)
|
||||
assertEquals(0x01.toByte(), record[1]) // hourlyChime on
|
||||
assertEquals(0x00.toByte(), record[6]) // chime index 0
|
||||
assertEquals(0x05.toByte(), record[7]) // snooze period 5 min
|
||||
assertEquals(0xC0.toByte(), record[9]) // hourMaskLo
|
||||
assertEquals(0xFF.toByte(), record[10]) // hourMaskMid
|
||||
assertEquals(0x0F.toByte(), record[11]) // hourMaskHi
|
||||
}
|
||||
|
||||
/**
|
||||
* Port of FreeOllee's alarm test vector: 1:30 PM (13:30), enabled, daily.
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordReproducesFreeOlleeTestVector() {
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 13,
|
||||
minute = 30,
|
||||
gbRepetitionMask = Alarm.ALARM_DAILY.toInt(), // 127 -> 0xFE
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
// Verify key fields match FreeOllee's test
|
||||
assertEquals(0x01.toByte(), record[0]) // enabled
|
||||
assertEquals(0x01.toByte(), record[1]) // hourlyChime on (default)
|
||||
assertEquals(0x00.toByte(), record[2]) // snooze off
|
||||
assertEquals(13, record[3].toInt() and 0xFF) // hour
|
||||
assertEquals(30, record[4].toInt() and 0xFF) // minute
|
||||
assertEquals(0xFE.toByte(), record[5]) // dayMask every day
|
||||
// record[6] is chime (default 0x00 when no existing)
|
||||
assertEquals(0x05.toByte(), record[7]) // snoozeMin (default)
|
||||
assertEquals(0x00.toByte(), record[8]) // playNow always 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Test once-alarm (ALARM_ONCE = 0).
|
||||
*/
|
||||
@Test
|
||||
fun buildRecordWithAlarmOnceMapsToWatchMask0x00() {
|
||||
val record = OlleeAlarm.buildRecord(
|
||||
enabled = true,
|
||||
hour = 7,
|
||||
minute = 0,
|
||||
gbRepetitionMask = Alarm.ALARM_ONCE.toInt(),
|
||||
snooze = false,
|
||||
existing = null
|
||||
)
|
||||
assertEquals(0x00, record[5].toInt() and 0xFF)
|
||||
}
|
||||
|
||||
/** One-shot alarm still ahead today (Wed, 07:00 alarm at 06:30 now) arms Wednesday = bit 3. */
|
||||
@Test
|
||||
fun oneShotAheadTodayArmsTodayBit() {
|
||||
assertEquals(
|
||||
0x08,
|
||||
OlleeAlarm.oneShotWatchDayMask(hour = 7, minute = 0, nowDayOfWeekIso = 3, nowHour = 6, nowMinute = 30)
|
||||
)
|
||||
}
|
||||
|
||||
/** One-shot already passed today (Wed, 07:00 alarm at 07:00 now) arms Thursday = bit 4. */
|
||||
@Test
|
||||
fun oneShotPassedTodayArmsTomorrowBit() {
|
||||
assertEquals(
|
||||
0x10,
|
||||
OlleeAlarm.oneShotWatchDayMask(hour = 7, minute = 0, nowDayOfWeekIso = 3, nowHour = 7, nowMinute = 0)
|
||||
)
|
||||
}
|
||||
|
||||
/** One-shot passed on Sunday wraps to Monday = bit 1. */
|
||||
@Test
|
||||
fun oneShotSundayWrapsToMonday() {
|
||||
assertEquals(
|
||||
0x02,
|
||||
OlleeAlarm.oneShotWatchDayMask(hour = 6, minute = 0, nowDayOfWeekIso = 7, nowHour = 22, nowMinute = 0)
|
||||
)
|
||||
}
|
||||
}
|
||||
+227
@@ -0,0 +1,227 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
|
||||
class OlleeFacesTableTest {
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
require(length % 2 == 0) { "Hex string must have even length" }
|
||||
return ByteArray(length / 2) { i ->
|
||||
substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
/**
|
||||
* Builds a full 14-record faces table. Slot->ID: 01=05 02=07 03=09 04=11 05=06 06=0D 07=08
|
||||
* 08=0E 09=0A 0A=0B 0B=0C 0C=0F 0D=10 0E=12. [unknownByteForId] overrides the ?? byte (default 0).
|
||||
*/
|
||||
private fun buildFacesPayload(
|
||||
enabledIds: Set<Int> = emptySet(),
|
||||
unknownByteForId: Map<Int, Int> = emptyMap()
|
||||
): ByteArray {
|
||||
val payload = mutableListOf<Byte>()
|
||||
|
||||
// 6-byte header
|
||||
payload.add(0x04)
|
||||
payload.add(0x00)
|
||||
payload.add(0x00)
|
||||
payload.add(0x00)
|
||||
payload.add(0x00)
|
||||
payload.add(0x00)
|
||||
|
||||
// Slot to (ID, slot) mapping
|
||||
val slotToId = mapOf(
|
||||
0x01 to 0x05,
|
||||
0x02 to 0x07,
|
||||
0x03 to 0x09,
|
||||
0x04 to 0x11,
|
||||
0x05 to 0x06,
|
||||
0x06 to 0x0D,
|
||||
0x07 to 0x08,
|
||||
0x08 to 0x0E,
|
||||
0x09 to 0x0A,
|
||||
0x0A to 0x0B,
|
||||
0x0B to 0x0C,
|
||||
0x0C to 0x0F,
|
||||
0x0D to 0x10,
|
||||
0x0E to 0x12
|
||||
)
|
||||
|
||||
// 14 records in slot order
|
||||
for (slot in 0x01..0x0E) {
|
||||
val faceId = slotToId[slot] ?: error("Unknown slot $slot")
|
||||
val enabled = if (enabledIds.contains(faceId)) 0x01 else 0x00
|
||||
val unknownByte = unknownByteForId[faceId] ?: 0x00
|
||||
|
||||
payload.add(faceId.toByte())
|
||||
payload.add(0x01)
|
||||
payload.add(enabled.toByte())
|
||||
payload.add(0x01)
|
||||
payload.add(unknownByte.toByte())
|
||||
payload.add(slot.toByte())
|
||||
}
|
||||
|
||||
return payload.toByteArray()
|
||||
}
|
||||
|
||||
// Test 1: Build full 14-record table payload
|
||||
@Test
|
||||
fun buildFacesPayloadCreates14Records() {
|
||||
val payload = buildFacesPayload()
|
||||
// 6-byte header + 14 * 6-byte records = 90 bytes
|
||||
assertEquals(90, payload.size)
|
||||
}
|
||||
|
||||
// Test 2: withFaceEnabled flips exactly one byte for ID 0x06 (World Time)
|
||||
@Test
|
||||
fun withFaceEnabledFlipsExactlyOneByte() {
|
||||
val payload = buildFacesPayload(
|
||||
enabledIds = setOf(0x0B), // Temperature enabled
|
||||
unknownByteForId = mapOf(0x0B to 0x2A) // Mark World Time's ?? byte as 0x2A
|
||||
)
|
||||
// Slot 5 is ID 0x06 (World Time), currently disabled
|
||||
val result = OlleeFacesTable.withFaceEnabled(payload, 0x06, true)
|
||||
|
||||
// Check length is unchanged
|
||||
assertEquals(payload.size, result.size)
|
||||
|
||||
// Find the World Time record (slot 5)
|
||||
// Header is 6 bytes, then records start. Slot 5 is the 5th record (index 4)
|
||||
val recordOffset = 6 + 4 * 6 // bytes into payload
|
||||
val enabledByteOffset = recordOffset + 2
|
||||
|
||||
// The enabled byte should have flipped from 0x00 to 0x01
|
||||
assertEquals(0x00.toByte(), payload[enabledByteOffset])
|
||||
assertEquals(0x01.toByte(), result[enabledByteOffset])
|
||||
|
||||
// All other bytes should be identical
|
||||
for (i in payload.indices) {
|
||||
if (i != enabledByteOffset) {
|
||||
assertEquals(
|
||||
"Byte at index $i differs",
|
||||
payload[i], result[i]
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Test 3: withFaceEnabled on already-enabled face returns content-equal payload
|
||||
@Test
|
||||
fun withFaceEnabledAlreadyEnabledReturnsSameContent() {
|
||||
val payload = buildFacesPayload(enabledIds = setOf(0x06)) // World Time already enabled
|
||||
val result = OlleeFacesTable.withFaceEnabled(payload, 0x06, true)
|
||||
|
||||
// Should be equal in content
|
||||
assertArrayEquals(payload, result)
|
||||
}
|
||||
|
||||
// Test 4: isFaceEnabled reflects the ENABLED byte
|
||||
@Test
|
||||
fun isFaceEnabledReflectsEnabledByte() {
|
||||
val payload = buildFacesPayload(
|
||||
enabledIds = setOf(0x06) // World Time enabled
|
||||
)
|
||||
|
||||
assertTrue("World Time (0x06) should be enabled", OlleeFacesTable.isFaceEnabled(payload, 0x06)!!)
|
||||
assertFalse("Temperature (0x0B) should be disabled", OlleeFacesTable.isFaceEnabled(payload, 0x0B)!!)
|
||||
}
|
||||
|
||||
// Test 5: isFaceEnabled returns null for unknown ID
|
||||
@Test
|
||||
fun isFaceEnabledReturnsNullForUnknownId() {
|
||||
val payload = buildFacesPayload()
|
||||
val result = OlleeFacesTable.isFaceEnabled(payload, 0x42)
|
||||
assertNull("Unknown ID 0x42 should return null", result)
|
||||
}
|
||||
|
||||
// Test 6: isFaceEnabled returns null for too-short payload
|
||||
@Test
|
||||
fun isFaceEnabledReturnsNullForTooShortPayload() {
|
||||
val tooShort = byteArrayOf(0x04, 0x00, 0x00)
|
||||
val result = OlleeFacesTable.isFaceEnabled(tooShort, 0x06)
|
||||
assertNull("Too-short payload should return null", result)
|
||||
}
|
||||
|
||||
// Test 7: The ?? byte survives withFaceEnabled
|
||||
@Test
|
||||
fun unknownBytePreservedOnWithFaceEnabled() {
|
||||
val payload = buildFacesPayload(
|
||||
enabledIds = setOf(0x0A), // Step Counter enabled, World Time disabled
|
||||
unknownByteForId = mapOf(0x06 to 0x2A, 0x0A to 0x3B) // Different ?? bytes
|
||||
)
|
||||
|
||||
// Flip World Time (0x06) to enabled
|
||||
val result = OlleeFacesTable.withFaceEnabled(payload, 0x06, true)
|
||||
|
||||
// Find World Time record (slot 5, record index 4)
|
||||
val recordOffset = 6 + 4 * 6
|
||||
val unknownByteOffset = recordOffset + 4
|
||||
|
||||
// The ?? byte (0x2A) should be preserved
|
||||
assertEquals(0x2A.toByte(), payload[unknownByteOffset])
|
||||
assertEquals(0x2A.toByte(), result[unknownByteOffset])
|
||||
|
||||
// Also verify Step Counter's ?? byte is untouched
|
||||
// Slot 9 is record index 8
|
||||
val stepCounterRecordOffset = 6 + 8 * 6
|
||||
val stepCounterUnknownOffset = stepCounterRecordOffset + 4
|
||||
assertEquals(0x3B.toByte(), result[stepCounterUnknownOffset])
|
||||
}
|
||||
|
||||
// Test 8: withFaceEnabled disabling a face
|
||||
@Test
|
||||
fun withFaceEnabledDisablingFace() {
|
||||
val payload = buildFacesPayload(enabledIds = setOf(0x06, 0x0B)) // Both enabled
|
||||
val result = OlleeFacesTable.withFaceEnabled(payload, 0x06, false)
|
||||
|
||||
// World Time should be disabled in result
|
||||
assertFalse("World Time should be disabled", OlleeFacesTable.isFaceEnabled(result, 0x06)!!)
|
||||
// Temperature should still be enabled
|
||||
assertTrue("Temperature should still be enabled", OlleeFacesTable.isFaceEnabled(result, 0x0B)!!)
|
||||
}
|
||||
|
||||
// Test 9: Trailing partial record is tolerated (payload with < 6 bytes at end)
|
||||
@Test
|
||||
fun trailingPartialRecordTolerated() {
|
||||
val fullPayload = buildFacesPayload(enabledIds = setOf(0x06))
|
||||
// Remove last 3 bytes from last record
|
||||
val truncated = fullPayload.copyOfRange(0, fullPayload.size - 3)
|
||||
|
||||
// isFaceEnabled should still work on earlier records
|
||||
assertTrue(OlleeFacesTable.isFaceEnabled(truncated, 0x06)!!)
|
||||
|
||||
// Attempting to query a face in the truncated record should return null
|
||||
// (because it can't be found completely)
|
||||
val result = OlleeFacesTable.isFaceEnabled(truncated, 0x12) // Game C (slot 0E, last record)
|
||||
assertNull("Incomplete record for Game C should return null", result)
|
||||
}
|
||||
|
||||
// Test 10: withFaceEnabled on unknown face returns payload unchanged
|
||||
@Test
|
||||
fun withFaceEnabledUnknownFaceReturnsUnchanged() {
|
||||
val payload = buildFacesPayload(enabledIds = setOf(0x06))
|
||||
val result = OlleeFacesTable.withFaceEnabled(payload, 0x42, true)
|
||||
|
||||
// Content should be identical (no change)
|
||||
assertArrayEquals(payload, result)
|
||||
}
|
||||
}
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
|
||||
class OlleeProtocolTest {
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
require(length % 2 == 0) { "Hex string must have even length" }
|
||||
return ByteArray(length / 2) { i ->
|
||||
substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
// Test 1: buildSetClock golden vector
|
||||
@Test
|
||||
fun setClockGoldenVector() {
|
||||
val result = OlleeProtocol.buildSetClock(
|
||||
nowEpochSec = 1783729820L,
|
||||
utcOffsetSec = -21600,
|
||||
latE3 = 40140,
|
||||
lonE3 = -105144
|
||||
)
|
||||
val expected = "001aaa55fb5302239c8e516aa0abffffcc9c00004865feff0300ffff".hexToByteArray()
|
||||
assertArrayEquals(expected, result)
|
||||
}
|
||||
|
||||
// Test 2: CRC matches golden frame
|
||||
@Test
|
||||
fun crcMatchesGoldenFrame() {
|
||||
val goldenFrame = "001aaa55fb5302239c8e516aa0abffffcc9c00004865feff0300ffff".hexToByteArray()
|
||||
// Inner bytes are from byte 6 onward (after 00 LEN AA 55 CRC16)
|
||||
val inner = goldenFrame.copyOfRange(6, goldenFrame.size)
|
||||
val crc = OlleeProtocol.crc16(inner)
|
||||
assertEquals(0xfb53, crc)
|
||||
}
|
||||
|
||||
// Test 3: readRequest has correct framing
|
||||
@Test
|
||||
fun readRequestHasLen06() {
|
||||
val result = OlleeProtocol.readRequest(0x27)
|
||||
val expected = "0006aa552fe80227".hexToByteArray()
|
||||
assertArrayEquals(expected, result)
|
||||
}
|
||||
|
||||
// Test 4: reassembler joins 20-byte chunks
|
||||
@Test
|
||||
fun reassemblerJoins20ByteChunks() {
|
||||
val goldenFrame = "001aaa55fb5302239c8e516aa0abffffcc9c00004865feff0300ffff".hexToByteArray()
|
||||
// Frame is 26 bytes; split at byte 20
|
||||
val chunk1 = goldenFrame.copyOfRange(0, 20)
|
||||
val chunk2 = goldenFrame.copyOfRange(20, goldenFrame.size)
|
||||
|
||||
val reassembler = OlleeFrameReassembler()
|
||||
|
||||
val frame1 = reassembler.accept(chunk1)
|
||||
assertNull("First chunk should not yield a frame", frame1)
|
||||
|
||||
val frame2 = reassembler.accept(chunk2)
|
||||
assertNotNull("Second chunk should complete the frame", frame2)
|
||||
assertEquals(0x23, frame2!!.target)
|
||||
|
||||
// The payload is bytes 8-25 of the frame (after 6-byte preamble + 1 cmd + 1 target)
|
||||
val expectedPayload = goldenFrame.copyOfRange(8, goldenFrame.size)
|
||||
assertArrayEquals(expectedPayload, frame2.payload)
|
||||
}
|
||||
|
||||
// Test 5: reassembler drops CRC mismatch
|
||||
@Test
|
||||
fun reassemblerDropsCrcMismatch() {
|
||||
val goldenFrame = "001aaa55fb5302239c8e516aa0abffffcc9c00004865feff0300ffff".hexToByteArray()
|
||||
// Corrupt one payload byte (change byte at index 10)
|
||||
val corruptedFrame = goldenFrame.copyOf()
|
||||
corruptedFrame[10] = (corruptedFrame[10].toInt() xor 0xFF).toByte()
|
||||
|
||||
val reassembler = OlleeFrameReassembler()
|
||||
val result = reassembler.accept(corruptedFrame)
|
||||
assertNull("Corrupted frame with bad CRC should be dropped", result)
|
||||
}
|
||||
|
||||
// Test 6: voltage parsing trailing u16
|
||||
@Test
|
||||
fun voltageParsesTrailingU16() {
|
||||
val payload = ByteArray(36)
|
||||
payload[34] = 0x0B.toByte()
|
||||
payload[35] = 0x1B.toByte()
|
||||
|
||||
val voltage = OlleeProtocol.parseVoltageMillivolts(payload)
|
||||
assertEquals(2843, voltage)
|
||||
}
|
||||
|
||||
// Test 7: voltage returns null on short payload
|
||||
@Test
|
||||
fun voltageNullOnShortPayload() {
|
||||
val payload = ByteArray(10)
|
||||
val voltage = OlleeProtocol.parseVoltageMillivolts(payload)
|
||||
assertNull(voltage)
|
||||
}
|
||||
|
||||
// Test 8: pipelined replies — one fragment carries the tail of one frame and the head
|
||||
// of the next. Fragments captured verbatim from the watch (init read burst, 2026-07-11):
|
||||
// version reply (0x4A, 44 bytes) immediately followed by alarm readback (0x4B, 40 bytes).
|
||||
@Test
|
||||
fun reassemblerKeepsPipelinedFrameTail() {
|
||||
val f1 = "002AAA555421024A444541444245454630312E30".hexToByteArray()
|
||||
val f2 = "352E303030302E30312E31304445414442454546".hexToByteArray()
|
||||
val f3 = "00000B200026AA559381024B0000010C00000005".hexToByteArray()
|
||||
val f4 = "C0FF0FFF0C0000000C0000000C0000000C000000".hexToByteArray()
|
||||
val f5 = "0C000000".hexToByteArray()
|
||||
|
||||
val reassembler = OlleeFrameReassembler()
|
||||
|
||||
assertNull(reassembler.accept(f1))
|
||||
assertNull(reassembler.accept(f2))
|
||||
|
||||
// f3 completes the version frame AND starts the alarm frame
|
||||
val version = reassembler.accept(f3)
|
||||
assertNotNull("f3 should complete the version frame", version)
|
||||
assertEquals(0x4A, version!!.target)
|
||||
assertEquals(2848, OlleeProtocol.parseVoltageMillivolts(version.payload))
|
||||
assertNull("Alarm frame is still incomplete", reassembler.pending())
|
||||
|
||||
assertNull(reassembler.accept(f4))
|
||||
val alarm = reassembler.accept(f5)
|
||||
assertNotNull("f5 should complete the alarm frame", alarm)
|
||||
assertEquals(0x4B, alarm!!.target)
|
||||
assertArrayEquals(
|
||||
"0000010C00000005C0FF0FFF0C0000000C0000000C0000000C0000000C000000".hexToByteArray(),
|
||||
alarm.payload
|
||||
)
|
||||
}
|
||||
|
||||
// Test 9: two complete frames in a single chunk — accept yields the first, pending drains the second
|
||||
@Test
|
||||
fun reassemblerDrainsTwoFramesFromOneChunk() {
|
||||
val readA = OlleeProtocol.readRequest(0x2A)
|
||||
val readB = OlleeProtocol.readRequest(0x2B)
|
||||
|
||||
val reassembler = OlleeFrameReassembler()
|
||||
val first = reassembler.accept(readA + readB)
|
||||
assertNotNull(first)
|
||||
assertEquals(0x2A, first!!.target)
|
||||
|
||||
val second = reassembler.pending()
|
||||
assertNotNull(second)
|
||||
assertEquals(0x2B, second!!.target)
|
||||
|
||||
assertNull(reassembler.pending())
|
||||
}
|
||||
|
||||
// Test 10: firmware version parsed from the real 0x4A payload (hardware capture)
|
||||
@Test
|
||||
fun firmwareVersionGoldenVector() {
|
||||
val payload = (
|
||||
"4445414442454546" + // "DEADBEEF"
|
||||
"30312E30352E303030302E30312E3130" + // "01.05.0000.01.10"
|
||||
"4445414442454546" + // "DEADBEEF"
|
||||
"0000" + "0B20" // padding + 2848 mV
|
||||
).hexToByteArray()
|
||||
|
||||
assertEquals("01.05.0000.01.10", OlleeProtocol.parseFirmwareVersion(payload))
|
||||
assertEquals(2848, OlleeProtocol.parseVoltageMillivolts(payload))
|
||||
}
|
||||
|
||||
// Test 11: firmware version is null on short or non-ASCII payloads
|
||||
@Test
|
||||
fun firmwareVersionNullOnBadPayload() {
|
||||
assertNull(OlleeProtocol.parseFirmwareVersion(ByteArray(10)))
|
||||
assertNull(OlleeProtocol.parseFirmwareVersion(ByteArray(36))) // all zero bytes
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
/* Copyright (C) 2026 Ken Blizzard-Caron
|
||||
|
||||
This file is part of Gadgetbridge.
|
||||
|
||||
Gadgetbridge is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as published
|
||||
by the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
Gadgetbridge is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>. */
|
||||
package nodomain.freeyourgadget.gadgetbridge.service.devices.ollee
|
||||
|
||||
import org.junit.Test
|
||||
import org.junit.Assert.*
|
||||
|
||||
class WeekdayRegisterComposerTest {
|
||||
|
||||
private fun String.hexToByteArray(): ByteArray {
|
||||
require(length % 2 == 0) { "Hex string must have even length" }
|
||||
return ByteArray(length / 2) { i ->
|
||||
substring(i * 2, i * 2 + 2).toInt(16).toByte()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ByteArray.toHex(): String = joinToString("") { "%02x".format(it) }
|
||||
|
||||
// Test 1: encode Tokyo offset +9:00 (32400 s) -> header bytes 00 00 7E 90
|
||||
@Test
|
||||
fun encodesTokyoOffset() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 32_400
|
||||
val payload = composer.composePayload()
|
||||
val header = payload.copyOfRange(0, 4)
|
||||
assertArrayEquals("00007e90".hexToByteArray(), header)
|
||||
}
|
||||
|
||||
// Test 2: encode MDT offset -6:00 (-21600 s) -> header bytes FF FF AB A0
|
||||
@Test
|
||||
fun encodeMdtOffset() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = -21_600
|
||||
val payload = composer.composePayload()
|
||||
val header = payload.copyOfRange(0, 4)
|
||||
assertArrayEquals("ffffaba0".hexToByteArray(), header)
|
||||
}
|
||||
|
||||
// Test 3: encode IST offset +5:30 (19800 s) -> header bytes 00 00 4D 58
|
||||
@Test
|
||||
fun encodeIstOffset() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 19_800
|
||||
val payload = composer.composePayload()
|
||||
val header = payload.copyOfRange(0, 4)
|
||||
assertArrayEquals("00004d58".hexToByteArray(), header)
|
||||
}
|
||||
|
||||
// Test 4: badgeCount 0 -> payload text bytes are "MOTUWETHFRSASU" (ASCII)
|
||||
@Test
|
||||
fun badgeCountZeroShowsWeekdays() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 0
|
||||
composer.badgeCount = 0
|
||||
val payload = composer.composePayload()
|
||||
assertEquals(18, payload.size) // 4 header + 14 chars
|
||||
val text = payload.copyOfRange(4, 18).toString(Charsets.US_ASCII)
|
||||
assertEquals("MOTUWETHFRSASU", text)
|
||||
}
|
||||
|
||||
// Test 5: badgeCount 6 -> seven cells of "6 "
|
||||
@Test
|
||||
fun badgeCount6ShowsSixInAllCells() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 0
|
||||
composer.badgeCount = 6
|
||||
val payload = composer.composePayload()
|
||||
assertEquals(18, payload.size)
|
||||
val text = payload.copyOfRange(4, 18).toString(Charsets.US_ASCII)
|
||||
assertEquals("6 6 6 6 6 6 6 ", text)
|
||||
}
|
||||
|
||||
// Test 6: badgeCount 1-9 are left-aligned in cells
|
||||
@Test
|
||||
fun badgeCellFormatsSingleDigits() {
|
||||
assertEquals("1 ", WeekdayRegisterComposer.badgeCell(1))
|
||||
assertEquals("5 ", WeekdayRegisterComposer.badgeCell(5))
|
||||
assertEquals("9 ", WeekdayRegisterComposer.badgeCell(9))
|
||||
}
|
||||
|
||||
// Test 7: badgeCount 10 and 11 format as two digits
|
||||
@Test
|
||||
fun badgeCellFormatsTenAndEleven() {
|
||||
assertEquals("10", WeekdayRegisterComposer.badgeCell(10))
|
||||
assertEquals("11", WeekdayRegisterComposer.badgeCell(11))
|
||||
}
|
||||
|
||||
// Test 8: badgeCount 10 -> seven cells of "10"
|
||||
@Test
|
||||
fun badgeCount10ShowsTenInAllCells() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 0
|
||||
composer.badgeCount = 10
|
||||
val payload = composer.composePayload()
|
||||
val text = payload.copyOfRange(4, 18).toString(Charsets.US_ASCII)
|
||||
assertEquals("10101010101010", text)
|
||||
}
|
||||
|
||||
// Test 9: badgeCount 15 caps at "11" in all cells
|
||||
@Test
|
||||
fun badgeCount15ShowsElevenInAllCells() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 0
|
||||
composer.badgeCount = 15
|
||||
val payload = composer.composePayload()
|
||||
val text = payload.copyOfRange(4, 18).toString(Charsets.US_ASCII)
|
||||
assertEquals("11111111111111", text)
|
||||
}
|
||||
|
||||
// Test 10: badgeCell caps at 11 for counts >= 12
|
||||
@Test
|
||||
fun badgeCellCapsAtEleven() {
|
||||
assertEquals("11", WeekdayRegisterComposer.badgeCell(12))
|
||||
assertEquals("11", WeekdayRegisterComposer.badgeCell(99))
|
||||
assertEquals("11", WeekdayRegisterComposer.badgeCell(4321))
|
||||
}
|
||||
|
||||
// Test 11: seedFromReadback parses offset from 0x55 reply payload
|
||||
@Test
|
||||
fun seedFromReadbackParsesOffset() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
// Readback payload: 4-byte header (FF FF AB A0 = -21600) + weekday text (MOTUWETHFRSASU)
|
||||
val readbackPayload = "ffffaba04d4f545557455448465253415355".hexToByteArray()
|
||||
composer.seedFromReadback(readbackPayload)
|
||||
assertEquals(-21_600, composer.worldTimeOffsetSec)
|
||||
}
|
||||
|
||||
// Test 12: seedFromReadback rejects short payload
|
||||
@Test
|
||||
fun seedFromReadbackRejectsShortPayload() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 999 // set a non-default value
|
||||
val shortPayload = "ffff".hexToByteArray()
|
||||
composer.seedFromReadback(shortPayload)
|
||||
// worldTimeOffsetSec should not change if payload is too short
|
||||
assertEquals(999, composer.worldTimeOffsetSec)
|
||||
}
|
||||
|
||||
// Test 13: offset header invariant: changing badgeCount doesn't change header bytes
|
||||
@Test
|
||||
fun offsetHeaderInvariantWithBadgeChanges() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = -21_600
|
||||
|
||||
val payload0 = composer.composePayload()
|
||||
val header0 = payload0.copyOfRange(0, 4)
|
||||
|
||||
composer.badgeCount = 6
|
||||
val payload6 = composer.composePayload()
|
||||
val header6 = payload6.copyOfRange(0, 4)
|
||||
|
||||
composer.badgeCount = 10
|
||||
val payload10 = composer.composePayload()
|
||||
val header10 = payload10.copyOfRange(0, 4)
|
||||
|
||||
assertArrayEquals(header0, header6)
|
||||
assertArrayEquals(header0, header10)
|
||||
}
|
||||
|
||||
// Test 14: composePayload size is always 18 bytes (4 header + 14 text)
|
||||
@Test
|
||||
fun payloadSizeAlwaysEighteen() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
assertEquals(18, composer.composePayload().size)
|
||||
|
||||
composer.badgeCount = 5
|
||||
assertEquals(18, composer.composePayload().size)
|
||||
|
||||
composer.worldTimeOffsetSec = 32_400
|
||||
assertEquals(18, composer.composePayload().size)
|
||||
}
|
||||
|
||||
// Test 15: badgeCount 0 with Tokyo offset
|
||||
@Test
|
||||
fun weekdaysWithTokyoOffset() {
|
||||
val composer = WeekdayRegisterComposer()
|
||||
composer.worldTimeOffsetSec = 32_400
|
||||
composer.badgeCount = 0
|
||||
val payload = composer.composePayload()
|
||||
val header = payload.copyOfRange(0, 4)
|
||||
val text = payload.copyOfRange(4, 18).toString(Charsets.US_ASCII)
|
||||
assertArrayEquals("00007e90".hexToByteArray(), header)
|
||||
assertEquals("MOTUWETHFRSASU", text)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user