xmihome-devices
Version:
Device definitions and specifications for the node-xmihome ecosystem.
626 lines (579 loc) • 21.5 kB
JavaScript
import Device from 'xmihome/device.js';
/** @import { XiaomiMiHome } from 'xmihome' */
/** @import { Config as DeviceConfig, Property, UuidMapping, Schema } from 'xmihome/device.js' */
/**
* @typedef {DeviceConfig & {
* user?: {
* height: number,
* birthday: string,
* gender: 'male'|'female'
* }
* }} Config
*/
/**
* Класс для управления умными весами Xiaomi Body Composition Scale.
* @extends Device
*/
export default class XiaomiScales extends Device {
/** @type {string} */
static name = 'Xiaomi Body Composition Scale';
/** @type {string[]} */
static alias = [
'MIBFS'
];
/** @type {string[]} */
static models = [
'xiaomi.scales.ms115'
];
/** @type {UuidMapping} */
static uuidMap = {
services: {
'0000181b-0000-1000-8000-00805f9b34fb': '0017'
},
characteristics: {
'00002a9c-0000-1000-8000-00805f9b34fb': '001c'
}
};
/** @type {Schema} */
static schema = {
key: 'user',
fields: [
{ key: 'height', type: 'number' },
{ key: 'birthday', type: 'date' },
{ key: 'gender', type: 'select', options: ['male', 'female'] }
]
};
/**
* Типы телосложения.
* @type {string[]}
*/
static BODY_TYPES = ['obese', 'overweight', 'thick-set', 'lack-exerscise', 'balanced', 'balanced-muscular', 'skinny', 'balanced-skinny', 'skinny-muscular'];
/**
* Статусы для индекса массы тела (ИМТ).
* @type {string[]}
*/
static BMI_STATUSES = ['Underweight', 'Normal', 'Overweight', 'Obese', 'Morbidly Obese'];
/**
* Статусы для процента жира в организме.
* @type {string[]}
*/
static BODY_FAT_STATUSES = ['Very Low', 'Low', 'Normal', 'High', 'Very High'];
/**
* Статусы для мышечной массы.
* @type {string[]}
*/
static MUSCLE_MASS_STATUSES = ['Insufficient', 'Normal', 'Good'];
/**
* Статусы для процента воды в организме.
* @type {string[]}
*/
static WATER_STATUSES = ['Insufficient', 'Normal', 'Good'];
/**
* Статусы для костной массы.
* @type {string[]}
*/
static BONE_MASS_STATUSES = ['Insufficient', 'Normal', 'Good'];
/**
* Статусы для процента белка.
* @type {string[]}
*/
static PROTEIN_STATUSES = ['Insufficient', 'Normal', 'Good'];
/**
* Статусы для висцерального жира.
* @type {string[]}
*/
static VISCERAL_FAT_STATUSES = ['Normal', 'High', 'Very High'];
/**
* Статусы для базального метаболизма (BMR).
* @type {string[]}
*/
static BASAL_METABOLISM_STATUSES = ['Insufficient', 'Normal'];
/**
* Шкалы для оценки мышечной массы в зависимости от роста и пола.
* @type {Array<{min: number, F: number[], M: number[]}>}
*/
static MUSCLE_MASS_SCALES = [
{ min: 170, F: [36.5, 42.5], M: [49.5, 59.4] },
{ min: 160, F: [32.9, 37.5], M: [44.0, 52.4] },
{ min: 0, F: [29.1, 34.7], M: [38.5, 46.5] }
];
/**
* Шкалы для оценки костной массы в зависимости от веса и пола.
* @type {Array<{F: {min: number, optimal: number}, M: {min: number, optimal: number}}>}
*/
static BONE_MASS_SCALES = [
{ F: { min: 60, optimal: 2.5 }, M: { min: 75, optimal: 3.2 } },
{ F: { min: 45, optimal: 2.2 }, M: { min: 69, optimal: 2.9 } },
{ F: { min: 0, optimal: 1.8 }, M: { min: 0, optimal: 2.5 } }
];
/**
* Шкалы для оценки процента жира в зависимости от возраста и пола.
* @type {Array<{min: number, max: number, F: number[], M: number[]}>}
*/
static FAT_PERCENTAGE_SCALES = [
{ min: 0, max: 20, F: [18, 23, 30, 35], M: [8, 14, 21, 25] },
{ min: 21, max: 25, F: [19, 24, 30, 35], M: [10, 15, 22, 26] },
{ min: 26, max: 30, F: [20, 25, 31, 36], M: [11, 16, 21, 27] },
{ min: 31, max: 35, F: [21, 26, 33, 36], M: [13, 17, 25, 28] },
{ min: 46, max: 40, F: [22, 27, 34, 37], M: [15, 20, 26, 29] },
{ min: 41, max: 45, F: [23, 28, 35, 38], M: [16, 22, 27, 30] },
{ min: 46, max: 50, F: [24, 30, 36, 38], M: [17, 23, 29, 31] },
{ min: 51, max: 55, F: [26, 31, 36, 39], M: [19, 25, 30, 33] },
{ min: 56, max: 100, F: [27, 32, 37, 40], M: [21, 26, 31, 34] },
];
/**
* Получает текстовое описание для значения на основе шкалы.
* @param {number} val - Значение.
* @param {number[]} scale - Массив пороговых значений шкалы.
* @param {string[]} descriptions - Массив текстовых описаний.
* @returns {string} Текстовое описание.
*/
static GET_SCALE_VALUE_DESCRIPTION(val, scale, descriptions) {
let desc;
scale.some((s, i) => {
if (val <= s) {
desc = descriptions[i];
return true;
}
});
return desc || descriptions[descriptions.length - 1];
};
/**
* Ограничивает значение заданным диапазоном.
* @param {number} val - Значение.
* @param {number} min - Минимальный порог.
* @param {number} max - Максимальный порог.
* @returns {number} Ограниченное значение.
*/
static CHECK_OVERFLOW(val, min, max) {
if (val < min)
return min;
if (val > max)
return max;
return val;
};
/**
* @typedef {Omit<Property, 'read'> & {
* read: (buf: Buffer) => ({
* isStabilized: false,
* weight: number,
* unit: string
* } | {
* isStabilized: true,
* timestamp: string,
* weight: number,
* unit: string,
* impedance: number,
* bmi?: { value: number, status: string },
* bodyScore?: { value: number },
* bodyFat?: { value: number, status: string },
* muscleMass?: { value: number, status: string },
* water?: { value: number, status: string },
* boneMass?: { value: number, status: string },
* protein?: { value: number, status: string },
* visceralFat?: { value: number, status: string },
* basalMetabolism?: { value: number },
* bodyAge?: { value: number },
* idealWeight?: { value: number },
* bodyType?: { value: string },
* } | null)
* }} MeasurementProperty
*/
/**
* @type {({
* measurement: MeasurementProperty
* }) & { [x: string]: Property }}
* @property {MeasurementProperty} measurement Характеристика для получения данных измерений. Возвращает промежуточные (только вес) и финальные (полный отчет) данные.
*/
properties = {
'measurement': {
service: '0000181b-0000-1000-8000-00805f9b34fb',
characteristic: '00002a9c-0000-1000-8000-00805f9b34fb',
access: ['read', 'notify'],
read: buf => {
if (buf.length < 2)
return null;
const isImperial = (buf.readUInt8(0) & 0x01) !== 0;
const unit = isImperial ? 'lbs' : 'kg';
const divisor = isImperial ? 100 : 200;
const isStabilized = (buf.readUInt8(1) & 0x20) !== 0;
const hasImpedance = (buf.readUInt8(1) & 0x02) !== 0;
if (buf.length >= 13 && isStabilized && hasImpedance) {
const result = {
isStabilized: true,
timestamp: new Date(buf.readUInt16LE(2), buf.readUInt8(4) - 1, buf.readUInt8(5), buf.readUInt8(6), buf.readUInt8(7), buf.readUInt8(8)).toISOString(),
impedance: buf.readUInt16LE(9),
weight: parseFloat((buf.readUInt16LE(11) / divisor).toFixed(2)),
unit
};
if (this.config?.user) {
const metrics = this.calculateBodyMetrics(result.weight, result.impedance);
return { ...result, ...metrics };
}
return result;
}
if (buf.length > 0)
return {
isStabilized: false,
weight: parseFloat((buf.readUInt16LE(buf.length - 2) / divisor).toFixed(2)),
unit
};
return null;
}
}
};
/** @param {Config} config @param {XiaomiMiHome} client */
constructor(config, client) {
super(config, client);
this.config = config;
};
/**
* Рассчитывает все метрики тела на основе веса и импеданса.
* @param {number} weight - Вес в кг.
* @param {number} impedance - Электрический импеданс.
* @returns {object} Объект со всеми рассчитанными метриками.
*/
calculateBodyMetrics(weight, impedance) {
const { height, birthday, gender } = this.config.user;
if (!height || !birthday || !gender)
return null;
const age = (new Date().getTime() - new Date(birthday).getTime()) / 31_556_926_000;
if (isNaN(age)) {
this.client?.log('error', 'Invalid birthdate, cannot calculate age.');
return null;
}
const sex = gender === 'male' ? 'M' : 'F';
const lbm = this.getLBMCoefficient(weight, impedance, height, age);
const bodyFat = this.getFatPercentage(lbm, weight, age, sex, height);
const boneMass = this.getBoneMass(lbm, sex);
const muscleMass = this.getMuscleMass(weight, bodyFat, boneMass);
const water = this.getWaterPercentage(bodyFat);
const protein = this.getProteinPercentage(weight, bodyFat, water, boneMass);
const bmi = this.getBMI(weight, height);
const bmr = this.getBMR(weight, height, age, sex);
const visceralFat = this.getVisceralFat(weight, age, height, sex);
const bodyFatScale = this.getFatPercentageScale(age, sex);
const muscleMassScale = this.getMuscleMassScale(height, sex);
return {
bmi: {
value: parseFloat(bmi.toFixed(2)),
status: XiaomiScales.GET_SCALE_VALUE_DESCRIPTION(bmi, this.getBMIScale(), XiaomiScales.BMI_STATUSES)
},
bodyFat: {
value: parseFloat(bodyFat.toFixed(2)),
status: XiaomiScales.GET_SCALE_VALUE_DESCRIPTION(bodyFat, bodyFatScale, XiaomiScales.BODY_FAT_STATUSES)
},
muscleMass: {
value: parseFloat(muscleMass.toFixed(2)),
status: XiaomiScales.GET_SCALE_VALUE_DESCRIPTION(muscleMass, muscleMassScale, XiaomiScales.MUSCLE_MASS_STATUSES)
},
water: {
value: parseFloat(water.toFixed(2)),
status: XiaomiScales.GET_SCALE_VALUE_DESCRIPTION(water, this.getWaterPercentageScale(), XiaomiScales.WATER_STATUSES)
},
boneMass: {
value: parseFloat(boneMass.toFixed(2)),
status: XiaomiScales.GET_SCALE_VALUE_DESCRIPTION(boneMass, this.getBoneMassScale(weight, sex), XiaomiScales.BONE_MASS_STATUSES)
},
protein: {
value: parseFloat(protein.toFixed(2)),
status: XiaomiScales.GET_SCALE_VALUE_DESCRIPTION(protein, this.getProteinPercentageScale(), XiaomiScales.PROTEIN_STATUSES)
},
visceralFat: {
value: parseFloat(visceralFat.toFixed(2)),
status: XiaomiScales.GET_SCALE_VALUE_DESCRIPTION(visceralFat, this.getVisceralFatScale(), XiaomiScales.VISCERAL_FAT_STATUSES)
},
basalMetabolism: {
value: parseFloat(bmr.toFixed(2))
},
bodyAge: {
value: Math.round(this.getBodyAge(bodyFat, age, sex))
},
idealWeight: {
value: parseFloat(this.getIdealWeight(height).toFixed(2))
},
bodyType: {
value: this.getBodyType(bodyFat, muscleMass, bodyFatScale, muscleMassScale)
},
bodyScore: {
value: parseFloat(this.getBodyScore(weight, bodyFat, muscleMass, water, bmr).toFixed(2))
}
};
};
/**
* Рассчитывает коэффициент безжировой массы тела (LBM).
* @param {number} weight Вес (кг).
* @param {number} impedance Импеданс.
* @param {number} height Рост (см).
* @param {number} age Возраст (лет).
* @returns {number} Коэффициент LBM.
*/
getLBMCoefficient(weight, impedance, height, age) {
let lbm = (height * 9.058 / 100) * (height / 100);
lbm += weight * 0.32 + 12.226;
lbm -= impedance * 0.0068;
lbm -= age * 0.0542;
return lbm;
};
/**
* Рассчитывает базальный метаболизм (BMR).
* @param {number} weight Вес (кг).
* @param {number} height Рост (см).
* @param {number} age Возраст (лет).
* @param {'M'|'F'} sex Пол.
* @returns {number} Количество калорий BMR.
*/
getBMR(weight, height, age, sex) {
let bmr;
if (sex === 'M') {
bmr = 877.8 + weight * 14.916 - height * 0.726 - age * 8.976;
if (bmr > 2322)
bmr = 5000;
} else {
bmr = 864.6 + weight * 10.2036 - height * 0.39336 - age * 6.204;
if (bmr > 2996)
bmr = 5000;
}
return XiaomiScales.CHECK_OVERFLOW(bmr, 500, 10000);
};
/**
* Рассчитывает процент жира в организме.
* @param {number} lbm Коэффициент безжировой массы тела.
* @param {number} weight Вес (кг).
* @param {number} age Возраст (лет).
* @param {'M'|'F'} sex Пол.
* @param {number} height Рост (см).
* @returns {number} Процент жира.
*/
getFatPercentage(lbm, weight, age, sex, height) {
let fatPercentage;
let coefficient = 1.0;
let negativeConstant;
if (sex === 'F') {
negativeConstant = (age <= 49) ? 9.25 : 7.25;
if (weight > 60) {
coefficient = 0.96;
if (height > 160)
coefficient *= 1.03;
} else if (weight < 50) {
coefficient = 1.02;
if (height > 160)
coefficient *= 1.03;
}
} else {
negativeConstant = 0.8;
if (weight < 61)
coefficient = 0.98;
}
fatPercentage = (1.0 - (((lbm - negativeConstant) * coefficient) / weight)) * 100;
if (fatPercentage > 63)
fatPercentage = 75;
return XiaomiScales.CHECK_OVERFLOW(fatPercentage, 5, 75);
};
/**
* Возвращает шкалу для оценки процента жира.
* @param {number} age Возраст (лет).
* @param {'M'|'F'} sex Пол.
* @returns {number[]} Массив пороговых значений.
*/
getFatPercentageScale(age, sex) {
const scale = XiaomiScales.FAT_PERCENTAGE_SCALES.find(s => age >= s.min && age <= s.max);
return scale ? scale[sex] : [];
};
/**
* Рассчитывает процент воды в организме.
* @param {number} fatPercentage Процент жира.
* @returns {number} Процент воды.
*/
getWaterPercentage(fatPercentage) {
let waterPercentage = (100 - fatPercentage) * 0.7;
let coefficient = (waterPercentage <= 50) ? 1.02 : 0.98;
if (waterPercentage * coefficient >= 65)
waterPercentage = 75;
return XiaomiScales.CHECK_OVERFLOW(waterPercentage * coefficient, 35, 75);
};
/**
* Возвращает шкалу для оценки процента воды.
* @returns {number[]} Массив пороговых значений.
*/
getWaterPercentageScale() {
return [53, 67];
};
/**
* Рассчитывает массу костей.
* @param {number} lbm Коэффициент безжировой массы тела.
* @param {'M'|'F'} sex Пол.
* @returns {number} Масса костей (кг).
*/
getBoneMass(lbm, sex) {
const base = (sex === 'F') ? 0.245691014 : 0.18016894;
let boneMass = (base - (lbm * 0.05158)) * -1;
if (boneMass > 2.2)
boneMass += 0.1;
else
boneMass -= 0.1;
if ((sex === 'F') && (boneMass > 5.1))
boneMass = 8;
else if ((sex === 'M') && (boneMass > 5.2))
boneMass = 8;
return XiaomiScales.CHECK_OVERFLOW(boneMass, 0.5, 8);
};
/**
* Возвращает шкалу для оценки костной массы.
* @param {number} weight Вес (кг).
* @param {'M'|'F'} sex Пол.
* @returns {number[]} Массив пороговых значений.
*/
getBoneMassScale(weight, sex) {
const scale = XiaomiScales.BONE_MASS_SCALES.find(s => weight >= s[sex].min);
return scale ? [scale[sex].optimal - 1, scale[sex].optimal + 1] : [];
};
/**
* Рассчитывает мышечную массу.
* @param {number} weight Вес (кг).
* @param {number} fatPercentage Процент жира.
* @param {number} boneMass Костная масса (кг).
* @returns {number} Мышечная масса (кг).
*/
getMuscleMass(weight, fatPercentage, boneMass) {
let muscleMass = weight - ((fatPercentage * 0.01) * weight) - boneMass;
return XiaomiScales.CHECK_OVERFLOW(muscleMass, 10, 120);
};
/**
* Возвращает шкалу для оценки мышечной массы.
* @param {number} height Рост (см).
* @param {'M'|'F'} sex Пол.
* @returns {number[]} Массив пороговых значений.
*/
getMuscleMassScale(height, sex) {
const scale = XiaomiScales.MUSCLE_MASS_SCALES.find(s => height >= s.min);
return scale ? scale[sex] : [];
};
/**
* Рассчитывает уровень висцерального жира.
* @param {number} weight Вес (кг).
* @param {number} age Возраст (лет).
* @param {number} height Рост (см).
* @param {'M'|'F'} sex Пол.
* @returns {number} Уровень висцерального жира.
*/
getVisceralFat(weight, age, height, sex) {
let vfal;
if (sex === 'F') {
const subcalc = 0.691 + (height * -0.0024) + (height * -0.0024);
vfal = (((height * 0.027) - (subcalc * weight)) * -1) + (age * 0.07) - age;
} else {
const subcalc = 0.765 + height * -0.0015;
vfal = (((height * 0.143) - (weight * subcalc)) * -1) + (age * 0.15) - 5.0;
}
return XiaomiScales.CHECK_OVERFLOW(vfal, 1, 50);
};
/**
* Возвращает шкалу для оценки висцерального жира.
* @returns {number[]} Массив пороговых значений.
*/
getVisceralFatScale() {
return [10, 15];
};
/**
* Рассчитывает индекс массы тела (ИМТ).
* @param {number} weight Вес (кг).
* @param {number} height Рост (см).
* @returns {number} ИМТ.
*/
getBMI(weight, height) {
return XiaomiScales.CHECK_OVERFLOW(weight / ((height / 100) * (height / 100)), 10, 90);
};
/**
* Возвращает шкалу для оценки ИМТ.
* @returns {number[]} Массив пороговых значений.
*/
getBMIScale() {
return [18.5, 25, 28, 32];
};
/**
* Рассчитывает идеальный вес.
* @param {number} height Рост (см).
* @returns {number} Идеальный вес (кг).
*/
getIdealWeight(height) {
return XiaomiScales.CHECK_OVERFLOW((22 * height) * height / 10000, 5.5, 198);
};
/**
* Рассчитывает процент белка в организме.
* @param {number} weight Общий вес (кг).
* @param {number} fatPercentage Процент жира.
* @param {number} waterPercentage Процент воды.
* @param {number} boneMass Костная масса (кг).
* @returns {number} Процент белка.
*/
getProteinPercentage(weight, fatPercentage, waterPercentage, boneMass) {
const fatMass = weight * (fatPercentage / 100);
const waterMass = weight * (waterPercentage / 100);
const proteinMass = weight - fatMass - waterMass - boneMass;
const proteinPercentage = (proteinMass / weight) * 100;
return XiaomiScales.CHECK_OVERFLOW(proteinPercentage, 10, 32);
};
/**
* Возвращает шкалу для оценки процента белка.
* @returns {number[]} Массив пороговых значений.
*/
getProteinPercentageScale() {
return [16, 20];
};
/**
* Определяет тип телосложения.
* @param {number} fatPercentage Процент жира.
* @param {number} muscleMass Мышечная масса (кг).
* @param {number[]} fatScale Шкала оценки жира.
* @param {number[]} muscleScale Шкала оценки мышц.
* @returns {string} Тип телосложения.
*/
getBodyType(fatPercentage, muscleMass, fatScale, muscleScale) {
let factor;
if (fatPercentage > fatScale[2])
factor = 0;
else if (fatPercentage < fatScale[1])
factor = 2;
else
factor = 1;
if (muscleMass > muscleScale[1])
return XiaomiScales.BODY_TYPES[2 + (factor * 3)];
if (muscleMass < muscleScale[0])
return XiaomiScales.BODY_TYPES[(factor * 3)];
return XiaomiScales.BODY_TYPES[1 + (factor * 3)];
};
/**
* Рассчитывает метаболический возраст.
* @param {number} fatPercentage Процент жира.
* @param {number} age Возраст (лет).
* @param {'M'|'F'} sex Пол.
* @returns {number} Метаболический возраст.
*/
getBodyAge(fatPercentage, age, sex) {
let bodyAge;
if (sex === 'M')
bodyAge = (fatPercentage * 0.8) + (age * 0.8);
else
bodyAge = (fatPercentage * 0.8) + (age * 0.9);
return XiaomiScales.CHECK_OVERFLOW(bodyAge, 10, 80);
};
/**
* Рассчитывает общую оценку тела.
* @param {number} weight Вес (кг).
* @param {number} fatPercentage Процент жира.
* @param {number} muscleMass Мышечная масса (кг).
* @param {number} waterPercentage Процент воды.
* @param {number} bmr Базальный метаболизм.
* @returns {number} Оценка тела (от 50 до 100).
*/
getBodyScore(weight, fatPercentage, muscleMass, waterPercentage, bmr) {
let score = 0;
score += (weight / this.getIdealWeight(this.config.user.height)) * 20;
score += (100 - fatPercentage) * 0.3;
score += (muscleMass / (weight * 0.85)) * 30;
score += (waterPercentage / 65) * 10;
score += (bmr / 2200) * 10;
return XiaomiScales.CHECK_OVERFLOW(score, 50, 100);
};
};