diff --git a/apps/aviatorclk/.gitignore b/apps/aviatorclk/.gitignore new file mode 100644 index 000000000..bdbc0d22e --- /dev/null +++ b/apps/aviatorclk/.gitignore @@ -0,0 +1 @@ +aviatorclk.json diff --git a/apps/aviatorclk/ChangeLog b/apps/aviatorclk/ChangeLog new file mode 100644 index 000000000..971e5b97e --- /dev/null +++ b/apps/aviatorclk/ChangeLog @@ -0,0 +1 @@ +1.00: initial release diff --git a/apps/aviatorclk/README.md b/apps/aviatorclk/README.md new file mode 100644 index 000000000..fe7376b5d --- /dev/null +++ b/apps/aviatorclk/README.md @@ -0,0 +1,36 @@ +# Aviator Clock + +A clock for aviators, with local time and UTC - and the latest METAR +(Meteorological Aerodrome Report) for the nearest airport + + + + +This app depends on the [AVWX module](?id=avwx). Make sure to configure that +module after installing this app. + + +## Features + +- Local time (with optional seconds) +- UTC / Zulu time +- Weekday and day of the month +- Latest METAR for the nearest airport (scrollable) + +Tap the screen in the top or bottom half to scroll the METAR text (in case not +the whole report fits on the screen). + +The colour of the METAR text will change to orange if the report is more than +1h old, and red if it's older than 1.5h. + + +## Settings + +- **Show Seconds**: to conserve battery power, you can turn the seconds display off +- **Invert Scrolling**: swaps the METAR scrolling direction of the top and bottom taps + + +## Author + +Flaparoo [github](https://github.com/flaparoo) + diff --git a/apps/aviatorclk/aviatorclk-icon.js b/apps/aviatorclk/aviatorclk-icon.js new file mode 100644 index 000000000..508769a66 --- /dev/null +++ b/apps/aviatorclk/aviatorclk-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwg96iIACCqMBCwYABiAWQiUiAAUhDBwWGDCAWHDAYuMCw4ABGBYWKGBYuLGBcBLpAXNFxhIKFxgwCIyhIJC58hC44WNC5B2NPBIXbBYIAHNgIXKCpAYEC5AhBII8SDAQXJMI5EEC6ZREC6EhFwkRO4zuCC46AFAgLYEC4YCBIoaADF4gXEKgYXDVBAcCXxBZDkcyDRAXHmILCif//4GEC5f/PQQWB//zbAX/C5gAKC78BC6K/In4WJ+YXW+QXHMAURl4XJeQYWEGALhBC4q+BYYLbDFwowCkLTCRIyNHGArNBC48SFxIXCMApHDOwQXIJAIQCAAaWCDYJGIDAipGFwQWKDAUSDAnzUoIWMDAcjn/zUgQWOPYYADOZJjKFqIAp")) diff --git a/apps/aviatorclk/aviatorclk.app.js b/apps/aviatorclk/aviatorclk.app.js new file mode 100644 index 000000000..1d99fdbde --- /dev/null +++ b/apps/aviatorclk/aviatorclk.app.js @@ -0,0 +1,283 @@ +/* + * Aviator Clock - Bangle.js + * + */ + +const COLOUR_DARK_GREY = 0x4208; // same as: g.setColor(0.25, 0.25, 0.25) +const COLOUR_GREY = 0x8410; // same as: g.setColor(0.5, 0.5, 0.5) +const COLOUR_LIGHT_GREY = 0xc618; // same as: g.setColor(0.75, 0.75, 0.75) +const COLOUR_RED = 0xf800; // same as: g.setColor(1, 0, 0) +const COLOUR_BLUE = 0x001f; // same as: g.setColor(0, 0, 1) +const COLOUR_YELLOW = 0xffe0; // same as: g.setColor(1, 1, 0) +const COLOUR_LIGHT_CYAN = 0x87ff; // same as: g.setColor(0.5, 1, 1) +const COLOUR_DARK_YELLOW = 0x8400; // same as: g.setColor(0.5, 0.5, 0) +const COLOUR_DARK_CYAN = 0x0410; // same as: g.setColor(0, 0.5, 0.5) +const COLOUR_ORANGE = 0xfc00; // same as: g.setColor(1, 0.5, 0) + +const APP_NAME = 'aviatorclk'; + +const horizontalCenter = g.getWidth()/2; +const mainTimeHeight = 38; +const secondaryFontHeight = 22; +const dateColour = ( g.theme.dark ? COLOUR_YELLOW : COLOUR_BLUE ); +const UTCColour = ( g.theme.dark ? COLOUR_LIGHT_CYAN : COLOUR_DARK_CYAN ); +const separatorColour = ( g.theme.dark ? COLOUR_LIGHT_GREY : COLOUR_DARK_GREY ); + +const avwx = require('avwx'); + + +// read in the settings +var settings = Object.assign({ + showSeconds: true, + invertScrolling: false, +}, require('Storage').readJSON(APP_NAME+'.json', true) || {}); + + +// globals +var drawTimeout; +var secondsInterval; +var avwxTimeout; + +var AVWXrequest; +var METAR = ''; +var METARlinesCount = 0; +var METARscollLines = 0; +var METARts; + + + +// date object to time string in format HH:MM[:SS] +// (with a leading 0 for hours if required, unlike the "locale" time() function) +function timeStr(date, seconds) { + let timeStr = date.getHours().toString(); + if (timeStr.length == 1) timeStr = '0' + timeStr; + let minutes = date.getMinutes().toString(); + if (minutes.length == 1) minutes = '0' + minutes; + timeStr += ':' + minutes; + if (seconds) { + let seconds = date.getSeconds().toString(); + if (seconds.length == 1) seconds = '0' + seconds; + timeStr += ':' + seconds; + } + return timeStr; +} + + +// draw the METAR info +function drawAVWX() { + let now = new Date(); + let METARage = 0; // in minutes + if (METARts) { + METARage = Math.floor((now - METARts) / 60000); + } + + g.setBgColor(g.theme.bg); + + let y = Bangle.appRect.y + mainTimeHeight + secondaryFontHeight + 4; + g.clearRect(0, y, g.getWidth(), y + (secondaryFontHeight * 4)); + + g.setFontAlign(0, -1).setFont("Vector", secondaryFontHeight); + if (METARage > 90) { // older than 1.5h + g.setColor(COLOUR_RED); + } else if (METARage > 60) { // older than 1h + g.setColor( g.theme.dark ? COLOUR_ORANGE : COLOUR_DARK_YELLOW ); + } else { + g.setColor(g.theme.fg); + } + let METARlines = g.wrapString(METAR, g.getWidth()); + METARlinesCount = METARlines.length; + METARlines.splice(0, METARscollLines); + g.drawString(METARlines.join("\n"), horizontalCenter, y, true); + + if (! avwxTimeout) { avwxTimeout = setTimeout(updateAVWX, 5 * 60000); } +} + +// update the METAR info +function updateAVWX() { + if (avwxTimeout) clearTimeout(avwxTimeout); + avwxTimeout = undefined; + + METAR = '\nGetting GPS fix'; + METARlinesCount = 0; METARscollLines = 0; + METARts = undefined; + drawAVWX(); + + Bangle.setGPSPower(true, APP_NAME); + Bangle.on('GPS', fix => { + // prevent multiple, simultaneous requests + if (AVWXrequest) { return; } + + if ('fix' in fix && fix.fix != 0 && fix.satellites >= 4) { + Bangle.setGPSPower(false, APP_NAME); + let lat = fix.lat; + let lon = fix.lon; + + METAR = '\nRequesting METAR'; + METARlinesCount = 0; METARscollLines = 0; + METARts = undefined; + drawAVWX(); + + // get latest METAR from nearest airport (via AVWX API) + AVWXrequest = avwx.request('metar/'+lat+','+lon, 'onfail=nearest', data => { + if (avwxTimeout) clearTimeout(avwxTimeout); + avwxTimeout = undefined; + + let METARjson = JSON.parse(data.resp); + + if ('sanitized' in METARjson) { + METAR = METARjson.sanitized; + } else { + METAR = 'No "sanitized" METAR data found!'; + } + METARlinesCount = 0; METARscollLines = 0; + + if ('time' in METARjson) { + METARts = new Date(METARjson.time.dt); + let now = new Date(); + let METARage = Math.floor((now - METARts) / 60000); // in minutes + if (METARage <= 30) { + // some METARs update every 30 min -> attempt to update after METAR is 35min old + avwxTimeout = setTimeout(updateAVWX, (35 - METARage) * 60000); + } else if (METARage <= 60) { + // otherwise, attempt METAR update after it's 65min old + avwxTimeout = setTimeout(updateAVWX, (65 - METARage) * 60000); + } + } else { + METARts = undefined; + } + + drawAVWX(); + AVWXrequest = undefined; + + }, error => { + // AVWX API request failed + console.log(error); + METAR = 'ERR: ' + error; + METARlinesCount = 0; METARscollLines = 0; + METARts = undefined; + drawAVWX(); + AVWXrequest = undefined; + }); + } + }); +} + + +// draw only the seconds part of the main clock +function drawSeconds() { + let now = new Date(); + let seconds = now.getSeconds().toString(); + if (seconds.length == 1) seconds = '0' + seconds; + let y = Bangle.appRect.y + mainTimeHeight - 3; + g.setFontAlign(-1, 1).setFont("Vector", secondaryFontHeight).setColor(COLOUR_GREY); + g.drawString(seconds, horizontalCenter + 54, y, true); +} + +// sync seconds update +function syncSecondsUpdate() { + drawSeconds(); + setTimeout(function() { + drawSeconds(); + secondsInterval = setInterval(drawSeconds, 1000); + }, 1000 - (Date.now() % 1000)); +} + +// set timeout for per-minute updates +function queueDraw() { + if (drawTimeout) clearTimeout(drawTimeout); + drawTimeout = setTimeout(function() { + drawTimeout = undefined; + if (METARts) { + let now = new Date(); + let METARage = Math.floor((now - METARts) / 60000); + if (METARage > 60) { + // the METAR colour might have to be updated: + drawAVWX(); + } + } + draw(); + }, 60000 - (Date.now() % 60000)); +} + +// draw top part of clock (main time, date and UTC) +function draw() { + let now = new Date(); + let nowUTC = new Date(now + (now.getTimezoneOffset() * 1000 * 60)); + + // prepare main clock area + let y = Bangle.appRect.y; + + g.setBgColor(g.theme.bg); + + // main time display + g.setFontAlign(0, -1).setFont("Vector", mainTimeHeight).setColor(g.theme.fg); + g.drawString(timeStr(now, false), horizontalCenter, y, true); + + // prepare second line (UTC and date) + y += mainTimeHeight; + g.clearRect(0, y, g.getWidth(), y + secondaryFontHeight - 1); + + // weekday and day of the month + g.setFontAlign(-1, -1).setFont("Vector", secondaryFontHeight).setColor(dateColour); + g.drawString(require("locale").dow(now, 1).toUpperCase() + ' ' + now.getDate(), 0, y, false); + + // UTC + g.setFontAlign(1, -1).setFont("Vector", secondaryFontHeight).setColor(UTCColour); + g.drawString(timeStr(nowUTC, false) + "Z", g.getWidth(), y, false); + + queueDraw(); +} + + +// initialise +g.clear(true); + +// scroll METAR lines on taps +Bangle.setUI("clockupdown", action => { + switch (action) { + case -1: // top tap + if (settings.invertScrolling) { + if (METARscollLines > 0) + METARscollLines--; + } else { + if (METARscollLines < METARlinesCount - 4) + METARscollLines++; + } + break; + case 1: // bottom tap + if (settings.invertScrolling) { + if (METARscollLines < METARlinesCount - 4) + METARscollLines++; + } else { + if (METARscollLines > 0) + METARscollLines--; + } + break; + default: + // ignore + } + drawAVWX(); +}); + +// load widgets +Bangle.loadWidgets(); +Bangle.drawWidgets(); + +// draw static separator line +y = Bangle.appRect.y + mainTimeHeight + secondaryFontHeight; +g.setColor(separatorColour); +g.drawLine(0, y, g.getWidth(), y); + +// draw times and request METAR +draw(); +if (settings.showSeconds) + syncSecondsUpdate(); +updateAVWX(); + + +// TMP for debugging: +//METAR = 'YAAA 011100Z 21014KT CAVOK 23/08 Q1018 RMK RF000/0000'; +//METAR = 'YAAA 150900Z 14012KT 9999 SCT045 BKN064 26/14 Q1012 RMK RF000/0000 DL-W/DL-NW'; +//METAR = 'YAAA 020030Z VRB CAVOK'; +//METARts = new Date(Date.now() - 61 * 60000); // 61 to trigger warning, 91 to trigger alert + diff --git a/apps/aviatorclk/aviatorclk.png b/apps/aviatorclk/aviatorclk.png new file mode 100644 index 000000000..af88cfbc4 Binary files /dev/null and b/apps/aviatorclk/aviatorclk.png differ diff --git a/apps/aviatorclk/aviatorclk.settings.js b/apps/aviatorclk/aviatorclk.settings.js new file mode 100644 index 000000000..6db212ef1 --- /dev/null +++ b/apps/aviatorclk/aviatorclk.settings.js @@ -0,0 +1,35 @@ +(function(back) { + var FILE = "aviatorclk.json"; + + // Load settings + var settings = Object.assign({ + showSeconds: true, + invertScrolling: false, + }, require('Storage').readJSON(FILE, true) || {}); + + function writeSettings() { + require('Storage').writeJSON(FILE, settings); + } + + // Show the menu + E.showMenu({ + "" : { "title" : "AV8R Clock" }, + "< Back" : () => back(), + 'Show Seconds': { + value: !!settings.showSeconds, // !! converts undefined to false + format: v => v ? "On" : "Off", + onchange: v => { + settings.showSeconds = v; + writeSettings(); + } + }, + 'Invert Scrolling': { + value: !!settings.invertScrolling, // !! converts undefined to false + format: v => v ? "On" : "Off", + onchange: v => { + settings.invertScrolling = v; + writeSettings(); + } + }, + }); +}) diff --git a/apps/aviatorclk/metadata.json b/apps/aviatorclk/metadata.json new file mode 100644 index 000000000..6ae8c4a18 --- /dev/null +++ b/apps/aviatorclk/metadata.json @@ -0,0 +1,20 @@ +{ + "id": "aviatorclk", + "name": "Aviator Clock", + "shortName":"AV8R Clock", + "version":"1.00", + "description": "A clock for aviators, with local time and UTC - and the latest METAR for the nearest airport", + "icon": "aviatorclk.png", + "screenshots": [{ "url": "screenshot.png" }, { "url": "screenshot2.png" }], + "type": "clock", + "tags": "clock", + "supports": ["BANGLEJS2"], + "dependencies" : { "avwx": "module" }, + "readme": "README.md", + "storage": [ + { "name":"aviatorclk.app.js", "url":"aviatorclk.app.js" }, + { "name":"aviatorclk.settings.js", "url":"aviatorclk.settings.js" }, + { "name":"aviatorclk.img", "url":"aviatorclk-icon.js", "evaluate":true } + ], + "data": [{ "name":"aviatorclk.json" }] +} diff --git a/apps/aviatorclk/screenshot.png b/apps/aviatorclk/screenshot.png new file mode 100644 index 000000000..127946f42 Binary files /dev/null and b/apps/aviatorclk/screenshot.png differ diff --git a/apps/aviatorclk/screenshot2.png b/apps/aviatorclk/screenshot2.png new file mode 100644 index 000000000..e00e2238b Binary files /dev/null and b/apps/aviatorclk/screenshot2.png differ diff --git a/apps/avwx/ChangeLog b/apps/avwx/ChangeLog new file mode 100644 index 000000000..971e5b97e --- /dev/null +++ b/apps/avwx/ChangeLog @@ -0,0 +1 @@ +1.00: initial release diff --git a/apps/avwx/README.md b/apps/avwx/README.md new file mode 100644 index 000000000..a954d118f --- /dev/null +++ b/apps/avwx/README.md @@ -0,0 +1,41 @@ +# AVWX Module + +This is a module/library to use the [AVWX](https://account.avwx.rest/) Aviation +Weather API. It doesn't include an app. + + +## Configuration + +You will need an AVWX account (see above for link) and generate an API token. +The free "Hobby" plan is normally sufficient, but please consider supporting +the AVWX project. + +After installing the module on your Bangle, use the "interface" page (floppy +disk icon) in the App Loader to set the API token. + + +## Usage + +Include the module in your app with: + + const avwx = require('avwx'); + +Then use the exported function, for example to get the "sanitized" METAR from +the nearest station to a lat/lon coordinate pair: + + reqID = avwx.request('metar/'+lat+','+lon, + 'filter=sanitized&onfail=nearest', + data => { console.log(data); }, + error => { console.log(error); }); + +The returned reqID can be useful to track whether a request has already been +made (ie. the app is still waiting on a response). + +Please consult the [AVWX documentation](https://avwx.docs.apiary.io/) for +information about the available end-points and request parameters. + + +## Author + +Flaparoo [github](https://github.com/flaparoo) + diff --git a/apps/avwx/avwx.js b/apps/avwx/avwx.js new file mode 100644 index 000000000..1a9193b26 --- /dev/null +++ b/apps/avwx/avwx.js @@ -0,0 +1,47 @@ +/* + * AVWX Bangle Module + * + * AVWX doco: https://avwx.docs.apiary.io/ + * test AVWX API request with eg.: curl -X GET 'https://avwx.rest/api/metar/43.9844,-88.5570?token=...' + * + */ + + +const AVWX_BASE_URL = 'https://avwx.rest/api/'; // must end with a slash +const AVWX_CONFIG_FILE = 'avwx.json'; + + +// read in the settings +var AVWXsettings = Object.assign({ + AVWXtoken: '', +}, require('Storage').readJSON(AVWX_CONFIG_FILE, true) || {}); + + +/** + * Make an AVWX API request + * + * @param {string} requestPath API path (after /api/), eg. 'meta/KOSH' + * @param {string} params optional request parameters, eg. 'onfail=nearest' (use '&' in the string to combine multiple params) + * @param {function} successCB callback if the API request was successful - will supply the returned data: successCB(data) + * @param {function} failCB callback in case the API request failed - will supply the error: failCB(error) + * + * @returns {number} the HTTP request ID + * + * Example: + * reqID = avwx.request('metar/'+lat+','+lon, + * 'filter=sanitized&onfail=nearest', + * data => { console.log(data); }, + * error => { console.log(error); }); + * + */ +exports.request = function(requestPath, optParams, successCB, failCB) { + if (! AVWXsettings.AVWXtoken) { + failCB('No AVWX API Token defined!'); + return undefined; + } + let params = 'token='+AVWXsettings.AVWXtoken; + if (optParams) + params += '&'+optParams; + return Bangle.http(AVWX_BASE_URL+requestPath+'?'+params).then(successCB).catch(failCB); +}; + diff --git a/apps/avwx/avwx.png b/apps/avwx/avwx.png new file mode 100644 index 000000000..129c9f9f4 Binary files /dev/null and b/apps/avwx/avwx.png differ diff --git a/apps/avwx/interface.html b/apps/avwx/interface.html new file mode 100644 index 000000000..cdd77cb74 --- /dev/null +++ b/apps/avwx/interface.html @@ -0,0 +1,47 @@ + +
+ + + + +To use the AVWX API, you need an account and generate an API token. The free "Hobby" plan is sufficient, but please consider supporting the AVWX project.
++ + +
++ +
+ + + + + + + + diff --git a/apps/avwx/metadata.json b/apps/avwx/metadata.json new file mode 100644 index 000000000..0b07f32d4 --- /dev/null +++ b/apps/avwx/metadata.json @@ -0,0 +1,18 @@ +{ + "id": "avwx", + "name": "AVWX Module", + "shortName":"AVWX", + "version":"1.00", + "description": "Module/library for the AVWX API", + "icon": "avwx.png", + "type": "module", + "tags": "outdoors", + "supports": ["BANGLEJS2"], + "provides_modules": ["avwx"], + "readme": "README.md", + "interface": "interface.html", + "storage": [ + { "name":"avwx", "url":"avwx.js" } + ], + "data": [{ "name":"avwx.json" }] +}