rcvd

A JS library and a KaiOS 2.5.x application for BLE-enabled Casio watch time synchronization
git clone git://git.luxferre.top/rcvd.git
Log | Files | Refs | README | LICENSE

commit 9c5723d7702a0df07c2e44f0720cc493b736e128
Author: Luxferre <lux@ferre>
Date:   Tue,  4 Oct 2022 17:47:08 +0300

LOL

Diffstat:
AREADME.md | 50++++++++++++++++++++++++++++++++++++++++++++++++++
Aapp.js | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aimg/icon112.png | 0
Aimg/icon56.png | 0
Aindex.html | 66++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Amanifest.webapp | 23+++++++++++++++++++++++
Arcvd.js | 140+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
7 files changed, 326 insertions(+), 0 deletions(-)

diff --git a/README.md b/README.md @@ -0,0 +1,50 @@ +# RCVD: sync BLE-enabled Casio watches from KaiOS and modern Web + +RCVD is the missing KaiOS application and, first and foremost, a [JS library](rcvd.js) that allows you to interact with a set of most recent Casio watches via Bluetooth 4.0LE communication protocol. + +## Why? + +- Official Casio apps like G-Shock Connected, Oceanus Connected etc. are too bulky, proprietary and opaque, require too many permissions to work and only available for two major mobile platforms. +- The only existing FOSS alternative for the time being is no less bulky, written for Android only, has a lot of obscure and suboptimal code and also doesn't solve Android's problem with location permission required to be granted to use BLE scanning. +- As such, up until this point, there was no way to sync these watches from a Bluetooth-enabled PC in a fully cross-platform way. +- Up until this point, KaiOS also didn't have any simple and straightforward examples of working with Bluetooth GATT characteristics on real-world consumer-grade devices. +- BLE Web API interfaces totally differ across KaiOS and modern browsers, so the lowest-level abstraction layer is still necessary. + +## Supported host platforms + +- As a certified app: KaiOS 2.5.x (install directly from this repo via WebIDE); +- As a JS library: Chrome 85+, Opera 43+, Edge 79+ and other modern Chromium-based browsers (including Android ones) except the ones with support excplicitly disabled, such as Brave. + +## Supported models + +- GW-B5600 +- GMW-B5000 +- OCW-T200 + +The list is incomplete and your watch may be supported too. Please [let me know](https://matrix.to/#/@luxferre:anonymousland.org) if this is the case! + +## How to use the app + +1. Ensure your phone time is synced from the network. +2. Open the app and press SYNC (central D-pad key). +3. Enter the fast or full sync mode on your watch (depends on your model, please refer to the manual). +4. Wait until the time is set. If the app cannot connect, retry steps 2-3 until the app says your model is synced. + +## How to use the library + +The [library](rcvd.js) can be used "as is" or as a building block for more advanced tools. For its purposes, it exposes several methods: + +- `RCVD.connect() -> Promise` - connect to a watch, perform the necessary handshake operations and resolve the Promise on success; +- `RCVD.disconnect()` - disconnect from the currently connected watch's GATT server; +- `RCVD.sync(Date obj?) -> Promise` - perform time synchronization with the **optional** Date object (if it's not passed, actual current local time will be set), running all the required DST/world time setting cycles before setting the actual time; +- `RCVD.getModel() -> String` - return the watch model name obdained during the connection process; +- `RCVD.rawRead(uint8 param1, uint8 param2...) -> Promise(Uint8Array)` - accept variable number of integer parameters and execute the read command on the watch, resolving to the result in a Uint8Array value; +- `RCVD.rawWrite(Uint8Array command) -> Promise` - accept a write command shaped as byte data in Uint8Array and resolve on successful write operation. + +The last two methods are not used in the app directly in their exposed format, but allow to use the library to easily create more advanced applications to interact with Casio watches based on already known protocol details, without having to worry about low-level connectivity bits. + +## Credits + +Created by Luxferre in 2022. Both the app and the library are released into public domain. + +Made in Ukraine. diff --git a/app.js b/app.js @@ -0,0 +1,47 @@ +addEventListener('DOMContentLoaded', _ => { + + var fmt2 = n => ('0'+n).slice(-2), + tElem = document.getElementById('timeline'), + dElem = document.getElementById('dateline'), + sElem = document.getElementById('status'), + upd = function() { + var now = new Date() + tElem.textContent = [now.getHours(), now.getMinutes(), now.getSeconds()].map(fmt2).join(':') + dElem.textContent = [now.getFullYear()].concat([now.getMonth()+1, now.getDate()].map(fmt2)).join('-') + setTimeout(upd, 1000) + }, runSync = function() { + sElem.textContent = 'Connecting...' + RCVD.disconnect() + RCVD.connect().then(mode => { + var model = RCVD.getModel() + console.log('Main setting mode:', mode) + sElem.textContent = 'Syncing ' + model + RCVD.sync().then(_ => { + sElem.textContent = model + ' synced' + RCVD.disconnect() + }).catch(e => {console.error(e)}) + }).catch(e => { + sElem.textContent = 'Failed, please retry!' + console.error(e) + }) + } + + addEventListener('keydown', e => { + if(e.key === 'MicrophoneToggle') + e.preventDefault() + else if(e.key === 'Enter') { + e.preventDefault() + e.stopPropagation() + runSync() + } + }) + + addEventListener('click', e => { + if(e.target.id === 'syncbtn') { + e.preventDefault() + runSync() + } + }) + + upd() +}) diff --git a/img/icon112.png b/img/icon112.png Binary files differ. diff --git a/img/icon56.png b/img/icon56.png Binary files differ. diff --git a/index.html b/index.html @@ -0,0 +1,66 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <meta name=viewport content="width=device-width"> + <title>RCVD</title> + <style> + * {box-sizing: border-box; margin: 0; padding: 0} + html, body { + text-align:center; + width: 100%; + height: 100%; + top: 0; + left: 0; + position: fixed; + background: #000; + color: cyan; + font-family: sans-serif; + font-size: 17px; + } + header {padding-top:5px} + h1 { + font-size: 17px; + font-weight: 600; + padding: 6px 0; + } + h2 { + font-size: 17px; + font-weight: 400; + padding: 2px 0; + color: yellow; + } + main.div { + font-size: 2rem; + } + #timeline {font-size:54px; padding-top: 1rem} + #dateline {font-size:26px; padding: 1rem 0} + #status {padding-top:5px} + #syncbtn {cursor:pointer} + footer { + bottom: 0; + height: 24px; + position: fixed; + color: yellow; + width: 100%; + font-weight:600; + } + </style> + </head> + <body> + <header> + <h1>RCVD</h1> + <h2>by Luxferre</h2> + </header> + <main> + <div id=timeline></div> + <div id=dateline></div> + <div id=status>Press both sync buttons</div> + </main> + <footer> + <span id=syncbtn>SYNC</span> + </footer> + <script src="rcvd.js"></script> + <script src=app.js></script> + </body> +</html> diff --git a/manifest.webapp b/manifest.webapp @@ -0,0 +1,23 @@ +{ + "version": "0.0.1", + "name": "RCVD", + "description": "Casio BLE-enabled watches time synchronization app", + "type": "certified", + "origin": "app://rcvd.luxferre.top", + "launch_path": "/index.html", + "orientation": ["portrait"], + "installs_allowed_from": [ + "*" + ], + "icons": { + "56": "/img/icon56.png", + "112": "/img/icon112.png" + }, + "developer": { + "name": "Luxferre" + }, + "permissions": { + "bluetooth": "Perform BLE operations" + } +} + diff --git a/rcvd.js b/rcvd.js @@ -0,0 +1,140 @@ +// RCVD.js: a small library for syncing time on BLE-enabled Casio watches +// Tested on: GW-B5600BC, OCW-T200S +// Compatible with both KaiOS 2.5.x and in-progress Web Bluetooth API spec +// Created by Luxferre in 2022, released into public domain + +RCVD = (function(nav) { + var readerChar, writerChar, outputQueue, wt, + isKai = 'mozBluetooth' in nav && !('bluetooth' in nav), //whether or not we're interacting using non-standard KaiOS 2.5.x API + btx = isKai ? nav.mozBluetooth : nav.bluetooth, + waitForOutput = _ => new Promise((res, rej) => { + (wt = function() { + if(outputQueue.length) + res(outputQueue.pop()) + else setTimeout(wt, 1) + })() + }), + watchModel = '', + localgatt = null, //local GATT reference for disconnection + TD = new TextDecoder(), + ids = s => `26eb00${s}-b012-49a8-b1f8-394fb2032b0f`, + scanids = ['00001804-0000-1000-8000-00805f9b34fb', ids('0d')], + connectWatch = disconnectCb => new Promise((res, rej) => { + if(!disconnectCb) disconnectCb = ()=>{} + if(isKai) { + var adp = btx.defaultAdapter + adp.startLeScan(scanids).then(dHandle => { + dHandle.ondevicefound = e => { + adp.stopLeScan(dHandle) + localgatt = e.device.gatt + localgatt.connect(false).then(_ => { + outputQueue = [] + localgatt.oncharacteristicchanged = e => { + outputQueue.push(new Uint8Array(e.characteristic.value)) + } + localgatt.discoverServices().then(_ => { + for(let srv of localgatt.services) { + for(var chr of srv.characteristics) { + if(chr.uuid === ids('2c')) { + readerChar = chr + readerChar.writeValueWithoutResponse = readerChar.writeValue + } + else if(chr.uuid === ids('2d')) { + writerChar = chr + writerChar.writeValueWithResponse = writerChar.writeValue + } + } + } + writerChar.startNotifications().then(res).catch(rej) + }) + }).catch(rej) + } + }).catch(rej) + } + else btx.requestDevice({filters:[{services:[scanids[0]]},{services:[scanids[1]]}]}) + .then(dev => { + dev.addEventListener('gattserverdisconnected', disconnectCb) + localgatt = dev.gatt + return localgatt.connect() + }) + .then(conn => conn.getPrimaryService(ids('0d'))) + .then(srv => srv.getCharacteristics()) + .then(arr => { + for(let chr of arr) { + if(chr.uuid === ids('2c')) + readerChar = chr + else if(chr.uuid === ids('2d')) + writerChar = chr + } + outputQueue = [] + writerChar.startNotifications().then(chr => { + chr.addEventListener('characteristicvaluechanged', e => { + outputQueue.push(new Uint8Array(e.target.value.buffer)) + }) + res() + }).catch(rej) + }).catch(rej) + }), + execReadCmd = (...args) => readerChar.writeValueWithoutResponse(Uint8Array.from(args).buffer).then(waitForOutput), + execWriteCmd = cmddata => writerChar.writeValueWithResponse(cmddata.buffer), + + //some logic needed to init the time sync process + //these parameters need to be read from the watch and then written back + + plgen = k => [...Array(6)].map((a,i)=>[k,i]), //property list generator + cyclePresyncProperties = _ => new Promise((res, rej) => { + var plist = [[29,0], [29,2], [29,4]].concat(watchModel.indexOf('OCW')>5 ? [[30,0],[30,1]] : [...plgen(30),...plgen(31)]); //populate the props list + (function cycle() { + var p = plist.shift() + if(p) //we still have properties + execReadCmd.apply(null, p) + .then(execWriteCmd) + .then(cycle).catch(rej) + else //properties over + res() + })() + }) + + return { + connect: dcb => new Promise((res, rej) => { //resolve to true if full setting mode, or false if just sync + connectWatch(dcb) + .then(_ => execReadCmd(0x23).then(v => watchModel = TD.decode(v).slice(1).replace(/\x00/g,''))) + .then(_ => execReadCmd(0x10)) + .then(features => { + if(features[8]<2) { //we entered full setting mode, sync application info first + execReadCmd(0x22).then(info => //set the correct initialization after BLE reset on the watch + (info[11] || Math.min(...info.slice(1,11))^255) ? info : + execWriteCmd(Uint8Array.from([0x22, 0x34, 0x88, 0xF4, 0xE5, 0xD5, 0xAF, 0xC8, 0x29, 0xE0, 0x6D, 0x02])) + ).then(info => { + if(info instanceof Promise) + info.then(_=>res(true)).catch(rej) + else res(true) + }).catch(rej) + } + else res(false) + }).catch(rej) + }), + getModel: _ => watchModel, + rawRead: execReadCmd, + rawWrite: execWriteCmd, + sync: dObj => new Promise((res, rej) => { + cyclePresyncProperties().then(_ => { + //set to the next second to make up for latency + var d = dObj || new Date(Date.now() + 1000), year = d.getFullYear() + execWriteCmd(Uint8Array.from([ + 9, + year&255, year>>>8, + d.getMonth()+1, d.getDate(), + d.getHours(), d.getMinutes(), d.getSeconds(), + d.getDay(), 0, 0, 1 + ])).then(res).catch(res) //not a mistake! + }).catch(rej) + }), + disconnect: _ => { + if(localgatt) { + localgatt.disconnect() + localgatt = null + } + } + } +})(navigator)