This commit is contained in:
Luxferre
2022-10-04 17:47:08 +03:00
commit 9c5723d770
7 changed files with 326 additions and 0 deletions
+50
View File
@@ -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.
+47
View File
@@ -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()
})
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

+66
View File
@@ -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>
+23
View File
@@ -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"
}
}
+140
View File
@@ -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)