diff --git a/js/device/cadence.js b/js/device/cadence.js new file mode 100644 index 0000000..7815b5a --- /dev/null +++ b/js/device/cadence.js @@ -0,0 +1,15 @@ +const CadenceQuality = Object.freeze({ + HIGH: "high", + MEDIUM: "medium", + LOW: "low", + UNKNOWN: "unknown", +}); + +function validateCadenceQuality(v) { + const set = CadenceQuality; + if (!v) return set.UNKNOWN; + const vals = Object.values(set); + return vals.includes(v) ? v : set.UNKNOWN; +} + +export { validateCadenceQuality, CadenceQuality }; diff --git a/js/device/capabilities/control.js b/js/device/capabilities/control.js new file mode 100644 index 0000000..20b9425 --- /dev/null +++ b/js/device/capabilities/control.js @@ -0,0 +1,34 @@ +import { deepFreeze, isFiniteNumber } from "../../utils"; +import { CyclingPowerCapabilities } from "./cyclingPower.js"; + +export class Control { + constructor({ + supportsERG = false, + controlCharacteristic, + maxPowerWatts, + minPowerWatts, + ergRampRateWattsPerSec, + cyclingPower, + } = {}) { + this.supportsERG = Boolean(supportsERG); + this.controlCharacteristic = controlCharacteristic; + this.maxPowerWatts = isFiniteNumber(maxPowerWatts) + ? Number(maxPowerWatts) + : undefined; + this.minPowerWatts = isFiniteNumber(minPowerWatts) + ? Number(minPowerWatts) + : undefined; + this.ergRampRateWattsPerSec = isFiniteNumber(ergRampRateWattsPerSec) + ? Number(ergRampRateWattsPerSec) + : undefined; + this.cyclingPower = + cyclingPower instanceof CyclingPowerCapabilities + ? cyclingPower + : cyclingPower + ? new CyclingPowerCapabilities(cyclingPower) + : undefined; + deepFreeze(this); + } + + merge = (partial) => new Control(Object.assign({}, this, partial)); +} diff --git a/js/device/capabilities/core.js b/js/device/capabilities/core.js new file mode 100644 index 0000000..0a3a010 --- /dev/null +++ b/js/device/capabilities/core.js @@ -0,0 +1,35 @@ +import { deepFreeze } from "../../utils"; + +export class Core { + constructor({ + supportsBattery = false, + supportsManufacturerData = false, + gattServices, + gattCharacteristics, + manufacturer, + model, + firmwareVersion, + serial, + reconnectOnDisconnect = true, + requiresPairing = false, + } = {}) { + this.supportsBattery = Boolean(supportsBattery); + this.supportsManufacturerData = Boolean(supportsManufacturerData); + this.gattServices = Array.isArray(gattServices) + ? gattServices.slice() + : undefined; + this.gattCharacteristics = + gattCharacteristics && typeof gattCharacteristics === "object" + ? Object.assign({}, gattCharacteristics) + : undefined; + this.manufacturer = manufacturer; + this.model = model; + this.firmwareVersion = firmwareVersion; + this.serial = serial; + this.reconnectOnDisconnect = Boolean(reconnectOnDisconnect); + this.requiresPairing = Boolean(requiresPairing); + deepFreeze(this); + } + + merge = (partial) => new Core(Object.assign({}, this, partial)); +} diff --git a/js/device/capabilities/cyclingPower.js b/js/device/capabilities/cyclingPower.js new file mode 100644 index 0000000..26443d0 --- /dev/null +++ b/js/device/capabilities/cyclingPower.js @@ -0,0 +1,17 @@ +import { deepFreeze } from "../../utils"; + +export class CyclingPowerCapabilities { + constructor({ controlOpcodes = [], supportsCrankTorque = false } = {}) { + /* + * Если в конструктор передали ссылку на внешний массив, + * slice() возвращает новый массив. О бъект получает собственную копию, + * и дальнейшие изменения исходного массива снаружи не повлияют на + * this.controlOpcodes. + */ + this.controlOpcodes = Array.isArray(controlOpcodes) + ? controlOpcodes.slice() + : []; + this.supportsCrankTorque = Boolean(supportsCrankTorque); + deepFreeze(this); + } +} diff --git a/js/device/capabilities/index.js b/js/device/capabilities/index.js new file mode 100644 index 0000000..7433834 --- /dev/null +++ b/js/device/capabilities/index.js @@ -0,0 +1,5 @@ +import { Control } from "./control"; +import { Sensors } from "./sensors"; +import { Core } from "./core"; + +export { Control, Sensors, Core }; diff --git a/js/device/capabilities/sensors.js b/js/device/capabilities/sensors.js new file mode 100644 index 0000000..b1de6d2 --- /dev/null +++ b/js/device/capabilities/sensors.js @@ -0,0 +1,31 @@ +import { deepFreeze, isFiniteNumber } from "../../utils"; +import { validateCadenceQuality } from "../cadence"; + +export class Sensors { + constructor({ + supportsHeartRate = false, + supportsCadence = false, + supportsPower = false, + hrSamplingHz, + cadenceSamplingHz, + powerSamplingHz, + cadenceSourceQuality, + } = {}) { + this.supportsHeartRate = Boolean(supportsHeartRate); + this.supportsCadence = Boolean(supportsCadence); + this.supportsPower = Boolean(supportsPower); + this.hrSamplingHz = isFiniteNumber(hrSamplingHz) + ? Number(hrSamplingHz) + : undefined; + this.cadenceSamplingHz = isFiniteNumber(cadenceSamplingHz) + ? Number(cadenceSamplingHz) + : undefined; + this.powerSamplingHz = isFiniteNumber(powerSamplingHz) + ? Number(powerSamplingHz) + : undefined; + this.cadenceSourceQuality = validateCadenceQuality(cadenceSourceQuality); + deepFreeze(this); + } + + merge = (partial) => new Sensors(Object.assign({}, this, partial)); +} diff --git a/js/device/index.js b/js/device/index.js new file mode 100644 index 0000000..9a3b639 --- /dev/null +++ b/js/device/index.js @@ -0,0 +1,66 @@ +import { Control, Sensors, Core } from "./capabilities"; +import { deepFreeze } from "../utils"; + +const DeviceType = Object.freeze({ + HEART_RATE: "heart_rate", + CADENCE: "cadence", + TRAINER: "trainer", + UNKNOWN: "unknown", +}); + +export class Device { + constructor({ + id, + name, + type, + core, + sensors, + control, + connected = false, + lastSeen, + } = {}) { + if (!id) throw new Error("Device requires id"); + this.id = id; + this.name = name || id; + this.type = type; + this.core = core instanceof Core ? core : core ? new Core(core) : undefined; + this.sensors = + sensors instanceof Sensors + ? sensors + : sensors + ? new Sensors(sensors) + : undefined; + this.control = + control instanceof Control + ? control + : control + ? new Control(control) + : undefined; + this.connected = Boolean(connected); + this.lastSeen = lastSeen; + deepFreeze(this); + } + + withCore = (partial) => + new Device( + Object.assign({}, this, { + core: this.core ? this.core.merge(partial) : new Core(partial), + }), + ); + withSensors = (partial) => + new Device( + Object.assign({}, this, { + sensors: this.sensors + ? this.sensors.merge(partial) + : new Sensors(partial), + }), + ); + withControl = (partial) => + new Device( + Object.assign({}, this, { + control: this.control + ? this.control.merge(partial) + : new Control(partial), + }), + ); +} diff --git a/js/main.js b/js/main.js index 2a16c51..f965458 100644 --- a/js/main.js +++ b/js/main.js @@ -1,5 +1,4 @@ const startButton = document.getElementById("start"); -const FTM_SERVICE_UUID = "00001825-0000-1000-8000-00805f9b34fb"; startButton.addEventListener("click", async () => { try { diff --git a/js/utils/index.js b/js/utils/index.js new file mode 100644 index 0000000..6f1a011 --- /dev/null +++ b/js/utils/index.js @@ -0,0 +1,14 @@ +const deepFreeze = (obj) => { + if (obj && typeof obj === "object" && !Object.isFrozen(obj)) { + Object.getOwnPropertyNames(obj).forEach((prop) => { + const value = obj[prop]; + if (value && typeof value === "object") deepFreeze(value); + }); + Object.freeze(obj); + } + return obj; +}; + +const isFiniteNumber = (v) => typeof v === "number" && Number.isFinite(v); + +export { deepFreeze, isFiniteNumber };