Workout mode: HR-only recording (зарядка) without GPS

New 'Зарядка' activity type records a session with no GPS - no location
service, no watchPosition, no track points - just the heart-rate poll and a
timer, for indoor exercise. kcal comes from HR (Keytel gross minus BMR, net
like the rest of the app) and counts in the daily balance.

- gps.js: GPS.hrKcalNet; startRecording/stopRecording 'workout' branches;
  the HR poll appends [relSec,bpm] samples.
- db.js: gps_sessions.hr_samples column + setSessionHrSamples.
- main.js: 'Зарядка (пульс)' option; renderWorkoutInline (map-less live
  screen: big HR, timer, kcal, connect-sensor button); renderWorkoutDetail
  (HR chart by zones, load, avg/max).
- tools/hr_log.py: background HR logger (standard 0x180D, no pairing).

Sensor read from the desktop confirmed on a Coospo H808S (F7:72:74:A4:FB:07,
~1 Hz) without pairing.
This commit is contained in:
4aiman 2026-10-17 00:54:21 +03:00
commit f704498d11
7 changed files with 295 additions and 3 deletions

View file

@ -24,6 +24,7 @@
- **Дневник питания** — ~13000 продуктов с меткой источника (Магнит, 5ка, ИОН, KDV, Гриднев, свои). Поиск порядко-независимый (слова в любом порядке). Каталог чистится от неиспользованного импорта без метки (health-diet и пр.) кнопкой в настройках — съеденные и в рецептах сохраняются. 37 нутриентов (витамины, минералы, omega-3/6, вода, клетчатка). Рецепты с термообработкой. Формулы порций (4*30, 300+50).
- **GPS трекинг** — запись маршрутов на велосипеде/пешком в фоне (foreground service, точки пишутся при выключенном экране; один файл на сессию с id сессии в имени `gps_points_<ms>_s<id>.jsonl` для однозначной привязки, переживает START_STICKY-перезапуск ОС; полный до-импорт нативных точек по timestamp при финализации — без потерь, даже если экран был выключен всю запись, а при пробуждении экрана точки видимо нарастают на карте по мере импорта). При живой записи нативный файл — источник истины для БД, watchPosition только рисует маркер (без дублей одного фикса с разными ts). Восстановление точек из файлов на устройстве — матч файла к сессии по id/дате, дозаполнение разрывов с дедупом по близости ±2.5с, безопасный пересчёт без удаления точек, чистка уже импортированных файлов. Оффлайн карты (Leaflet + IndexedDB кэш). Импорт GPX/FTB (FitoTrack). Экспорт GPX. Фильтр altitude outlier'ов и GPS-залипаний. Отбор точек по точности (жёсткий порог по настройке «Мин. точность» — рубит грубые сотовые фиксы ~800м, оставляет слабый но точный GPS; где сигнала нет — честный разрыв). Направление (bearing) каждой точки пишется в БД. Вращение карты по компасу (leaflet-rotate, EMA-сглаживание, gimbal lock компенсация) и course-up по GPS-курсу (в ландшафте карта доворачивается вместе с экраном — курс к физическому верху). Ландшафтный режим (CSS rotation + панель справа, 3 режима: off/auto/lock). Dead reckoning при потере GPS (гироскоп + акселерометр). Placeholder до GPS (пиксельарт). Карта проезженных дорог (дедупликация 2м, вело/пешие). Настраиваемый цвет линии трека (чтобы отличать от цветных дорог). Навигационный маркер позиции с треугольником-направленцем (по компасу или GPS-курсу); порядок слоёв тайлы<дороги<маршрут<трек<маркер. Импорт тайлов по регионам (SQLite, роутер LAN).
- **BLE пульсометр** — подключение к FitPro браслетам и стандартным HR датчикам (0x180D). ЧСС, SpO2, давление в реальном времени при записи трека. Подключение **только вручную** (Настройки -> «Подключить»), в т.ч. во время записи - пульс пишется в трек с момента подключения; автопоиска при старте записи нет (экономия батареи). BT выключен -> кнопка просит включить (системный диалог). Браслет не найден в скане -> сервис не висит в коннекте.
- **Зарядка (пульс)** — режим записи БЕЗ GPS для домашней тренировки: геолокация не включается и не пишется, только пульс с датчика + таймер. Экран без карты (крупный пульс, время, живые ккал, средний/макс). Нагрузка (ккал) считается по пульсу формулой Keytel (нетто, как и остальная активность) и идёт в дневной баланс. Деталь - без карты, график пульса по HR-зонам. Хранится в `gps_sessions` (`activity_type="workout"`, без точек, ряд пульса в `hr_samples`).
- **Расчёт ккал** — велосипед: НЕТТО-модель (суточный баланс уже учитывает BMR, поэтому активность считается сверх покоя). Кручение = max(физика мощности из gradient+Crr+CdA / КПД, эмпирический MET-нетто по скорости); накат (freewheel, `power≤0` — «велик катился бы сам») = только базовый обмен. Crr на грунтах уточняется по вибрации акселерометра (до 0.020 на траве/колее). Ходьба с велосипедом в горку (только односкоростной) отделяется автоматически по скорости ниже каденса 30 об/мин и не засчитывается как езда. MET-таблица для ходьбы/бега и тренировок (приседания 6.0, скакалка 12.3, растяжки 2.3, бассейн 8.0). Набор высоты — кумулятивный гистерезис (устойчив на плотных треках, где per-point разница <1м); абсолютная точность набора ограничена сырым GPS-альтиметром (порядка десятков процентов, без модели рельефа не разрешается). Известное ограничение: `max(физика, MET)` не складывает накладные расходы, поэтому подъёмы по ккал слегка недосчитываются. Пункт меню «Пересчитать высоту/калории» пересчитывает статистику всех треков без изменения точек. Профили велосипедов. BMR по Mifflin-St Jeor с коэффициентом активности. Вес водителя для ккал берётся из **последнего взвешивания** (настройки - запас); каждая новая поездка **хранит вес на момент записи** (`gps_sessions.rider_weight_kg`), поэтому пересчёт старого трека считает по тогдашнему весу, а не текущему (старые треки без сохранённого веса - по последнему взвешиванию). Поле веса в настройках авто-синкается с последним взвешиванием. Баланс ккал вверху, тренд «Среднее за 7 дн.» и графики (вес/баланс, окна 30/90) считаются от **выбранного дня** (окно кончается на нём), а не всегда от «сегодня».
- **Дистанция на разрывах GPS** — на «слепых» участках (интервал между фиксами > `gapThresholdSec`) путь считается по скорости (speed×dt, с ограничением по макс. скорости), а не прямой хордой — иначе разрывы занижают/искажают длину. Телепорт-скачки без временного разрыва отбрасываются. Расчёт зависит только от времени и скорости, поэтому live-счётчик, «Стоп» и пересчёт дают одинаковую длину. Порог `gapThresholdSec` настраивается; при изменении и пересчёте применяется сразу.
- **Тренировки** — приседания (подходы x повторения), скакалка, растяжки, бассейн (дистанция). Автоматический расчёт ккал по MET и весу.

View file

@ -0,0 +1,40 @@
# Сессия 2026-10-17 - HR-датчик Coospo H808S: чтение + режим "Зарядка" без GPS
## Датчик прочитан с ПК (без спаривания)
`F7:72:74:A4:FB:07` (`808S 0026275`), стандарт 0x180D/0x2A37. `tools/ble_probe.py`
подключается без pair(), живой пульс ~1/сек (+ RR). `tools/hr_log.py` - фоновый логгер
(epoch,bpm в файл, авто-переподключение). Адаптер ПК - hci1 (один). Подробности в памяти
project-hr-sensor.
## Режим "Зарядка" (workout) - запись без GPS, только пульс + нагрузка
Нужен был режим трекинга без геолокации (домашняя зарядка): меряет пульс и ккал, GPS не
включает и не пишет.
- `frontend/js/gps.js`:
- `GPS.hrKcalNet(samples, profile)` - нетто-ккал по пульсу (Keytel gross минус BMR/мин,
как и остальная активность). samples = [[relSec,bpm]].
- `startRecording(type, wkg, profile)` - ветка `workout`: НЕ запускает нативный GPS-сервис,
watchPosition, поллинг точек. Создаёт сессию (`activity_type="workout"`), запускает HR-опрос.
- HR-опрос (`_startHrMonitor`) при workout пушит `[relSec,bpm]` в `GPS.recording.hrSamples`.
- `stopRecording` ветка workout: считает ккал (Keytel), avg/max пульс, сохраняет `hr_samples`
(JSON) + `finishGpsSession(dist=0, kcal)`.
- `frontend/js/db.js`: миграция `gps_sessions.hr_samples TEXT`; `setSessionHrSamples`.
- `frontend/js/main.js`:
- выпадашка записи: "Зарядка (пульс)".
- `renderWorkoutInline` - экран записи без карты: крупный пульс, таймер, живые ккал,
средний/макс, кнопка "Подключить пульсометр" (с проверкой BT), Стоп -> деталь.
- `renderWorkoutDetail` - деталь без карты: график пульса (цвет по HR-зонам), время,
нагрузка (ккал), средний/макс, удалить.
- `frontend/css/styles.css`: `.workout-screen` и пр.
Ккал зарядки идёт в дневной баланс автоматически (`getDailyActivityTotals` суммирует
`gps_sessions.total_kcal`). `recalculateSession` для workout - no-op (точек нет, возвращает null),
так что "Пересчитать всё" не затирает HR-ккал.
## Осталось (отдельным коммитом)
Заряд ремня ЧСС (0x180F/0x2A19) + голос/писк на 10% и 5%.
## Проверка
Синтаксис JS ок. На телефоне: выбрать "Зарядка", Старт (без браслета покажет "Подключите
пульсометр"), подключить датчик -> пульс крупно + ккал растут; Стоп -> деталь с графиком.
Нужен BT на телефоне + привязанный датчик. (Сейчас BT на телефоне выключен.)

View file

@ -714,6 +714,17 @@ select {
.full-width { width: 100%; }
/* HR-only workout (зарядка) recording screen: big heart rate, timer, kcal, no map. */
.workout-screen { padding: 24px 16px; display: flex; flex-direction: column; align-items: center; gap: 8px; }
.wk-hr-big { font-size: 92px; font-weight: 700; line-height: 1; color: var(--primary); }
.wk-hr-lbl { color: var(--muted); margin-top: -4px; }
.wk-stats { display: flex; gap: 18px; margin: 14px 0; flex-wrap: wrap; justify-content: center; }
.wk-stat { display: flex; flex-direction: column; align-items: center; min-width: 64px; }
.wk-val { font-size: 22px; font-weight: 600; }
.wk-lbl { font-size: 12px; color: var(--muted); }
.wk-status { color: var(--muted); font-size: 13px; text-align: center; min-height: 18px; margin-bottom: 6px; }
.workout-screen .btn-primary, .workout-screen .btn-secondary { margin-top: 8px; width: 100%; }
/* Progress fill for long-running action buttons: the background fills left-to-right by
--pct (0-100%, set from JS). The label stays on top and readable; the unfilled part keeps
the button's own background. Used by recompute / elevation-refine / roads-rebuild. */

View file

@ -1106,6 +1106,11 @@ async function setSessionRiderWeight(sessionId, kg) {
await execute("UPDATE gps_sessions SET rider_weight_kg = ?1 WHERE id = ?2", [kg, sessionId]);
}
// HR-only "workout" session: store the heart-rate time series as JSON [[relSec,bpm],...].
async function setSessionHrSamples(sessionId, samplesJson) {
await execute("UPDATE gps_sessions SET hr_samples = ?1 WHERE id = ?2", [samplesJson, sessionId]);
}
// Bulk-set terrain altitude (altitude_srtm_m) for points by id. rows: [{id, srtm}].
// Batched CASE update — values are numbers (ids small, no i64 corruption), safe to inline.
async function updatePointsSrtm(rows) {
@ -1410,6 +1415,7 @@ window.DB.updateBike = updateBike;
window.DB.deleteBike = deleteBike;
window.DB.updateSessionBike = updateSessionBike;
window.DB.setSessionRiderWeight = setSessionRiderWeight;
window.DB.setSessionHrSamples = setSessionHrSamples;
window.DB.updatePointsSrtm = updatePointsSrtm;
window.DB.getBikeMaintenanceLog = getBikeMaintenanceLog;
window.DB.addBikeMaintenance = addBikeMaintenance;
@ -1733,6 +1739,7 @@ async function runMigrations() {
if (!gsCols.includes("wind_marks")) await execute("ALTER TABLE gps_sessions ADD COLUMN wind_marks TEXT"); // JSON [{startTs,endTs}] headwind labels (ground truth for wind estimate)
if (!gsCols.includes("gap_filled")) await execute("ALTER TABLE gps_sessions ADD COLUMN gap_filled INTEGER DEFAULT 0"); // BRouter gap-fill applied (reversible)
if (!gsCols.includes("rider_weight_kg")) await execute("ALTER TABLE gps_sessions ADD COLUMN rider_weight_kg REAL"); // rider weight at ride time; recompute uses it (NULL on old sessions -> latest weigh-in)
if (!gsCols.includes("hr_samples")) await execute("ALTER TABLE gps_sessions ADD COLUMN hr_samples TEXT"); // HR-only "workout" sessions (no GPS): JSON [[relSec,bpm],...]
// Add tire_pressure_bar to bikes (for Crr correction by tire pressure)
const bikeCols = (await select("PRAGMA table_info(bikes)")).map(r => r.name);

View file

@ -843,6 +843,10 @@ GPS._startHrMonitor = async function() {
if (reading && reading.hr > 0) {
GPS.recording.currentHr = reading.hr;
GPS.recording.currentBleData = reading;
// HR-only workout: the poll IS the recorder — append [relSec, bpm] each reading.
if (GPS.recording.activityType === "workout" && GPS.recording.startTime) {
GPS.recording.hrSamples.push([Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000), reading.hr]);
}
if (GPS.recording.onHrUpdate) GPS.recording.onHrUpdate(reading);
}
} catch {}
@ -1296,15 +1300,55 @@ GPS.recoverSession = async function() {
}
};
GPS.startRecording = async function(activityType, weightKg) {
// Net kcal from a heart-rate series (Keytel 2005 gross, minus BMR for the same time so it is
// consistent with the app's NET model — BMR is already booked in the daily balance).
// samples: [[relSec, bpm], ...]. profile: {weightKg, age, sex ("m"/"f"), heightCm}.
GPS.hrKcalNet = function(samples, profile) {
if (!samples || samples.length < 2) return 0;
profile = profile || {};
const W = parseFloat(profile.weightKg) || 75, A = parseInt(profile.age) || 38;
const H = parseFloat(profile.heightCm) || 178, male = (profile.sex || "m") === "m";
const bmrMin = (10 * W + 6.25 * H - 5 * A + (male ? -5 : 161)) / 1440; // Mifflin -> kcal/min
const grossPerMin = hr => male
? (-55.0969 + 0.6309 * hr + 0.1988 * W + 0.2017 * A) / 4.184
: (-20.4022 + 0.4472 * hr - 0.1263 * W + 0.0740 * A) / 4.184;
let kcal = 0;
for (let i = 1; i < samples.length; i++) {
const dtMin = Math.max(0, samples[i][0] - samples[i-1][0]) / 60;
if (dtMin <= 0 || dtMin > 1) continue; // skip > 1 min gaps (disconnect)
const net = grossPerMin((samples[i-1][1] + samples[i][1]) / 2) - bmrMin;
if (net > 0) kcal += net * dtMin;
}
return Math.round(kcal);
};
GPS.startRecording = async function(activityType, weightKg, profile) {
if (GPS.recording.active) return;
if (!navigator.geolocation) throw new Error("Geolocation not available");
GPS.recording.active = true;
GPS.recording.activityType = activityType || "bike";
GPS.recording.points = [];
GPS.recording.hrSamples = [];
GPS.recording.hrProfile = profile || null;
GPS.recording.startTime = null;
GPS.recording.weightKg = parseFloat(weightKg) || 80;
// HR-only "workout" (зарядка): no GPS service, no location, no track points — just the
// heart-rate poll + a timer. kcal comes from HR (Keytel) at finish.
if (GPS.recording.activityType === "workout") {
GPS.recording.state = "recording";
GPS.recording.startTime = new Date();
GPS.recording.nativeService = false;
GPS.recording.sessionId = null;
try {
GPS.recording.sessionId = await DB.createGpsSession(
new Date().toISOString().slice(0, 10), new Date().toTimeString().slice(0, 5), "workout");
} catch (e) { console.error("[workout] createGpsSession failed:", e); }
try { await GPS._startHrMonitor(); } catch (e) { console.error("[HR] start error:", e); }
return;
}
if (!navigator.geolocation) throw new Error("Geolocation not available");
GPS.recording.state = "waiting_gps";
GPS.recording.sessionId = null;
GPS.recording.pauseStart = null;
@ -1400,6 +1444,26 @@ GPS.stopRecording = async function() {
GPS.recording.active = false;
if (GPS.recording._pollTimer) clearInterval(GPS.recording._pollTimer);
// HR-only workout: no GPS to stop. Compute net kcal from HR, save samples, finalise.
if (GPS.recording.activityType === "workout") {
const samples = GPS.recording.hrSamples || [];
const kcal = GPS.hrKcalNet(samples, GPS.recording.hrProfile);
const durationSec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0;
const hrs = samples.map(s => s[1]);
const avgHr = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null;
const maxHr = hrs.length ? Math.max(...hrs) : null;
const sid = GPS.recording.sessionId;
GPS._stopHrMonitor();
if (sid) {
try { await DB.setSessionHrSamples(sid, JSON.stringify(samples)); } catch (e) { console.error("[workout] save samples", e); }
// Reuse finishGpsSession: distance 0, kcal from HR; avg/max HR ride the speed columns' siblings? No —
// store them nowhere extra; detail derives avg/max from hr_samples. Just distance 0 + kcal.
try { await DB.finishGpsSession(sid, new Date().toTimeString().slice(0, 5), 0, kcal, null, null, 0, 0); } catch (e) { console.error("[workout] finish", e); }
}
return { sessionId: sid, workout: true, kcal, durationSec, avgHr, maxHr, samples };
}
GPS._stopHrMonitor();
if (GPS.recording.watchId != null) {

View file

@ -2784,6 +2784,7 @@ async function renderGpsScreen(container) {
<option value="bike"${(localStorage.getItem("baldav_gps_type")||"bike")==="bike"?" selected":""}>Вело</option>
<option value="walk"${localStorage.getItem("baldav_gps_type")==="walk"?" selected":""}>Ходьба</option>
<option value="run"${localStorage.getItem("baldav_gps_type")==="run"?" selected":""}>Бег</option>
<option value="workout"${localStorage.getItem("baldav_gps_type")==="workout"?" selected":""}>Зарядка (пульс)</option>
</select>
<button id="gps-rec-btn" class="btn-primary gps-rec ${isRec ? "recording" : ""}">
${isRec ? "Трекинг" : "Старт"}
@ -2845,7 +2846,14 @@ async function renderGpsScreen(container) {
GPS.recording.gearCount = bike.gear_count;
}
}
await GPS.startRecording(type, wkg);
// Workout (HR-only): pass the profile so finish can compute kcal from HR (Keytel).
let hrProfile = type === "workout" ? {
weightKg: wkg,
age: BMR.getAge(parseInt(state.profile?.birth_year) || 1988),
sex: state.profile?.sex || "m",
heightCm: parseFloat(state.profile?.height_cm) || 178,
} : null;
await GPS.startRecording(type, wkg, hrProfile);
renderScreen("recording");
}
};
@ -4425,6 +4433,111 @@ function posPuckIcon(fill, withTriangle) {
});
}
// HR-only "workout" session detail: no map — HR chart + load (kcal) + avg/max.
function renderWorkoutDetail(session) {
let samples = [];
try { samples = JSON.parse(session.hr_samples || "[]"); } catch {}
let hrs = samples.map(s => s[1]);
let durSec = samples.length ? samples[samples.length - 1][0] : 0;
let avg = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : null;
let max = hrs.length ? Math.max(...hrs) : null;
let kcal = Math.round(session.total_kcal || 0);
let html = `
<div class="sd-section"><div class="sd-title">Зарядка · ${session.date} ${session.start_time ?? ""}</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${GPS.formatDuration(durSec)}</span><span class="sd-lbl">Время</span></div>
<div class="sd-stat"><span class="sd-val">${kcal} ккал</span><span class="sd-lbl">Нагрузка</span></div>
</div>
<div class="sd-row">
<div class="sd-stat"><span class="sd-val">${avg ?? "—"}</span><span class="sd-lbl">Средний пульс</span></div>
<div class="sd-stat"><span class="sd-val">${max ?? "—"}</span><span class="sd-lbl">Макс пульс</span></div>
</div>
</div>
<div class="sd-section"><div class="sd-title">Пульс</div>
<div class="chart-wrap"><canvas id="wk-hr-chart" height="150"></canvas></div>
</div>
<button id="wk-del" class="btn-secondary full-width" style="margin-top:8px;color:#cf6679">Удалить зарядку</button>`;
showModal(html, () => {}, (overlay) => {
let ctx = overlay.querySelector("#wk-hr-chart");
if (ctx && samples.length) {
new Chart(ctx, {
type: "line",
data: { labels: samples.map(s => Math.round(s[0] / 60) + "м"),
datasets: [{ data: hrs, borderWidth: 2, pointRadius: 0, fill: false, tension: 0.3,
segment: { borderColor: c => hrZoneColor(c.p1.parsed.y) } }] },
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { display: false } }, scales: { x: { display: false } } },
});
}
let del = overlay.querySelector("#wk-del");
if (del) del.onclick = async () => {
if (!(await appConfirm("Удалить эту зарядку?", "Удалить"))) return;
await DB.deleteGpsSession(session.id);
if (winStack.length > 0) winStack.pop().close();
_skipPops = 1; history.back();
renderScreen("gps");
};
});
}
// HR-only "workout" recording screen (зарядка): no map/GPS, live heart rate + timer + kcal.
function renderWorkoutInline(container) {
container.innerHTML = `
<div class="workout-screen">
<div class="wk-hr-big" id="wk-hr">--</div>
<div class="wk-hr-lbl">уд/мин</div>
<div class="wk-stats">
<div class="wk-stat"><span id="wk-time" class="wk-val">0:00</span><span class="wk-lbl">Время</span></div>
<div class="wk-stat"><span id="wk-kcal" class="wk-val">0</span><span class="wk-lbl">ккал</span></div>
<div class="wk-stat"><span id="wk-avg" class="wk-val">--</span><span class="wk-lbl">средний</span></div>
<div class="wk-stat"><span id="wk-max" class="wk-val">--</span><span class="wk-lbl">макс</span></div>
</div>
<div class="wk-status" id="wk-status">Подключите пульсометр</div>
<button id="wk-connect" class="btn-secondary full-width">Подключить пульсометр</button>
<button id="wk-stop" class="btn-primary gps-rec recording full-width">Стоп</button>
</div>`;
let hrEl = container.querySelector("#wk-hr"), timeEl = container.querySelector("#wk-time");
let kcalEl = container.querySelector("#wk-kcal"), avgEl = container.querySelector("#wk-avg");
let maxEl = container.querySelector("#wk-max"), statusEl = container.querySelector("#wk-status");
GPS.recording.onHrUpdate = (reading) => {
if (hrEl) hrEl.textContent = reading.hr;
if (statusEl) statusEl.textContent = "Пульсометр на связи";
};
let tick = () => {
if (!GPS.recording.active) { clearInterval(window._wkTimer); return; }
let sec = GPS.recording.startTime ? Math.round((Date.now() - GPS.recording.startTime.getTime()) / 1000) : 0;
if (timeEl) timeEl.textContent = GPS.formatDuration(sec);
let samples = GPS.recording.hrSamples || [], hrs = samples.map(s => s[1]);
if (kcalEl) kcalEl.textContent = GPS.hrKcalNet(samples, GPS.recording.hrProfile);
if (avgEl) avgEl.textContent = hrs.length ? Math.round(hrs.reduce((a, b) => a + b, 0) / hrs.length) : "--";
if (maxEl) maxEl.textContent = hrs.length ? Math.max(...hrs) : "--";
if (GPS.recording.currentHr && hrEl) hrEl.textContent = GPS.recording.currentHr;
};
clearInterval(window._wkTimer);
window._wkTimer = setInterval(tick, 1000);
tick();
container.querySelector("#wk-connect").onclick = async () => {
let addr = state.profile?.ble_device_addr;
if (!addr) { showToast("Сначала привяжите датчик: Настройки -> Пульсометр -> Поиск"); return; }
let bt = "on"; try { bt = await window.__TAURI__.core.invoke("ble_check_bluetooth"); } catch {}
if (bt === "off") { try { await window.__TAURI__.core.invoke("ble_request_enable"); } catch {} showToast("Включите Bluetooth"); return; }
if (statusEl) statusEl.textContent = "Подключение...";
try { await window.__TAURI__.core.invoke("ble_connect", { address: addr, deviceType: state.profile?.ble_device_type || "hr_standard" }); } catch (e) { showToast("Ошибка: " + (e.message || e)); }
};
container.querySelector("#wk-stop").onclick = async () => {
clearInterval(window._wkTimer);
let res = await GPS.stopRecording();
if (res && res.sessionId) {
showToast(`Зарядка: ${res.kcal} ккал, ${GPS.formatDuration(res.durationSec)}, пульс ${res.avgHr ?? "--"}/${res.maxHr ?? "--"}`);
showSessionDetail(res.sessionId);
} else { renderScreen("gps"); }
};
}
async function renderRecordingInline(container) {
if (!GPS.recording.active) { renderScreen("gps"); return; }
@ -4435,6 +4548,9 @@ async function renderRecordingInline(container) {
}).catch(() => {});
}
// HR-only workout (зарядка): no map, no GPS — just heart rate, timer, live kcal.
if (GPS.recording.activityType === "workout") { renderWorkoutInline(container); return; }
container.innerHTML = `
<div class="rec-screen">
<div class="rec-map-wrap">
@ -5242,6 +5358,8 @@ async function showSessionDetail(sessionId) {
console.log("[SD] loading session", sessionId);
let session = await DB.getGpsSession(sessionId);
if (!session) { console.error("[SD] session not found"); return; }
// HR-only workout: no GPS/map — its own detail (HR chart + load), reuse this modal path.
if (session.activity_type === "workout") { renderWorkoutDetail(session); return; }
console.log("[SD] loading points");
let rawPoints = await DB.getGpsPoints(sessionId);
console.log("[SD] got", rawPoints.length, "raw points");

51
tools/hr_log.py Normal file
View file

@ -0,0 +1,51 @@
#!/usr/bin/env python3
"""Фоновый логгер ЧСС с BLE HR-датчика (стандарт 0x180D / 0x2A37, напр. Coospo H808S).
Пишет строки 'epoch,bpm' в указанный файл, пока процесс не убьют. Авто-переподключение
при обрыве. Спаривание не требуется.
Использование: python3 tools/hr_log.py <BLE-addr> <logfile>
"""
import asyncio, sys, time
from bleak import BleakClient, BleakScanner
ADDR = sys.argv[1]
LOG = sys.argv[2]
HR_CHR = "00002a37-0000-1000-8000-00805f9b34fb"
def make_cb(logf):
def cb(_, data: bytearray):
flags = data[0]
bpm = int.from_bytes(data[1:3], "little") if flags & 1 else data[1]
logf.write(f"{time.time():.1f},{bpm}\n")
logf.flush()
return cb
async def main():
logf = open(LOG, "a")
logf.write(f"# {time.time():.1f} start addr={ADDR}\n")
logf.flush()
while True:
try:
dev = await BleakScanner.find_device_by_address(ADDR, timeout=15.0)
if dev is None:
logf.write(f"# {time.time():.1f} not_found, retry\n")
logf.flush()
await asyncio.sleep(3)
continue
async with BleakClient(dev) as cli:
logf.write(f"# {time.time():.1f} connected\n")
logf.flush()
await cli.start_notify(HR_CHR, make_cb(logf))
while cli.is_connected:
await asyncio.sleep(1)
logf.write(f"# {time.time():.1f} disconnected, reconnect\n")
logf.flush()
except Exception as e:
logf.write(f"# {time.time():.1f} err {e}\n")
logf.flush()
await asyncio.sleep(3)
asyncio.run(main())