Compare commits
7 Commits
9fb6a22c84
...
9f9b4d48e2
| Author | SHA1 | Date |
|---|---|---|
|
|
9f9b4d48e2 | 1 week ago |
|
|
99c1ca2df9 | 1 week ago |
|
|
d161c74eee | 1 week ago |
|
|
ac351c9efc | 1 week ago |
|
|
f99b79feab | 1 week ago |
|
|
c7e1b3d9fb | 1 week ago |
|
|
3f48886f5c | 1 week ago |
@ -1,50 +1,223 @@
|
||||
const { once } = window.__TAURI__.event;
|
||||
const { once, listen } = 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 = {
|
||||
hr_sensors: document.querySelector("#hr-list"),
|
||||
cad_sensors: document.querySelector("#cadence-list"),
|
||||
trainers: document.querySelector("#trainer-list"),
|
||||
hr_sensors: { list: document.querySelector("#hr-list"), category: "hr" },
|
||||
cad_sensors: { list: document.querySelector("#cadence-list"), category: "cadence" },
|
||||
trainers: { list: document.querySelector("#trainer-list"), category: "trainer" },
|
||||
};
|
||||
|
||||
function renderDevice(device) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "device";
|
||||
const deviceCards = new Map(); // device_id -> li
|
||||
const selected = {}; // category -> { btn, card } подключённого датчика
|
||||
|
||||
const name = document.createElement("span");
|
||||
name.className = "device__name";
|
||||
name.textContent = device.display_name || "без имени";
|
||||
function el(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
if (text != null) node.textContent = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
const rssi = document.createElement("span");
|
||||
rssi.className = "device__rssi";
|
||||
rssi.textContent = device.rssi != null ? `${device.rssi} dBm` : "";
|
||||
// --- Батарея ---
|
||||
function batteryIcon(percent) {
|
||||
const low = percent < 20;
|
||||
const wrap = el("span", "battery" + (low ? " battery--low" : ""));
|
||||
wrap.title = `${percent}%`;
|
||||
|
||||
const address = document.createElement("span");
|
||||
address.className = "device__meta";
|
||||
address.textContent = device.address;
|
||||
const ns = "http://www.w3.org/2000/svg";
|
||||
const svg = document.createElementNS(ns, "svg");
|
||||
svg.setAttribute("viewBox", "0 0 24 12");
|
||||
svg.classList.add("battery__icon");
|
||||
|
||||
li.append(name, rssi, address);
|
||||
return li;
|
||||
const rect = (x, y, w, h, cls) => {
|
||||
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) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "device device--empty";
|
||||
empty.textContent = "нет";
|
||||
list.appendChild(empty);
|
||||
group.list.appendChild(el("li", "device device--empty", "нет"));
|
||||
return;
|
||||
}
|
||||
for (const d of devices) group.list.appendChild(renderDevice(d, group.category));
|
||||
}
|
||||
|
||||
// --- Подключение (режим радио-кнопки: один на категорию) ---
|
||||
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;
|
||||
}
|
||||
|
||||
for (const device of devices) {
|
||||
list.appendChild(renderDevice(device));
|
||||
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) => {
|
||||
const repo = event.payload;
|
||||
renderGroup(groups.hr_sensors, repo.hr_sensors ?? []);
|
||||
renderGroup(groups.cad_sensors, repo.cad_sensors ?? []);
|
||||
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);
|
||||
});
|
||||
|
||||
listen("connection", (event) => {
|
||||
const { category, status } = event.payload;
|
||||
const sel = selected[category];
|
||||
if (!sel) return;
|
||||
const { btn } = sel;
|
||||
if (status === "reconnecting") {
|
||||
btn.textContent = "переподключение…";
|
||||
btn.disabled = true;
|
||||
} else if (status === "reconnected") {
|
||||
btn.textContent = "✓";
|
||||
btn.classList.add("device__connect--done");
|
||||
btn.disabled = true;
|
||||
} else if (status === "gave_up") {
|
||||
btn.textContent = "подключить";
|
||||
btn.classList.remove("device__connect--done");
|
||||
btn.disabled = false;
|
||||
selected[category] = null;
|
||||
}
|
||||
});
|
||||
|
||||
// --- Таймер ---
|
||||
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