parent
9fb6a22c84
commit
3f48886f5c
@ -1,50 +1,203 @@
|
|||||||
const { once } = window.__TAURI__.event;
|
const { once } = window.__TAURI__.event;
|
||||||
|
const { invoke } = window.__TAURI__.core;
|
||||||
|
|
||||||
|
const connectScreen = document.querySelector("#connect");
|
||||||
|
const workoutScreen = document.querySelector("#workout");
|
||||||
|
const startBtn = document.querySelector("#start-btn");
|
||||||
|
|
||||||
const groups = {
|
const groups = {
|
||||||
hr_sensors: document.querySelector("#hr-list"),
|
hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" },
|
||||||
cad_sensors: document.querySelector("#cadence-list"),
|
cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" },
|
||||||
trainers: document.querySelector("#trainer-list"),
|
trainers: { list: document.querySelector("#trainer-list"), category: "trainer" },
|
||||||
};
|
};
|
||||||
|
|
||||||
function renderDevice(device) {
|
const deviceCards = new Map(); // device_id -> li
|
||||||
const li = document.createElement("li");
|
const selected = {}; // category -> { btn, card } подключённого датчика
|
||||||
li.className = "device";
|
|
||||||
|
|
||||||
const name = document.createElement("span");
|
function el(tag, className, text) {
|
||||||
name.className = "device__name";
|
const node = document.createElement(tag);
|
||||||
name.textContent = device.display_name || "без имени";
|
if (className) node.className = className;
|
||||||
|
if (text != null) node.textContent = text;
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
const rssi = document.createElement("span");
|
// --- Батарея ---
|
||||||
rssi.className = "device__rssi";
|
function batteryIcon(percent) {
|
||||||
rssi.textContent = device.rssi != null ? `${device.rssi} dBm` : "";
|
const low = percent < 20;
|
||||||
|
const wrap = el("span", "battery" + (low ? " battery--low" : ""));
|
||||||
|
wrap.title = `${percent}%`;
|
||||||
|
|
||||||
const address = document.createElement("span");
|
const ns = "http://www.w3.org/2000/svg";
|
||||||
address.className = "device__meta";
|
const svg = document.createElementNS(ns, "svg");
|
||||||
address.textContent = device.address;
|
svg.setAttribute("viewBox", "0 0 24 12");
|
||||||
|
svg.classList.add("battery__icon");
|
||||||
|
|
||||||
li.append(name, rssi, address);
|
const rect = (x, y, w, h, cls) => {
|
||||||
return li;
|
const r = document.createElementNS(ns, "rect");
|
||||||
|
r.setAttribute("x", x);
|
||||||
|
r.setAttribute("y", y);
|
||||||
|
r.setAttribute("width", w);
|
||||||
|
r.setAttribute("height", h);
|
||||||
|
if (cls) r.classList.add(cls);
|
||||||
|
return r;
|
||||||
|
};
|
||||||
|
|
||||||
|
svg.append(
|
||||||
|
rect(0, 0, 20, 12, "battery__body"),
|
||||||
|
rect(21, 3, 2, 6, "battery__terminal"),
|
||||||
|
rect(1, 1, low ? 0 : (18 * percent) / 100, 10, "battery__fill"),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (low) {
|
||||||
|
const warn = document.createElementNS(ns, "text");
|
||||||
|
warn.setAttribute("x", "10");
|
||||||
|
warn.setAttribute("y", "10");
|
||||||
|
warn.setAttribute("text-anchor", "middle");
|
||||||
|
warn.classList.add("battery__warn");
|
||||||
|
warn.textContent = "!";
|
||||||
|
svg.append(warn);
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.append(svg);
|
||||||
|
return wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderGroup(list, devices) {
|
// --- Датчики ---
|
||||||
list.replaceChildren();
|
function renderDevice(device, category) {
|
||||||
|
const li = el("li", "device");
|
||||||
|
deviceCards.set(device.id, li);
|
||||||
|
|
||||||
|
const head = el("div", "device__head");
|
||||||
|
head.append(el("span", "device__name", device.display_name || "без имени"));
|
||||||
|
head.append(el("span", "device__rssi", device.rssi != null ? `${device.rssi} dBm` : ""));
|
||||||
|
|
||||||
|
const connectBtn = el("button", "device__connect", "подключить");
|
||||||
|
connectBtn.addEventListener("click", () => connect(device.id, connectBtn, category, li));
|
||||||
|
|
||||||
|
li.append(head, el("span", "device__meta", device.address), connectBtn);
|
||||||
|
return li;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderGroup(group, devices) {
|
||||||
|
group.list.replaceChildren();
|
||||||
if (!devices.length) {
|
if (!devices.length) {
|
||||||
const empty = document.createElement("li");
|
group.list.appendChild(el("li", "device device--empty", "нет"));
|
||||||
empty.className = "device device--empty";
|
|
||||||
empty.textContent = "нет";
|
|
||||||
list.appendChild(empty);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
for (const d of devices) group.list.appendChild(renderDevice(d, group.category));
|
||||||
|
}
|
||||||
|
|
||||||
for (const device of devices) {
|
// --- Подключение (режим радио-кнопки: один на категорию) ---
|
||||||
list.appendChild(renderDevice(device));
|
function resetSelected(category) {
|
||||||
|
const prev = selected[category];
|
||||||
|
if (!prev) return;
|
||||||
|
prev.btn.textContent = "подключить";
|
||||||
|
prev.btn.classList.remove("device__connect--done");
|
||||||
|
prev.btn.disabled = false;
|
||||||
|
const battery = prev.card.querySelector(".battery");
|
||||||
|
if (battery) battery.remove();
|
||||||
|
selected[category] = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function connect(deviceId, btn, category, card) {
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = "…";
|
||||||
|
try {
|
||||||
|
await invoke("connect", { deviceId, category });
|
||||||
|
resetSelected(category);
|
||||||
|
btn.textContent = "✓";
|
||||||
|
btn.classList.add("device__connect--done");
|
||||||
|
selected[category] = { btn, card };
|
||||||
|
if (category === "trainer") startBtn.disabled = false;
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = "ошибка";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Переход на экран упражнения ---
|
||||||
|
startBtn.addEventListener("click", () => {
|
||||||
|
connectScreen.hidden = true;
|
||||||
|
workoutScreen.hidden = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Телеметрия ---
|
||||||
|
function setMetric(id, value) {
|
||||||
|
document.querySelector(`#${id}`).textContent = value;
|
||||||
|
}
|
||||||
|
|
||||||
once("devices-loaded", (event) => {
|
once("devices-loaded", (event) => {
|
||||||
const repo = event.payload;
|
const repo = event.payload;
|
||||||
renderGroup(groups.hr_sensors, repo.hr_sensors ?? []);
|
renderGroup(groups.hr_sensors, repo.hr_sensors ?? []);
|
||||||
renderGroup(groups.cad_sensors, repo.cad_sensors ?? []);
|
renderGroup(groups.cad_sensors, repo.cad_sensors ?? []);
|
||||||
renderGroup(groups.trainers, repo.trainers ?? []);
|
renderGroup(groups.trainers, repo.trainers ?? []);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
once("battery", (event) => {
|
||||||
|
const { device_id, percent } = event.payload;
|
||||||
|
const card = deviceCards.get(device_id);
|
||||||
|
if (card) card.querySelector(".device__head").append(batteryIcon(percent));
|
||||||
|
});
|
||||||
|
|
||||||
|
once("telemetry", (event) => {
|
||||||
|
const t = event.payload.telemetry;
|
||||||
|
if (t.Power) setMetric("power-value", t.Power.watts);
|
||||||
|
else if (t.Cadence) setMetric("cadence-value", Math.round(t.Cadence.rpm));
|
||||||
|
else if (t.HeartRate) setMetric("hr-value", t.HeartRate.bpm);
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Таймер ---
|
||||||
|
const timerEl = document.querySelector("#timer");
|
||||||
|
const timerBtn = document.querySelector("#timer-btn");
|
||||||
|
let running = false;
|
||||||
|
let startTs = 0;
|
||||||
|
let elapsed = 0;
|
||||||
|
let raf = 0;
|
||||||
|
|
||||||
|
function formatTime(ms) {
|
||||||
|
const sec = Math.floor(ms / 1000);
|
||||||
|
const m = Math.floor(sec / 60);
|
||||||
|
const s = sec % 60;
|
||||||
|
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tick() {
|
||||||
|
elapsed = performance.now() - startTs;
|
||||||
|
timerEl.textContent = formatTime(elapsed);
|
||||||
|
if (running) raf = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
timerBtn.addEventListener("click", async () => {
|
||||||
|
running = !running;
|
||||||
|
if (running) {
|
||||||
|
startTs = performance.now() - elapsed;
|
||||||
|
timerBtn.textContent = "стоп";
|
||||||
|
tick();
|
||||||
|
try {
|
||||||
|
await invoke("trainer_start");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
cancelAnimationFrame(raf);
|
||||||
|
timerBtn.textContent = "старт";
|
||||||
|
try {
|
||||||
|
await invoke("trainer_stop");
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Целевая мощность/каденс ---
|
||||||
|
document.querySelector("#apply-btn").addEventListener("click", async () => {
|
||||||
|
const watts = Number(document.querySelector("#power-input").value);
|
||||||
|
const rpm = Number(document.querySelector("#cadence-input").value);
|
||||||
|
try {
|
||||||
|
await invoke("set_target_power", { watts });
|
||||||
|
await invoke("set_target_cadence", { rpm });
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|||||||
Loading…
Reference in new issue