1
0
Fork 0
BangleApps/apps/btadv/app.ts

638 lines
14 KiB
TypeScript

const Layout = require("Layout") as Layout_.Layout;
Bangle.loadWidgets();
Bangle.drawWidgets();
const enum Intervals {
// BLE_ADVERT = 60 * 1000,
BLE = 1000,
MENU_WAKE = 2 * 1000,
MENU_SLEEP = 15 * 1000,
}
type Hrm = { bpm: number, confidence: number };
// 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>,
readable?: true,
notify?: true,
indicate?: true, // notify + ACK
maxLen?: number,
};
type BleServAdvert = {
[key in BleChar]?: BleCharAdvert;
};
type LenFunc<T> = {
(_: T): Array<number>,
maxLen: number,
}
let acc: undefined | AccelData;
let bar: undefined | PressureData;
let gps: undefined | GPSFix;
let hrm: undefined | Hrm;
let mag: undefined | CompassData;
let btnsShown = false;
let prevBtnsShown: boolean | undefined = undefined;
type BtAdvType<IncludeAcc = false> = "bar" | "gps" | "hrm" | "mag" | (IncludeAcc extends true ? "acc" : never);
type BtAdvMap<T, IncludeAcc = false> = { [key in BtAdvType<IncludeAcc>]: T };
const settings: BtAdvMap<boolean> = {
bar: false,
gps: false,
hrm: false,
mag: false,
};
const idToName: BtAdvMap<string, true> = {
acc: "Acceleration",
bar: "Barometer",
gps: "GPS",
hrm: "HRM",
mag: "Magnetometer",
};
const infoFont: FontNameWithScaleFactor = "6x8:2";
const colour = {
on: "#0f0",
off: "#fff",
} as const;
const makeToggle = (id: BtAdvType) => () => {
settings[id] = !settings[id];
const entry = btnLayout[id]!;
const col = settings[id] ? colour.on : colour.off;
entry.btnBorder = entry.col = col;
btnLayout.update();
btnLayout.render();
//require('Storage').writeJSON(SETTINGS_FILENAME, settings);
enableSensors();
};
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",
};
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,
},
]
}
]
},
{
lazy: true,
back: () => {
setBtnsShown(false);
},
},
);
const setBtnsShown = (b: boolean) => {
btnsShown = b;
redraw();
};
const drawInfo = () => {
let { y, x, w } = Bangle.appRect;
const mid = x + w / 2
let drawn = false;
g.reset()
.clearRect(Bangle.appRect)
.setFont(infoFont)
.setFontAlign(0, -1);
if (bar) {
g.drawString(`${bar.altitude.toFixed(1)}m`, mid, y);
y += g.getFontHeight();
g.drawString(`${bar.pressure.toFixed(1)}mbar`, mid, y);
y += g.getFontHeight();
g.drawString(`${bar.temperature.toFixed(1)}C`, mid, y);
y += g.getFontHeight();
drawn = true;
}
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;
}
if (hrm) {
g.drawString(`${hrm.bpm} BPM (${hrm.confidence}%)`, mid, y);
y += g.getFontHeight();
drawn = true;
}
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) {
g.drawString(`swipe to enable`, mid, y);
y += g.getFontHeight();
}
};
const onTap = (_: { /* ... */ }) => {
setBtnsShown(true);
};
const redraw = () => {
if (btnsShown) {
if (!prevBtnsShown) {
prevBtnsShown = btnsShown;
Bangle.removeListener("swipe", onTap);
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();
}
};
const encodeHrm: LenFunc<Hrm> = (hrm: Hrm) =>
// {
// flags: u8,
// bytes: [u8...]
// }
// 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
// }
[0, hrm.bpm];
encodeHrm.maxLen = 2;
const encodePressure: LenFunc<PressureData> = (data: PressureData) =>
toByteArray(Math.round(data.pressure * 1000), 4, false);
encodePressure.maxLen = 4;
const encodeElevation: LenFunc<PressureData> = (data: PressureData) =>
toByteArray(Math.round(data.altitude * 100), 3, true);
encodeElevation.maxLen = 3;
const encodeTemp: LenFunc<PressureData> = (data: PressureData) =>
toByteArray(Math.round(data.temperature * 100), 2, true);
encodeTemp.maxLen = 2;
const encodeGps: LenFunc<GPSFix> = (data: GPSFix) => {
// 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);
const lat = toByteArray(Math.round(data.lat * 10000000), 4, true);
const lon = toByteArray(Math.round(data.lon * 10000000), 4, true);
const elevation = toByteArray(Math.round(data.alt * 100), 3, true);
const heading = toByteArray(Math.round(data.course * 100), 2, false);
return [
0x9d, // 0b10011101: speed, location, elevation, heading [...]
0x2, // 0b00000010: position ok, 3d speed/distance(?)
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]!
];
};
encodeGps.maxLen = 17;
const encodeMag: LenFunc<CompassData> = (data: CompassData) => {
const x = toByteArray(data.x, 2, true);
const y = toByteArray(data.y, 2, true);
const z = toByteArray(data.z, 2, true);
return [ x[0]!, x[1]!, y[0]!, y[1]!, z[0]!, z[1]! ];
};
encodeMag.maxLen = 6;
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 = () => {
Bangle.setBarometerPower(settings.bar, "btadv");
if (!settings.bar)
bar = undefined;
Bangle.setGPSPower(settings.gps, "btadv");
if (!settings.gps)
gps = undefined;
Bangle.setHRMPower(settings.hrm, "btadv");
if (!settings.hrm)
hrm = undefined;
Bangle.setCompassPower(settings.mag, "btadv");
if (!settings.mag)
mag = undefined;
console.log("enableSensors():", settings);
};
// ----------------------------
const haveServiceData = (serv: BleServ): boolean => {
switch (serv) {
case BleServ.HRM: return !!hrm;
case BleServ.EnvSensing: return !!(bar || mag);
case BleServ.LocationAndNavigation: return !!(gps && gps.lat && gps.lon);
}
};
const serviceToAdvert = (serv: BleServ, initial = false): BleServAdvert => {
switch (serv) {
case BleServ.HRM:
if (hrm || initial) {
const o: BleCharAdvert = {
maxLen: encodeHrm.maxLen,
readable: true,
notify: true,
};
if (hrm) {
o.value = encodeHrm(hrm);
hrm = undefined;
}
return { [BleChar.HRM]: o };
}
return {};
case BleServ.LocationAndNavigation:
if (gps || initial) {
const o: BleCharAdvert = {
maxLen: encodeGps.maxLen,
readable: true,
notify: true,
};
if (gps) {
o.value = encodeGps(gps);
gps = undefined;
}
return { [BleChar.LocationAndSpeed]: o };
}
return {};
case BleServ.EnvSensing: {
const o: BleServAdvert = {};
if (bar || initial) {
o[BleChar.Elevation] = {
maxLen: encodeElevation.maxLen,
readable: true,
notify: true,
};
o[BleChar.TempCelsius] = {
maxLen: encodeTemp.maxLen,
readable: true,
notify: true,
};
o[BleChar.Pressure] = {
maxLen: encodePressure.maxLen,
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;
}
}
if (mag || initial) {
o[BleChar.MagneticFlux3D] = {
maxLen: encodeMag.maxLen,
readable: true,
notify: true,
};
if (mag) {
o[BleChar.MagneticFlux3D]!.value = encodeMag(mag);
mag = undefined;
}
}
return o;
}
}
};
const getBleAdvert = <T>(map: (s: BleServ) => T, all = false) => {
const advert: { [key in BleServ]?: T } = {};
for (const serv of services) {
if (all || haveServiceData(serv)) {
advert[serv] = map(serv);
}
}
return advert;
};
// TODO: call this at the start, set the advert
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("setAdvertising(): " + e);
}
};
// call this when settings changes, or when we have new data to send/serve
const updateServices = () => {
const newAdvert = getBleAdvert(serviceToAdvert);
NRF.updateServices(newAdvert);
};
Bangle.on('accel', newAcc => acc = newAcc);
Bangle.on('pressure', newBar => bar = newBar);
Bangle.on('GPS', newGps => gps = newGps);
Bangle.on('HRM', newHrm => hrm = newHrm);
Bangle.on('mag', newMag => mag = newMag);
setBtnsShown(true);
const redrawInterval = setInterval(redraw, Intervals.MENU_WAKE);
Bangle.on("lock", locked => {
changeInterval(
redrawInterval,
locked ? Intervals.MENU_SLEEP : Intervals.MENU_WAKE,
);
});
// turn things on
enableSensors();
// set services/advert once at startup:
{
// must have fixed services from the start:
const ad = getBleAdvert(serv => serviceToAdvert(serv, true), /*all*/true);
NRF.setServices(
ad,
{
// FIXME: go via setAdvertising(), merge wth Bangle.bleAdvert
advertise: Object
.keys(ad)
.map((k: string) => k.replace("0x", ""))
// TODO: uart: false,
},
);
}
let iv: undefined | number;
const setIntervals = (connected: boolean) => {
if (connected) {
if (iv) {
changeInterval(iv, Intervals.BLE);
} else {
iv = setInterval(
updateServices,
Intervals.BLE
);
}
} else if (iv) {
clearInterval(iv);
iv = undefined;
}
};
setIntervals(NRF.getSecurityStatus().connected);
NRF.on("connect", () => {
setIntervals(true);
});
NRF.on("disconnect", () => {
setIntervals(false);
});