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:
Ken Blizzard-Caron
2026-07-24 22:29:00 +02:00
committed by José Rebelo
parent fe3770a79e
commit fd1ffa28da
18 changed files with 2233 additions and 0 deletions
@@ -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();
@@ -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
}
@@ -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
}
@@ -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;
@@ -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()
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
@@ -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 }
}
}
+1
View File
@@ -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
@@ -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)
}
}
@@ -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)
)
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
}