diff --git a/apps/ruuviwatch/ChangeLog b/apps/ruuviwatch/ChangeLog index 7953548cb..15ff601f0 100644 --- a/apps/ruuviwatch/ChangeLog +++ b/apps/ruuviwatch/ChangeLog @@ -1,2 +1,3 @@ 0.01: Hello Ruuvi Watch! 0.02: Clear gfx on startup. +0.03: Improve design and code, reduce flicker. diff --git a/apps/ruuviwatch/README.md b/apps/ruuviwatch/README.md index bf4358267..c96e032d7 100644 --- a/apps/ruuviwatch/README.md +++ b/apps/ruuviwatch/README.md @@ -1,25 +1,26 @@ -# Ruuvi Watch +# Ruuvi Watch -Watch the status of [RuuviTags](https://ruuvi.com) in range. +Watch the status of [RuuviTags](https://ruuvi.com) in range. - - Id - - Temperature (°C) - - Humidity (%) - - Pressure (hPa) - - Battery voltage +![Ruuvi Watch in action](/BangleApps/apps/ruuviwatch/ruuviwatch-in-action.jpg) - Also shows how "fresh" the data is (age of reading). +- Id +- Temperature (°C) +- Humidity (%) +- Pressure (hPa) +- Battery voltage - ## Usage - - - Scans for devices when launched and every N seconds. - - Page trough devices with BTN1/BTN3. - - Trigger scan with BTN2. +Also shows how "fresh" the data is (age of reading). + +## Usage + +- Scans for devices when launched and every N seconds. +- Page trough devices with BTN1/BTN3. +- Trigger scan with BTN2. ## Todo / ideas - - Allow to "name" known devices - - Prevent flicker when updating - - Include more data - - Support older Ruuvi protocols - +- Settings for scan frequency, units +- Allow to "name" known devices +- Include more data +- Support older Ruuvi protocols diff --git a/apps/ruuviwatch/metadata.json b/apps/ruuviwatch/metadata.json index 12f9ff4a0..413d96153 100644 --- a/apps/ruuviwatch/metadata.json +++ b/apps/ruuviwatch/metadata.json @@ -2,8 +2,8 @@ "name": "Ruuvi Watch", "shortName":"Ruuvi Watch", "icon": "ruuviwatch.png", - "version":"0.02", - "description": "Keep an eye on RuuviTag devices (https://ruuvi.com). Only shows RuuviTags using the v5 format.", + "version":"0.03", + "description": "Keep an eye on RuuviTag devices (https://ruuvi.com). For RuuviTags using the v5 format.", "readme":"README.md", "tags": "bluetooth", "supports": ["BANGLEJS"], diff --git a/apps/ruuviwatch/ruuviwatch-in-action.jpg b/apps/ruuviwatch/ruuviwatch-in-action.jpg new file mode 100644 index 000000000..08b391e84 Binary files /dev/null and b/apps/ruuviwatch/ruuviwatch-in-action.jpg differ diff --git a/apps/ruuviwatch/ruuviwatch.app.js b/apps/ruuviwatch/ruuviwatch.app.js index 46218a323..674319dd8 100644 --- a/apps/ruuviwatch/ruuviwatch.app.js +++ b/apps/ruuviwatch/ruuviwatch.app.js @@ -1,151 +1,252 @@ -require("Storage").write("ruuviwatch.info",{ - "id":"ruuviwatch", - "name":"Ruuvi Watch", - "src":"ruuviwatch.app.js", - "icon":"ruuviwatch.img" - }); - - const lookup = {}; - const ruuvis = []; - let current = 0; - - function int2Hex (str) { - return ('0' + str.toString(16).toUpperCase()).slice(-2); +require("Storage").write("ruuviwatch.info", { + id: "ruuviwatch", + name: "Ruuvi Watch", + src: "ruuviwatch.app.js", + icon: "ruuviwatch.img", +}); + +const lookup = {}; +const ruuvis = []; +let current = 0; +let scanning = false; + +const SCAN_FREQ = 1000 * 30; + +// Fonts +const FONT_L = "Vector:60"; +const FONT_M = "Vector:20"; +const FONT_S = "Vector:16"; + +// "layout" +const CENTER = g.getWidth() / 2; +const MIDDLE = g.getHeight() / 2; + +const PAGING_Y = 25; +const NAME_Y = PAGING_Y + 25; + +const TEMP_Y = MIDDLE; +const HUMID_PRESSURE_Y = MIDDLE + 50; + +const VOLT_Y = g.getHeight() - 15; +const SCANNING_Y = VOLT_Y - 25; + +function int2Hex(str) { + return ("0" + str.toString(16).toUpperCase()).slice(-2); +} + +function p(data) { + const OFFSET = 7; // 0-4 header, 5-6 Ruuvi id + const robject = {}; + robject.version = data[OFFSET]; + + let temperature = (data[OFFSET + 1] << 8) | (data[OFFSET + 2] & 0xff); + if (temperature > 32767) { + temperature -= 65534; } - - function p(data) { - const OFFSET = 7; // 0-4 header, 5-6 Ruuvi id - const robject = {}; - robject.version = data[OFFSET]; - - let temperature = (data[OFFSET+1] << 8) | (data[OFFSET+2] & 0xff); - if (temperature > 32767) { - temperature -= 65534; - } - robject.temperature = temperature / 200.0; - - robject.humidity = (((data[OFFSET+3] & 0xff) << 8) | (data[OFFSET+4] & 0xff)) / 400.0; - robject.pressure = ((((data[OFFSET+5] & 0xff) << 8) | (data[OFFSET+6] & 0xff)) + 50000) / 100.0; - - let accelerationX = (data[OFFSET+7] << 8) | (data[OFFSET+8] & 0xff); - if (accelerationX > 32767) accelerationX -= 65536; // two's complement - robject.accelerationX = accelerationX / 1000.0; - - let accelerationY = (data[OFFSET+9] << 8) | (data[OFFSET+10] & 0xff); - if (accelerationY > 32767) accelerationY -= 65536; // two's complement - robject.accelerationY = accelerationY / 1000.0; - - let accelerationZ = (data[OFFSET+11] << 8) | (data[OFFSET+12] & 0xff); - if (accelerationZ > 32767) accelerationZ -= 65536; // two's complement - robject.accelerationZ = accelerationZ / 1000.0; - - const powerInfo = ((data[OFFSET+13] & 0xff) << 8) | (data[OFFSET+14] & 0xff); - robject.battery = ((powerInfo >>> 5) + 1600) / 1000.0; - robject.txPower = (powerInfo & 0b11111) * 2 - 40; - robject.movementCounter = data[OFFSET+15] & 0xff; - robject.measurementSequenceNumber = ((data[OFFSET+16] & 0xff) << 8) | (data[OFFSET+17] & 0xff); - - robject.mac = [ - int2Hex(data[OFFSET+18]), - int2Hex(data[OFFSET+19]), - int2Hex(data[OFFSET+20]), - int2Hex(data[OFFSET+21]), - int2Hex(data[OFFSET+22]), - int2Hex(data[OFFSET+23]) - ].join(':'); - - robject.name = "Ruuvi " + int2Hex(data[OFFSET+22]) + int2Hex(data[OFFSET+23]); - return robject; - } - - function getAge(created) { - const now = new Date().getTime(); - const ago = ((now - created) / 1000).toFixed(0); - return ago > 0 ? ago + "s ago" : "now"; - } - - function redraw() { - if (ruuvis.length > 0 && ruuvis[current]) { - const ruuvi = ruuvis[current]; - g.clear(); - g.setFontAlign(0,0); - g.setFont("Vector",12); - g.drawString(" (" + (current+1) + "/" + ruuvis.length + ")", g.getWidth()/2, 10); - g.setFont("Vector",20); - g.drawString(ruuvi.name, g.getWidth()/2, 30); - g.setFont("Vector",12); - const age = getAge(ruuvi.time); - if(age > (5*60)) { - g.setColor("#ff0000"); - } else if (age > 60) { - g.setColor("#f39c12"); - } else { - g.setColor("#2ecc71"); - } - g.drawString(age, g.getWidth()/2, 50); - g.setColor("#ffffff"); - g.setFont("Vector",60); - g.drawString(ruuvi.temperature.toFixed(2) + "°c", g.getWidth()/2, g.getHeight()/2); - g.setFontAlign(0,1); - g.setFont("Vector",20); - g.drawString(ruuvi.humidity + "% " + ruuvi.pressure + "hPa ", g.getWidth()/2, g.getHeight()-30); - g.setFont("Vector",12); - g.drawString(ruuvi.battery + "v", g.getWidth()/2, g.getHeight()-10); + robject.temperature = temperature / 200.0; + + robject.humidity = + (((data[OFFSET + 3] & 0xff) << 8) | (data[OFFSET + 4] & 0xff)) / 400.0; + robject.pressure = + ((((data[OFFSET + 5] & 0xff) << 8) | (data[OFFSET + 6] & 0xff)) + 50000) / + 100.0; + + let accelerationX = (data[OFFSET + 7] << 8) | (data[OFFSET + 8] & 0xff); + if (accelerationX > 32767) accelerationX -= 65536; // two's complement + robject.accelerationX = accelerationX / 1000.0; + + let accelerationY = (data[OFFSET + 9] << 8) | (data[OFFSET + 10] & 0xff); + if (accelerationY > 32767) accelerationY -= 65536; // two's complement + robject.accelerationY = accelerationY / 1000.0; + + let accelerationZ = (data[OFFSET + 11] << 8) | (data[OFFSET + 12] & 0xff); + if (accelerationZ > 32767) accelerationZ -= 65536; // two's complement + robject.accelerationZ = accelerationZ / 1000.0; + + const powerInfo = + ((data[OFFSET + 13] & 0xff) << 8) | (data[OFFSET + 14] & 0xff); + robject.battery = ((powerInfo >>> 5) + 1600) / 1000.0; + robject.txPower = (powerInfo & 0b11111) * 2 - 40; + robject.movementCounter = data[OFFSET + 15] & 0xff; + robject.measurementSequenceNumber = + ((data[OFFSET + 16] & 0xff) << 8) | (data[OFFSET + 17] & 0xff); + + robject.mac = [ + int2Hex(data[OFFSET + 18]), + int2Hex(data[OFFSET + 19]), + int2Hex(data[OFFSET + 20]), + int2Hex(data[OFFSET + 21]), + int2Hex(data[OFFSET + 22]), + int2Hex(data[OFFSET + 23]), + ].join(":"); + + robject.name = + "Ruuvi " + int2Hex(data[OFFSET + 22]) + int2Hex(data[OFFSET + 23]); + return robject; +} + +function drawAge() { + const ruuvi = ruuvis[current]; + const created = ruuvi.time; + const now = new Date().getTime(); + const agoMs = now - created; + let age = ""; + if (agoMs > SCAN_FREQ) { + const agoS = agoMs / 1000; + // not seen since last scan; indicate age + if (agoS < 60) { + // seconds + age = agoS.toFixed(0) + "s ago"; + } else if (agoS < 60 * 60) { + // minutes + age = (agoS / 60).toFixed(0) + "m ago"; } else { - g.clear(); - g.drawImage(require("Storage").read("ruuviwatch.img"), g.getWidth()/2-24, g.getHeight()/2-24); - g.setFontAlign(0,0); - g.setFont("Vector",16); - g.drawString("Looking for Ruuvi...", g.getWidth()/2, g.getHeight()/2 + 50); + // hours + age = (agoS / 60 / 60).toFixed(0) + "h ago"; } } - - function scan() { - NRF.findDevices(function(devices) { - let foundNew = false; - devices.forEach(device => { - const data = p(device.data); - data.time = new Date().getTime(); - const idx = lookup[data.name]; - if (idx !== undefined) { - ruuvis[idx] = data; - } else { - lookup[data.name] = ruuvis.push(data)-1; - foundNew = true; - } - }); - redraw(); - if (foundNew) { - Bangle.buzz(); - g.flip(); - } - - }, {timeout : 2000, filters : [{ manufacturerData:{0x0499:{}} }] }); + if (agoMs > 5 * SCAN_FREQ) { + // old + g.setColor("#ff0000"); } - + g.setFont(FONT_S); + g.drawString(age, CENTER, SCANNING_Y); +} + +function redrawAge() { + const originalColor = g.getColor(); + g.clearRect(0, SCANNING_Y - 10, g.getWidth(), SCANNING_Y + 10); + g.setFont(FONT_S); + g.setColor("#666666"); + if (scanning) { + g.drawString("Scanning...", CENTER, SCANNING_Y); + } else if (ruuvis.length > 0 && ruuvis[current]) { + drawAge(); + } else { + g.drawString("No tags in sight", CENTER, SCANNING_Y); + } + g.setColor(originalColor); +} + +function redraw() { g.clear(); - g.drawImage(require("Storage").read("ruuviwatch.img"), g.getWidth()/2-24, g.getHeight()/2-24); - - var drawInterval = setInterval(redraw, 1000); - var scanInterval = setInterval(scan, 10000); - setWatch(() => { + g.setColor("#ffffff"); + + if (ruuvis.length > 0 && ruuvis[current]) { + const ruuvi = ruuvis[current]; + + // page + g.setFont(FONT_S); + g.drawString( + " (" + (current + 1) + "/" + ruuvis.length + ")", + CENTER, + PAGING_Y + ); + + // name + g.setFont(FONT_M); + g.drawString(ruuvi.name, CENTER, NAME_Y); + + // age + redrawAge(); + + // temp + g.setFont(FONT_L); + g.drawString(ruuvi.temperature.toFixed(2) + "°c", CENTER, TEMP_Y); + + // humid & pressure + g.setFont(FONT_M); + g.drawString( + ruuvi.humidity.toFixed(2) + "% " + ruuvi.pressure.toFixed(2) + "hPa ", + CENTER, + HUMID_PRESSURE_Y + ); + + // battery + g.setFont(FONT_S); + g.drawString(ruuvi.battery + "v", CENTER, VOLT_Y); + } else { + // no ruuvis + g.drawImage( + require("Storage").read("ruuviwatch.img"), + CENTER - 24, + MIDDLE - 24 + ); + } +} + +function scan() { + if (scanning) return; + scanning = true; + NRF.findDevices( + function (devices) { + let foundNew = false; + devices.forEach((device) => { + const data = p(device.data); + data.time = new Date().getTime(); + const idx = lookup[data.name]; + if (idx !== undefined) { + ruuvis[idx] = data; + } else { + lookup[data.name] = ruuvis.push(data) - 1; + foundNew = true; + } + }); + scanning = false; + redraw(); + if (foundNew) { + Bangle.buzz(); + } + }, + { timeout: 2000, filters: [{ manufacturerData: { 0x0499: {} } }] } + ); +} + +// START +// Button 1 pages up +setWatch( + () => { current--; if (current < 0) { - current = ruuvis.length-1; + current = ruuvis.length - 1; } redraw(); - }, BTN1, {repeat:true}); - - setWatch(() => { + }, + BTN1, + { repeat: true } +); +// button triggers scan +setWatch( + () => { scan(); - }, BTN2, {repeat:true}); - - setWatch(() => { + }, + BTN2, + { repeat: true } +); +// button 3 pages down +setWatch( + () => { current++; if (current >= ruuvis.length) { current = 0; } redraw(); - }, BTN3, {repeat:true}); - - scan(); \ No newline at end of file + }, + BTN3, + { repeat: true } +); + +g.setFontAlign(0, 0); +g.clear(); +g.drawImage( + require("Storage").read("ruuviwatch.img"), + CENTER - 24, + MIDDLE - 24 +); + +g.setFont(FONT_M); +g.drawString("Ruuvi Watch", CENTER, HUMID_PRESSURE_Y); + +var ageInterval = setInterval(redrawAge, 1000); +var scanInterval = setInterval(scan, SCAN_FREQ); +scan();