-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathdecode.py
More file actions
126 lines (101 loc) · 3.75 KB
/
Copy pathdecode.py
File metadata and controls
126 lines (101 loc) · 3.75 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
"""Decode BLE advertisement packets and calculate body composition."""
from dataclasses import dataclass
@dataclass(frozen=True)
class ScaleReading:
"""Decoded scale reading from BLE advertisement."""
weight_kg: float
impedance_raw: int
impedance_ohm: float | None
user_id: int
is_complete: bool
is_locked: bool
@dataclass(frozen=True)
class BodyComposition:
"""Calculated body composition metrics."""
body_fat_pct: float
fat_mass_kg: float
lean_mass_kg: float
body_water_pct: float
muscle_mass_kg: float
bone_mass_kg: float
bmr_kcal: int
bmi: float
def decode_packet(manufacturer_id: int, manufacturer_data: bytes) -> ScaleReading | None:
"""Decode tzc scale advertisement packet.
Data bytes layout:
- Bytes 0-1: Weight (big-endian), divide by 10 for kg
- Bytes 2-3: Impedance (big-endian), divide by 10 for ohms
- Bytes 4-5: User ID (big-endian)
- Byte 6: Status (0x20 = weight complete, 0x21 = complete with impedance)
- Bytes 7+: MAC address (ignored)
Note: manufacturer_id varies and is ignored; filtering is by device name "tzc".
"""
if len(manufacturer_data) < 7:
return None
weight_raw = int.from_bytes(manufacturer_data[0:2], "big")
weight_kg = weight_raw / 10
# Ignore spurious readings below minimum weight threshold
if weight_kg < 30:
return None
impedance_raw = int.from_bytes(manufacturer_data[2:4], "big")
impedance_ohm = impedance_raw / 10 if impedance_raw > 0 else None
user_id = int.from_bytes(manufacturer_data[4:6], "big")
status = manufacturer_data[6]
# Complete when:
# - 0x21: weight + impedance both finalized, OR
# - 0x20 with impedance=0: weight-only mode (user not barefoot)
is_complete = status == 0x21 or (status == 0x20 and impedance_raw == 0)
is_locked = is_complete # locked when complete
return ScaleReading(
weight_kg=weight_kg,
impedance_raw=impedance_raw,
impedance_ohm=impedance_ohm,
user_id=user_id,
is_complete=is_complete,
is_locked=is_locked,
)
def calculate_body_composition(
weight_kg: float,
impedance_ohm: float,
height_cm: int,
age: int,
gender: str,
) -> BodyComposition:
"""Calculate body composition using standard BIA formulas (openScale compatible)."""
height_sq = height_cm**2
# Lean Body Mass
if gender == "male":
lbm = 0.485 * (height_sq / impedance_ohm) + 0.338 * weight_kg + 5.32
else:
lbm = 0.474 * (height_sq / impedance_ohm) + 0.180 * weight_kg + 5.03
# Body Fat
fat_mass_kg = weight_kg - lbm
body_fat_pct = (fat_mass_kg / weight_kg) * 100
# Body Water (approximately 73% of lean mass)
body_water_kg = lbm * 0.73
body_water_pct = (body_water_kg / weight_kg) * 100
# Muscle Mass (approximately 90% of lean mass)
muscle_mass_kg = lbm * 0.9
# Bone Mass (estimate based on weight and gender)
if gender == "male":
bone_mass_kg = 0.18 * (height_cm / 100) ** 2 * 22
else:
bone_mass_kg = 0.18 * (height_cm / 100) ** 2 * 20
bone_mass_kg = min(bone_mass_kg, lbm * 0.05) # Cap at 5% of LBM
# BMR (Mifflin-St Jeor)
if gender == "male":
bmr = 88.36 + (13.4 * weight_kg) + (4.8 * height_cm) - (5.7 * age)
else:
bmr = 447.6 + (9.2 * weight_kg) + (3.1 * height_cm) - (4.3 * age)
# BMI
bmi = weight_kg / (height_cm / 100) ** 2
return BodyComposition(
body_fat_pct=round(body_fat_pct, 1),
fat_mass_kg=round(fat_mass_kg, 1),
lean_mass_kg=round(lbm, 1),
body_water_pct=round(body_water_pct, 1),
muscle_mass_kg=round(muscle_mass_kg, 1),
bone_mass_kg=round(bone_mass_kg, 1),
bmr_kcal=round(bmr),
bmi=round(bmi, 1),
)