BangleApps/apps/btadv/app.ts

718 lines
17 KiB
TypeScript
Raw Normal View History

{
// @ts-ignore helper
const __assign = Object.assign;
2023-03-09 20:35:00 +00:00
const Layout = require("Layout");
2023-01-26 23:00:24 +00:00
Bangle.loadWidgets();
Bangle.drawWidgets();
2023-01-20 22:28:14 +00:00
const enum Intervals {
// BLE_ADVERT = 60 * 1000,
BLE = 1000, // info screen
BLE_BACKGROUND = 5000, // button screen
UI_INFO = 5 * 1000, // info refresh, wake
UI_INFO_SLEEP = 15 * 1000, // info refresh, asleep
2023-01-20 22:28:14 +00:00
}
2023-01-20 21:04:11 +00:00
2023-01-22 21:59:27 +00:00
type Hrm = { bpm: number, confidence: number };
const HRM_MIN_CONFIDENCE = 75;
2023-01-22 21:59:27 +00:00
// https://github.com/sputnikdev/bluetooth-gatt-parser/blob/master/src/main/resources/gatt/
const enum BleServ {
// org.bluetooth.service.heart_rate
// contains: HRM
HRM = "0x180d",
// org.bluetooth.service.environmental_sensing
// contains: Elevation, Temp(Celsius), Pressure, Mag
EnvSensing = "0x181a",
// org.bluetooth.service.location_and_navigation
// contains: LocationAndSpeed
LocationAndNavigation = "0x1819",
// Acc // none known for this
}
const services = [BleServ.HRM, BleServ.EnvSensing, BleServ.LocationAndNavigation];
const enum BleChar {
// org.bluetooth.characteristic.heart_rate_measurement
// <see encode function>
HRM = "0x2a37",
// org.bluetooth.characteristic.elevation
// s24, meters 0.01
Elevation = "0x2a6c",
// org.bluetooth.characteristic.temperature
// s16 *10^2
Temp = "0x2a6e",
// org.bluetooth.characteristic.temperature_celsius
// s16 *10^2
TempCelsius = "0x2A1F",
// org.bluetooth.characteristic.pressure
// u32 *10
Pressure = "0x2a6d",
// org.bluetooth.characteristic.location_and_speed
// <see encodeGps>
LocationAndSpeed = "0x2a67",
// org.bluetooth.characteristic.magnetic_flux_density_3d
// s16: x, y, z, tesla (10^-7)
MagneticFlux3D = "0x2aa1",
}
type BleCharAdvert = {
value?: Array<number>,
2023-01-22 21:59:27 +00:00
readable?: true,
notify?: true,
indicate?: true, // notify + ACK
maxLen?: number,
2023-01-22 21:59:27 +00:00
};
type BleServAdvert = {
[key in BleChar]?: BleCharAdvert;
};
type LenFunc<T> = {
(_: T): Array<number>,
maxLen: number,
}
2023-01-20 21:04:11 +00:00
let acc: undefined | AccelData;
let bar: undefined | PressureData;
let gps: undefined | GPSFix;
2023-01-22 21:59:27 +00:00
let hrm: undefined | Hrm;
let hrmAny: undefined | Hrm;
2023-01-20 21:04:11 +00:00
let mag: undefined | CompassData;
2023-01-27 22:08:28 +00:00
let btnsShown = false;
let prevBtnsShown: boolean | undefined = undefined;
let hrmAnyClear: undefined | number;
2023-01-20 21:04:11 +00:00
2023-01-27 22:08:28 +00:00
type BtAdvType<IncludeAcc = false> = "bar" | "gps" | "hrm" | "mag" | (IncludeAcc extends true ? "acc" : never);
type BtAdvMap<T, IncludeAcc = false> = { [key in BtAdvType<IncludeAcc>]: T };
2023-01-20 21:04:11 +00:00
2023-01-27 22:08:28 +00:00
const settings: BtAdvMap<boolean> = {
bar: false,
gps: false,
hrm: false,
mag: false,
};
2023-01-20 21:04:11 +00:00
2023-01-27 22:08:28 +00:00
const idToName: BtAdvMap<string, true> = {
acc: "Acceleration",
bar: "Barometer",
gps: "GPS",
hrm: "HRM",
mag: "Magnetometer",
};
2023-01-26 23:03:21 +00:00
2023-02-11 08:44:24 +00:00
// 15 characters per line
const infoFont: FontNameWithScaleFactor = "6x8:2";
2023-01-27 22:08:28 +00:00
const colour = {
on: "#0f0",
off: "#fff",
} as const;
2023-01-26 23:03:21 +00:00
2023-01-27 22:08:28 +00:00
const makeToggle = (id: BtAdvType) => () => {
settings[id] = !settings[id];
2023-01-20 21:04:11 +00:00
2023-01-27 22:08:28 +00:00
const entry = btnLayout[id]!;
const col = settings[id] ? colour.on : colour.off;
2023-01-20 21:04:11 +00:00
2023-01-27 22:08:28 +00:00
entry.btnBorder = entry.col = col;
2023-01-20 21:04:11 +00:00
2023-01-27 22:08:28 +00:00
btnLayout.update();
btnLayout.render();
2023-01-20 21:04:11 +00:00
2023-01-27 22:08:28 +00:00
//require('Storage').writeJSON(SETTINGS_FILENAME, settings);
enableSensors();
2023-01-20 22:28:14 +00:00
};
2023-01-27 22:08:28 +00:00
const btnStyle: {
font: FontNameWithScaleFactor,
fillx?: 1,
filly?: 1,
col: ColorResolvable,
bgCol: ColorResolvable,
btnBorder: ColorResolvable,
} = {
font: "Vector:14",
fillx: 1,
filly: 1,
col: g.theme.fg,
bgCol: g.theme.bg,
btnBorder: "#fff",
2023-01-20 21:04:11 +00:00
};
2023-01-27 22:08:28 +00:00
const btnLayout = new Layout(
{
type: "v",
c: [
{
type: "h",
c: [
{
type: "btn",
label: idToName.bar,
id: "bar",
cb: makeToggle('bar'),
...btnStyle,
},
{
type: "btn",
label: idToName.gps,
id: "gps",
cb: makeToggle('gps'),
...btnStyle,
},
]
},
{
type: "h",
c: [
// hrm, mag
{
type: "btn",
label: idToName.hrm,
id: "hrm",
cb: makeToggle('hrm'),
...btnStyle,
},
{
type: "btn",
label: idToName.mag,
id: "mag",
cb: makeToggle('mag'),
...btnStyle,
},
]
},
{
type: "h",
c: [
{
type: "btn",
label: idToName.acc,
id: "acc",
cb: () => {},
...btnStyle,
col: colour.on,
btnBorder: colour.on,
},
{
type: "btn",
label: "Back",
cb: () => {
setBtnsShown(false);
},
...btnStyle,
},
]
}
]
2023-01-20 21:04:11 +00:00
},
2023-01-27 22:08:28 +00:00
{
lazy: true,
back: () => {
setBtnsShown(false);
},
2023-01-20 21:04:11 +00:00
},
2023-01-27 22:08:28 +00:00
);
const setBtnsShown = (b: boolean) => {
btnsShown = b;
hook(!btnsShown);
setIntervals();
2023-01-27 22:08:28 +00:00
redraw();
2023-01-20 21:04:11 +00:00
};
const drawInfo = (force?: true) => {
let { y, x, w } = Bangle.appRect;
const mid = x + w / 2
let drawn = false;
2023-01-27 22:08:28 +00:00
if (!force && !bar && !gps && !hrm && !mag)
return;
g.reset()
.clearRect(Bangle.appRect)
.setFont(infoFont)
.setFontAlign(0, -1);
2023-01-27 22:08:28 +00:00
if (bar) {
g.drawString(`${bar.altitude.toFixed(1)}m`, mid, y);
y += g.getFontHeight();
2023-01-20 21:04:11 +00:00
2023-02-19 21:04:06 +00:00
g.drawString(`${bar.pressure.toFixed(1)} hPa`, mid, y);
y += g.getFontHeight();
2023-01-20 21:04:11 +00:00
g.drawString(`${bar.temperature.toFixed(1)}C`, mid, y);
y += g.getFontHeight();
drawn = true;
2023-01-27 22:08:28 +00:00
}
2023-01-26 23:03:21 +00:00
2023-01-27 22:08:28 +00:00
if (gps) {
g.drawString(
`${gps.lat.toFixed(4)} lat, ${gps.lon.toFixed(4)} lon`,
mid,
y,
);
y += g.getFontHeight();
g.drawString(
`${gps.alt}m (${gps.satellites} sat)`,
mid,
y,
);
y += g.getFontHeight();
drawn = true;
2023-01-27 22:08:28 +00:00
}
if (hrm) {
g.drawString(`${hrm.bpm} BPM (${hrm.confidence}%)`, mid, y);
y += g.getFontHeight();
drawn = true;
} else if (hrmAny) {
g.drawString(`~${hrmAny.bpm} BPM (${hrmAny.confidence}%)`, mid, y);
y += g.getFontHeight();
drawn = true;
if (!settings.hrm && !hrmAnyClear) {
// hrm is erased, but hrmAny will remain until cleared (or reset)
// if it runs via health check, we reset it here
hrmAnyClear = setTimeout(() => {
hrmAny = undefined;
hrmAnyClear = undefined;
}, 10000);
}
2023-01-27 22:08:28 +00:00
}
2023-01-26 23:03:21 +00:00
2023-01-27 22:08:28 +00:00
if (mag) {
g.drawString(
`${mag.x} ${mag.y} ${mag.z}`,
mid,
y
);
y += g.getFontHeight();
g.drawString(
`heading: ${mag.heading.toFixed(1)}`,
mid,
y
);
y += g.getFontHeight();
drawn = true;
}
if (!drawn) {
if (!force || Object.values(settings).every((x: boolean) => !x)) {
g.drawString(`swipe to enable`, mid, y);
} else {
2023-02-11 08:44:24 +00:00
g.drawString(`events pending`, mid, y);
}
y += g.getFontHeight();
2023-01-27 22:08:28 +00:00
}
2023-01-26 23:03:21 +00:00
};
const onTap = (/* _: { ... } */) => {
setBtnsShown(true);
};
2023-01-27 22:08:28 +00:00
const redraw = () => {
2023-01-27 22:08:28 +00:00
if (btnsShown) {
if (!prevBtnsShown) {
prevBtnsShown = btnsShown;
2023-01-27 22:08:28 +00:00
Bangle.removeListener("swipe", onTap);
2023-01-27 22:08:28 +00:00
btnLayout.setUI();
btnLayout.forgetLazyState();
g.clearRect(Bangle.appRect); // in case btnLayout isn't full screen
}
btnLayout.render();
} else {
if (prevBtnsShown) {
prevBtnsShown = btnsShown;
Bangle.setUI(); // remove all existing input handlers
Bangle.on("swipe", onTap);
drawInfo(true);
} else {
drawInfo();
}
2023-01-20 21:04:11 +00:00
}
};
const encodeHrm: LenFunc<Hrm> = (hrm: Hrm) =>
2023-01-22 21:59:27 +00:00
// {
// flags: u8,
// bytes: [u8...]
2023-01-20 21:04:11 +00:00
// }
2023-01-22 21:59:27 +00:00
// flags {
// 1 << 0: 16bit bpm
// 1 << 1: sensor contact available
// 1 << 2: sensor contact boolean
// 1 << 3: energy expended, next 16 bits
// 1 << 4: "rr" data available, u16s, intervals
2023-01-20 21:04:11 +00:00
// }
[0, hrm.bpm];
encodeHrm.maxLen = 2;
2023-01-22 21:59:27 +00:00
const encodePressure: LenFunc<PressureData> = (data: PressureData) =>
2023-02-19 21:05:17 +00:00
toByteArray(Math.round(data.pressure * 10), 4, false);
encodePressure.maxLen = 4;
2023-01-22 21:59:27 +00:00
const encodeElevation: LenFunc<PressureData> = (data: PressureData) =>
2023-01-22 21:59:27 +00:00
toByteArray(Math.round(data.altitude * 100), 3, true);
encodeElevation.maxLen = 3;
2023-01-22 21:59:27 +00:00
const encodeTemp: LenFunc<PressureData> = (data: PressureData) =>
2023-02-19 21:05:17 +00:00
toByteArray(Math.round(data.temperature * 10), 2, true);
encodeTemp.maxLen = 2;
2023-01-22 21:59:27 +00:00
const encodeGps: LenFunc<GPSFix> = (data: GPSFix) => {
2023-01-22 21:59:27 +00:00
// flags: 16 bits
// bit 0: Instantaneous Speed Present
// bit 1: Total Distance Present
// bit 2: Location Present
// bit 3: Elevation Present
// bit 4: Heading Present
// bit 5: Rolling Time Present
// bit 6: UTC Time Present
//
// bit 7-8: position status
// 0 (0b00): no position
// 1 (0b01): position ok
// 2 (0b10): estimated position
// 3 (0b11): last known position
//
// bit 9: speed & distance format
// 0: 2d
// 1: 3d
//
// bit 10-11: elevation source
// 0: Positioning System
// 1: Barometric Air Pressure
// 2: Database Service (or similiar)
// 3: Other
//
// bit 12: Heading Source
// 0: Heading based on movement
// 1: Heading based on magnetic compass
//
// speed: u16 (m/s), 1/100
// distance: u24, 1/10
// lat: s32, 1/10^7
// lon: s32, 1/10^7
// elevation: s24, 1/100
// heading: u16 (deg), 1/100
// rolling time: u8 (s)
// utc time: org.bluetooth.characteristic.date_time
const speed = toByteArray(Math.round(1000 * data.speed / 36), 2, false);
2023-01-20 21:04:11 +00:00
const lat = toByteArray(Math.round(data.lat * 10000000), 4, true);
const lon = toByteArray(Math.round(data.lon * 10000000), 4, true);
2023-01-22 21:59:27 +00:00
const elevation = toByteArray(Math.round(data.alt * 100), 3, true);
const heading = toByteArray(Math.round(data.course * 100), 2, false);
2023-01-20 21:04:11 +00:00
return [
2023-02-19 21:06:25 +00:00
0b10011101, // speed, location, elevation, heading [...]
0b00000010, // position ok, 3d speed/distance
2023-01-22 21:59:27 +00:00
speed[0]!, speed[1]!,
lat[0]!, lat[1]!, lat[2]!, lat[3]!,
lon[0]!, lon[1]!, lon[2]!, lon[3]!,
elevation[0]!, elevation[1]!, elevation[2]!,
heading[0]!, heading[1]!
2023-01-20 21:04:11 +00:00
];
};
encodeGps.maxLen = 17;
2023-01-20 21:04:11 +00:00
const encodeGpsHeadingOnly: LenFunc<CompassData> = (data: CompassData) => {
// see encodeGps()
const heading = toByteArray(Math.round(data.heading * 100), 2, false);
return [
0b00010000, // heading present
0b00010000, // heading source: mag
heading[0]!, heading[1]!
];
};
encodeGpsHeadingOnly.maxLen = 17;
const encodeMag: LenFunc<CompassData> = (data: CompassData) => {
2023-01-20 21:04:11 +00:00
const x = toByteArray(data.x, 2, true);
const y = toByteArray(data.y, 2, true);
const z = toByteArray(data.z, 2, true);
2023-01-22 21:59:27 +00:00
return [ x[0]!, x[1]!, y[0]!, y[1]!, z[0]!, z[1]! ];
2023-01-20 21:04:11 +00:00
};
encodeMag.maxLen = 6;
2023-01-20 21:04:11 +00:00
const toByteArray = (value: number, numberOfBytes: number, isSigned: boolean) => {
const byteArray: Array<number> = new Array(numberOfBytes);
if(isSigned && (value < 0)) {
value += 1 << (numberOfBytes * 8);
}
for(let index = 0; index < numberOfBytes; index++) {
byteArray[index] = (value >> (index * 8)) & 0xff;
}
return byteArray;
};
const enableSensors = () => {
2023-01-27 22:08:28 +00:00
Bangle.setBarometerPower(settings.bar, "btadv");
if (!settings.bar)
2023-01-21 00:14:02 +00:00
bar = undefined;
2023-01-27 22:08:28 +00:00
Bangle.setGPSPower(settings.gps, "btadv");
if (!settings.gps)
2023-01-21 00:14:02 +00:00
gps = undefined;
2023-01-27 22:08:28 +00:00
Bangle.setHRMPower(settings.hrm, "btadv");
if (!settings.hrm)
2023-02-11 08:44:39 +00:00
hrm = hrmAny = undefined;
2023-01-21 00:14:02 +00:00
2023-01-27 22:08:28 +00:00
Bangle.setCompassPower(settings.mag, "btadv");
if (!settings.mag)
2023-01-21 00:14:02 +00:00
mag = undefined;
2023-01-20 21:04:11 +00:00
};
// ----------------------------
const haveServiceData = (serv: BleServ): boolean => {
2023-01-22 21:59:27 +00:00
switch (serv) {
case BleServ.HRM: return !!hrm;
case BleServ.EnvSensing: return !!(bar || mag);
case BleServ.LocationAndNavigation: return !!(gps && gps.lat && gps.lon || mag);
2023-01-22 21:59:27 +00:00
}
};
const serviceToAdvert = (serv: BleServ, initial = false): BleServAdvert => {
2023-01-22 21:59:27 +00:00
switch (serv) {
case BleServ.HRM:
if (hrm || initial) {
const o: BleCharAdvert = {
maxLen: encodeHrm.maxLen,
readable: true,
notify: true,
2023-01-22 21:59:27 +00:00
};
if (hrm) {
o.value = encodeHrm(hrm);
hrm = undefined;
}
return { [BleChar.HRM]: o };
2023-01-20 21:04:11 +00:00
}
2023-01-22 21:59:27 +00:00
return {};
case BleServ.LocationAndNavigation:
if (gps || initial) {
const o: BleCharAdvert = {
maxLen: encodeGps.maxLen,
readable: true,
notify: true,
2023-01-22 21:59:27 +00:00
};
if (gps) {
o.value = encodeGps(gps);
gps = undefined;
}
return { [BleChar.LocationAndSpeed]: o };
} else if (mag) {
const o: BleCharAdvert = {
maxLen: encodeGpsHeadingOnly.maxLen,
readable: true,
notify: true,
value: encodeGpsHeadingOnly(mag),
};
return { [BleChar.LocationAndSpeed]: o };
2023-01-22 21:59:27 +00:00
}
return {};
case BleServ.EnvSensing: {
const o: BleServAdvert = {};
if (bar || initial) {
2023-01-22 21:59:27 +00:00
o[BleChar.Elevation] = {
maxLen: encodeElevation.maxLen,
2023-01-22 21:59:27 +00:00
readable: true,
notify: true,
};
o[BleChar.TempCelsius] = {
maxLen: encodeTemp.maxLen,
2023-01-22 21:59:27 +00:00
readable: true,
notify: true,
};
o[BleChar.Pressure] = {
maxLen: encodePressure.maxLen,
2023-01-22 21:59:27 +00:00
readable: true,
notify: true,
};
if (bar) {
o[BleChar.Elevation]!.value = encodeElevation(bar);
o[BleChar.TempCelsius]!.value = encodeTemp(bar);
o[BleChar.Pressure]!.value = encodePressure(bar);
bar = undefined;
}
2023-01-22 21:59:27 +00:00
}
if (mag || initial) {
o[BleChar.MagneticFlux3D] = {
maxLen: encodeMag.maxLen,
readable: true,
notify: true,
};
if (mag) {
o[BleChar.MagneticFlux3D]!.value = encodeMag(mag);
}
}
2023-01-22 21:59:27 +00:00
return o;
2023-01-20 21:04:11 +00:00
}
2023-01-22 21:59:27 +00:00
}
2023-01-20 21:04:11 +00:00
};
const getBleAdvert = <T>(map: (s: BleServ) => T, all = false) => {
2023-01-22 21:59:27 +00:00
const advert: { [key in BleServ]?: T } = {};
for (const serv of services) {
if (all || haveServiceData(serv)) {
2023-01-22 21:59:27 +00:00
advert[serv] = map(serv);
}
}
// clear mag only after both EnvSensing and LocationAndNavigation have run
mag = undefined;
2023-01-22 21:59:27 +00:00
return advert;
};
// done via advertise in setServices()
//const updateBleAdvert = () => {
// let bleAdvert: ReturnType<typeof getBleAdvert<undefined>>;
//
// if (!(bleAdvert = (Bangle as any).bleAdvert)) {
// bleAdvert = getBleAdvert(_ => undefined);
//
// (Bangle as any).bleAdvert = bleAdvert;
// }
//
// try {
// NRF.setAdvertising(bleAdvert);
// } catch (e) {
// console.log("couldn't setAdvertising():", e);
// }
//};
2023-01-22 21:59:27 +00:00
const updateServices = () => {
const newAdvert = getBleAdvert(serviceToAdvert);
NRF.updateServices(newAdvert);
2023-01-22 21:59:27 +00:00
};
const onAccel = (newAcc: NonNull<typeof acc>) => acc = newAcc;
const onPressure = (newBar: NonNull<typeof bar>) => bar = newBar;
const onGPS = (newGps: NonNull<typeof gps>) => gps = newGps;
const onHRM = (newHrm: NonNull<typeof hrm>) => {
if (newHrm.confidence >= HRM_MIN_CONFIDENCE)
hrm = newHrm;
hrmAny = newHrm;
};
const onMag = (newMag: NonNull<typeof mag>) => mag = newMag;
const hook = (enable: boolean) => {
// need to disable for perf reasons, when buttons are shown
if (enable) {
Bangle.on("accel", onAccel);
Bangle.on("pressure", onPressure);
Bangle.on("GPS", onGPS);
Bangle.on("HRM", onHRM);
Bangle.on("mag", onMag);
} else {
Bangle.removeListener("accel", onAccel);
Bangle.removeListener("pressure", onPressure);
Bangle.removeListener("GPS", onGPS);
Bangle.removeListener("HRM", onHRM);
Bangle.removeListener("mag", onMag);
}
}
2023-01-20 21:04:11 +00:00
// --- intervals ---
2023-01-27 22:08:28 +00:00
const setIntervals = (
locked: boolean = Bangle.isLocked(),
connected: boolean = NRF.getSecurityStatus().connected,
) => {
2023-01-20 22:28:14 +00:00
changeInterval(
2023-01-27 22:08:28 +00:00
redrawInterval,
locked ? Intervals.UI_INFO_SLEEP : Intervals.UI_INFO,
2023-01-20 22:28:14 +00:00
);
if (connected) {
const interval = btnsShown ? Intervals.BLE_BACKGROUND : Intervals.BLE;
if (bleInterval) {
changeInterval(bleInterval, interval);
} else {
bleInterval = setInterval(updateServices, interval);
}
} else if (bleInterval) {
clearInterval(bleInterval);
bleInterval = undefined;
}
};
const redrawInterval = setInterval(redraw, /*replaced*/1000);
Bangle.on("lock", locked => setIntervals(locked));
let bleInterval: undefined | IntervalId;
NRF.on("connect", () => setIntervals(undefined, true));
NRF.on("disconnect", () => setIntervals(undefined, false));
setIntervals();
2023-01-20 22:28:14 +00:00
2023-01-22 21:59:27 +00:00
// turn things on
setBtnsShown(true);
enableSensors();
// set services/advert once at startup:
{
// must have fixed services from the start:
const ad = getBleAdvert(serv => serviceToAdvert(serv, true), /*all*/true);
const adServices = Object
.keys(ad)
.map((k: string) => k.replace("0x", ""));
NRF.setServices(
ad,
{
advertise: adServices,
uart: false,
},
);
}
}