Chestny Znak scanner: DataMatrix -> National Catalog КБЖУ prefill

- ChZ module (frontend/js/chzn.js): import an anonymous session (exported from
  the CRPT app with tools/chzn_export.sh) and keep it alive via /authorize/refresh
  — /authorize/anonymous is Play Integrity-gated so BalDav can't mint its own.
  GS1 code normalization (reconstruct the GS separator before AI 93), check ->
  catalogData good_attrs КБЖУ parser (match by attr group + name prefix, since
  producers name the fields differently), optional nutrition/info vitamins.
- chzn_http Rust command: browser UA + returns {status,body} so 204/401/427 drive
  the session/refresh logic.
- Scanner modal (BarcodeDetector camera + photo/manual fallbacks) -> prefill the
  new-product form; CAMERA permission; settings section to import/refresh session.
- tools/chzn_export.sh: pull + validate the anonymous session from a rooted device.
This commit is contained in:
4aiman 2026-08-27 03:27:15 +03:00
commit c3ec590da5
7 changed files with 511 additions and 11 deletions

3
.gitignore vendored
View file

@ -22,3 +22,6 @@ db_backups/
# Session-local analysis harnesses, copied DBs, pulled 50Hz sensor dumps (large)
test_recovery/
# Честный знак session export (tokens — never commit)
chzn_session.json

View file

@ -25,6 +25,7 @@
| `js/db.js` | ~1550 | SQLite через Tauri IPC. Миграции, CRUD, 41 поле добавок, water_pct, reserves, sync. Хелперы маршрутов (routes CRUD, `updateRoutePoints` — сохранить высоты, `getRoutes.hasEle`). `saveSmoothedTrack` — чанковый `CASE`-апдейт lat/lon_smoothed (18 UPDATE на 4366 точек вместо 4366), `setSessionSmoothed` — откат де-джиттера |
| `js/gps.js` | ~1650 | GPS запись, ккал (нетто-модель + MET-нетто floor, накат=базовый), altitude filter + гистерезис высоты, BLE HR polling, dead reckoning. Источник истины БД при живой записи — нативный файл (`_processIncomingPoint(p, fromNative)`: watchPosition только рисует, поллинг пишет). `recoverFromFiles` — восстановление точек с устройства (матч по id/дате + `_s<id>` в имени, дедуп ±2.5с, `recalculateSession(noDelete)`, чистка импортированных файлов). `recomputeAllStats` — пересчёт статы всех треков без изменения точек. `filterByAccuracy` — отбор по точности (minAccuracyM) внутри `computeStats`+`filterNoise`: неточные фиксы исключаются из расчёта/линии (обратимо, non-destructive; null/≥999 keep), bearing_deg. `smoothTrackSG(pts,W,it)` — де-джиттер Савицкого-Голея (квадратичный МНК по окну, итеративно, концы закреплены; lat/lon→метры→обратно, все поля точки сохраняются). `parseRouteGPX(xml)` — парсер маршрутов (`rtept`/`trkpt`/`wpt`, читает `<ele>`, DOMParser). `routeElevStats(pts)` — профиль маршрута (накопл. дистанция, грейд к след. точке, набор/сброс с гистерезисом, макс-уклон; высоты сглаживаются). `config.gpxzKey` — ключ GPXZ (localStorage) |
| `js/sync.js` | ~300 | Синхронизация между устройствами (LAN, PHP сервер на роутере) |
| `js/chzn.js` | ~330 | Честный знак (CRPT). Скан DataMatrix → карточка Нацкаталога → префилл КБЖУ. `ChZ`: сессия в localStorage (`chzn_session`), `importFromText`, `refresh`/`ensureSid` (sid 48ч, refreshToken ~год), `authedPost/Get` (userId+sid в query, авто-refresh на 401), `check`, `nutritionInfo` (витамины, часто 204), `normalizeCode` (реконструкция GS 0x1D перед AI 93), `parseCard` (группа «Энергетическая ценность…», имена атрибутов разнятся у производителей), `resolve`. `/authorize/anonymous` закрыт Play Integrity — свою сессию не завести, только импорт из приложения (`tools/chzn_export.sh`) |
| `js/tilecache.js` | 178 | Оффлайн кэш тайлов карты (IndexedDB) |
| `js/bmr.js` | ~70 | BMR (Mifflin-St Jeor), РСП из БД (33 нутриента), полураспад |
| `css/styles.css` | ~2100 | Темная тема, мобильный дизайн, HR зоны, ландшафтный режим |
@ -38,7 +39,7 @@
| Файл | Строк | Назначение |
|------|-------|------------|
| `src/lib.rs` | ~750 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек) |
| `src/lib.rs` | ~790 | Миграции v1, IPC команды, seed import, update check, tile import, road map rebuild. `native_root()` — выбор корня приложения по наличию `gps_live_points.jsonl`/`gps_points_*` (на этом Tauri `app_data_dir()` = корень напрямую; старый `.parent().parent()` читал мимо, отсюда потеря фоновых точек). `http_request` (GET/POST, тело) и `chzn_http` (браузерный User-Agent + возврат `{status,body}` для 204/401/427, `http_status_as_error(false)`) — прокси HTTP мимо CORS WebView |
| `src/ble.rs` | ~470 | BLE HR: btleplug (desktop), file IPC (Android), FitPro + Standard HR |
| `src/main.rs` | 5 | Точка входа |
| `Cargo.toml` | — | tauri, tauri-plugin-sql, rusqlite, serde, btleplug, tokio, uuid |
@ -71,6 +72,7 @@
| `import_healthdiet.js` | Импорт health-diet.ru JSON в seed DB (авто-бамп версии) |
| `fetch_openfoodfacts.js` | OpenFoodFacts API |
| `fuzzy_dedup.js` | Нечёткая дедупликация |
| `chzn_export.sh` | Экспорт анонимной сессии приложения «Честный знак» с рут-девайса (adb) → `chzn_session.json` для импорта в BalDav. Читает `com.qrbonus.xml`, проверяет живым `refresh` |
### Корень

View file

@ -23,6 +23,7 @@
<script src="js/tilecache.js"></script>
<script src="js/gps.js"></script>
<script src="js/sync.js"></script>
<script src="js/chzn.js"></script>
<script src="js/main.js"></script>
</body>
</html>

278
frontend/js/chzn.js Normal file
View file

@ -0,0 +1,278 @@
// ---------------------------------------------------------------------------
// Честный знак (CRPT) integration.
//
// Scan a product's ЧЗ DataMatrix -> resolve via mobile.api.crpt.ru -> pull КБЖУ
// from the National Catalog card -> prefill the "new product" form.
//
// Auth: the CRPT anonymous-session endpoint (/authorize/anonymous) is gated by
// Google Play Integrity (bound to the ЧЗ app package) so BalDav cannot mint its
// own session. Instead a session is exported from the ЧЗ app on a rooted device
// with tools/chzn_export.sh and imported here as a JSON file. The session's sid
// (48h) is kept alive indefinitely via /authorize/refresh (NOT integrity-gated).
//
// All requests go through the Rust `chzn_http` command (returns {status,body})
// so there is no CORS and we can see 204/401/427 status codes.
// ---------------------------------------------------------------------------
let ChZ = (function () {
let LS_KEY = "chzn_session";
let DEFAULT_BASE = "https://mobile.api.crpt.ru";
let DEFAULT_EXPIRES = 172800000; // 48h fallback
function invoke(url, method, body) {
return window.__TAURI__.core.invoke("chzn_http", { url, method, body: body || null });
}
// Returns {status:int, body:string} or throws.
async function raw(url, method, body) {
let envelope = await invoke(url, method, body);
let parsed = JSON.parse(envelope);
return parsed;
}
function getSession() {
try { return JSON.parse(localStorage.getItem(LS_KEY)) || null; } catch { return null; }
}
function setSession(s) {
localStorage.setItem(LS_KEY, JSON.stringify(s));
}
function hasSession() {
let s = getSession();
return !!(s && s.userId && s.sid && s.refreshToken && s.instanceId && s.deviceId);
}
function baseUrl() {
let s = getSession();
return (s && s.baseUrl) || DEFAULT_BASE;
}
// Import an exported chzn_session.json (text). Returns the stored session.
function importFromText(text) {
let d = JSON.parse(text);
let need = ["userId", "sid", "refreshToken", "instanceId", "deviceId"];
for (let k of need) {
if (d[k] === undefined || d[k] === null || d[k] === "") {
throw new Error("в файле нет поля " + k);
}
}
let s = {
source: "chestny_znak",
baseUrl: d.baseUrl || DEFAULT_BASE,
userId: d.userId,
sid: d.sid,
refreshToken: d.refreshToken,
instanceId: d.instanceId,
deviceId: String(d.deviceId),
sidObtainedAt: d.sidObtainedAt || Date.now(),
expiresInMs: d.expiresInMs || DEFAULT_EXPIRES,
};
setSession(s);
return s;
}
function sidExpired(s) {
let exp = s.expiresInMs || DEFAULT_EXPIRES;
let got = s.sidObtainedAt || 0;
return Date.now() >= got + exp - 120000; // refresh 2 min early
}
// POST /mobile/authorize/refresh -> new sid. Updates stored session.
async function refresh() {
let s = getSession();
if (!s) throw new Error("нет сессии Честного знака");
let body = JSON.stringify({
instanceId: s.instanceId,
refresh_token: s.refreshToken,
device_id: String(s.deviceId),
client: "MOBILE",
});
let r = await raw(baseUrl() + "/mobile/authorize/refresh", "POST", body);
if (r.status !== 200) throw new Error("refresh HTTP " + r.status);
let j = JSON.parse(r.body);
if (!j.sid) throw new Error("refresh без sid: " + r.body.slice(0, 120));
s.sid = j.sid;
if (j.refreshToken) s.refreshToken = j.refreshToken;
s.expiresInMs = j.expiresIn || s.expiresInMs || DEFAULT_EXPIRES;
s.sidObtainedAt = Date.now();
setSession(s);
return s;
}
// Ensure a fresh sid before an authed call.
async function ensureSid() {
let s = getSession();
if (!s) throw new Error("нет сессии Честного знака — импортируйте chzn_session.json");
if (sidExpired(s)) return await refresh();
return s;
}
function looksLikeAuthError(r) {
if (r.status === 401 || r.status === 403) return true;
if (r.status === 400 && /"formErrors"[\s\S]*"(userId|sid)"/.test(r.body || "")) return true;
return false;
}
// Authenticated POST with userId/sid in the query string; refreshes once on auth error.
async function authedPost(path, body) {
let s = await ensureSid();
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
let r = await raw(mk(s), "POST", body);
if (looksLikeAuthError(r)) {
s = await refresh();
r = await raw(mk(s), "POST", body);
}
return r;
}
async function authedGet(path) {
let s = await ensureSid();
let mk = (sess) => baseUrl() + path + (path.includes("?") ? "&" : "?") +
"userId=" + encodeURIComponent(sess.userId) + "&sid=" + encodeURIComponent(sess.sid);
let r = await raw(mk(s), "GET");
if (looksLikeAuthError(r)) {
s = await refresh();
r = await raw(mk(s), "GET");
}
return r;
}
// POST /mobile/check with a GS1 code string. Returns parsed check JSON.
async function check(code) {
let r = await authedPost("/mobile/check", JSON.stringify({ code }));
if (r.status !== 200) throw new Error("check HTTP " + r.status + ": " + (r.body || "").slice(0, 160));
return JSON.parse(r.body);
}
// Optional bonus: GET /mobile/nutrition/info -> vitamins/minerals. Often 204.
async function nutritionInfo(checkId, gtin) {
try {
let r = await authedGet("/mobile/nutrition/info?checkId=" + encodeURIComponent(checkId) +
"&gtin=" + encodeURIComponent(gtin));
if (r.status !== 200 || !r.body) return null;
return JSON.parse(r.body);
} catch { return null; }
}
// ------------------------------------------------------------------
// GS1 code normalisation.
// A GS1 DataMatrix is 01<gtin14>21<serial><GS>93<crypto>. The GS (0x1D)
// separator between the variable-length AI 21 and AI 93 is REQUIRED for
// /mobile/check to resolve. Different decoders present it differently, so:
// - strip a leading symbology id (]d2, ]C1, {FNC1}) and leading GS chars;
// - if the string already contains a GS, keep it;
// - otherwise insert a GS before the last "93".
// ------------------------------------------------------------------
let GS = String.fromCharCode(0x1d);
function normalizeCode(rawStr) {
let s = String(rawStr || "");
s = s.replace(/^\]d2/, "").replace(/^\]C1/, "").replace(/^\]Q3/, "");
s = s.replace(/^\{FNC1\}/i, "").replace(/\{GS\}/gi, GS);
s = s.replace(new RegExp("^" + GS + "+"), "");
if (s.indexOf(GS) === -1) {
let m = s.match(/^01(\d{14})21(.+)$/);
if (m) {
let tail = m[2];
let idx = tail.lastIndexOf("93");
if (idx > 0) {
s = "01" + m[1] + "21" + tail.slice(0, idx) + GS + tail.slice(idx);
}
}
}
return s;
}
function extractGtin(code) {
let m = String(code || "").match(/^01(\d{14})/);
return m ? m[1] : null;
}
// ------------------------------------------------------------------
// Parse the National Catalog card (check.catalogData[0].good_attrs) into a
// product prefill object. КБЖУ live in the group "Энергетическая ценность,
// белки, жиры и углеводы" but attr names vary by producer
// ("Белки" vs "Белки, количество", etc.), so match by prefix within the group.
// ------------------------------------------------------------------
function num(v) {
if (v === undefined || v === null) return null;
let n = parseFloat(String(v).replace(",", ".").replace(/[^\d.\-]/g, ""));
return isFinite(n) ? n : null;
}
function parseCard(chk) {
let cd = (chk.catalogData || [])[0] || {};
let attrs = cd.good_attrs || [];
let inEnergy = (a) => (a.attr_group_name || "").indexOf("Энергетическая") === 0;
let find = (pred) => attrs.find(pred);
let kcalAttr = find(a => inEnergy(a) && /Энергетическая ценность/i.test(a.attr_name) &&
/ккал/i.test((a.attr_value_type || "") + " " + a.attr_name));
let proteinAttr = find(a => inEnergy(a) && /^Белки/i.test(a.attr_name));
let fatAttr = find(a => inEnergy(a) && /^Жиры/i.test(a.attr_name));
let carbsAttr = find(a => inEnergy(a) && /^Углеводы/i.test(a.attr_name));
let compAttr = find(a => /^Состав$/i.test(a.attr_name) || (a.attr_group_name === "Состав"));
let weightAttr = find(a => /вес нетто|Заявленный объ[её]м/i.test(a.attr_name));
let name = cd.good_name || chk.productName || "";
let prefill = {
name: name,
kcal_per_100g: kcalAttr ? num(kcalAttr.attr_value) : null,
protein_per_100g: proteinAttr ? num(proteinAttr.attr_value) : null,
fat_per_100g: fatAttr ? num(fatAttr.attr_value) : null,
carbs_per_100g: carbsAttr ? num(carbsAttr.attr_value) : null,
composition: compAttr ? String(compAttr.attr_value).trim() : null,
net_weight_g: weightAttr ? num(weightAttr.attr_value) : null,
gtin: extractGtin(chk.code) || (chk.codeResolveData || {}).gtin || null,
source: "chzn",
micros: {},
};
return prefill;
}
// Map ЧЗ vitamin/mineral names to product table columns (unit matches column).
let MICRO_MAP = {
"Кальций": "calcium_mg", "Фосфор": "phosphorus_mg", "Железо": "iron_mg",
"Магний": "magnesium_mg", "Калий": "potassium_mg", "Натрий": "sodium_mg",
"Цинк": "zinc_mg", "Медь": "copper_mg", "Селен": "selenium_mcg",
"Витамин A": "vit_a_mcg", "Витамин C": "vit_c_mg", "Витамин D": "vit_d_mcg",
"Витамин E": "vit_e_mg", "Витамин B1": "vit_b1_mg", "Витамин B2": "vit_b2_mg",
"Витамин B6": "vit_b6_mg", "Витамин B12": "vit_b12_mcg", "Фолиевая кислота": "folate_mcg",
};
function mergeMicros(prefill, nut) {
if (!nut || !Array.isArray(nut.nutrition)) return prefill;
for (let blk of nut.nutrition) {
for (let c of (blk.components || [])) {
if (c.key !== "vitaminsAndMinerals") continue;
for (let d of (c.details || [])) {
for (let row of (d.rows || [])) {
let col = MICRO_MAP[row.name];
let val = row.calculationValue;
if (col && val != null) prefill.micros[col] = val;
}
}
}
}
return prefill;
}
// Full resolve: GS1 code (raw from scanner) -> prefill object.
async function resolve(rawCode) {
let code = normalizeCode(rawCode);
let chk = await check(code);
if (chk.codeFounded === false) {
let e = new Error("код не найден в Честном знаке");
e.notFound = true;
throw e;
}
let prefill = parseCard(chk);
let cid = chk.id;
let gtin = prefill.gtin;
if (cid && gtin) {
let nut = await nutritionInfo(cid, gtin);
mergeMicros(prefill, nut);
}
return prefill;
}
return {
getSession, setSession, hasSession, importFromText, refresh, ensureSid,
check, nutritionInfo, normalizeCode, extractGtin, parseCard, resolve,
_raw: raw,
};
})();

View file

@ -1553,6 +1553,7 @@ async function renderProducts(container) {
placeholder="Название, бренд или ингредиент..." />
<button class="add-btn" id="export-all-custom" style="font-size:12px" title="Экспорт своих продуктов и рецептов">&#x21E9;</button>
<button class="add-btn" id="import-all-custom" style="font-size:12px" title="Импорт">&#x21E7;</button>
<button class="add-btn" id="scan-chzn" style="font-size:14px" title="Скан «Честный знак»">&#x25A6;</button>
<button class="add-btn" id="add-custom-product" title="Свой продукт">+</button>
</div>
<div id="prod-count" class="prod-count"></div>
@ -1724,6 +1725,7 @@ async function renderProducts(container) {
// Add custom product button
let addCustomBtn = container.querySelector("#add-custom-product");
if (addCustomBtn) addCustomBtn.onclick = () => showAddProductModal(() => doSearch(true));
container.querySelector("#scan-chzn")?.addEventListener("click", () => showChznScanModal(() => doSearch(true)));
// Export all custom data (manual products + recipes + supplements)
container.querySelector("#export-all-custom")?.addEventListener("click", async () => {
@ -1768,41 +1770,55 @@ async function renderProducts(container) {
});
}
function showAddProductModal(onSaved) {
function showAddProductModal(onSaved, prefill) {
let pf = prefill || {};
let micros = pf.micros || {};
let v = (x) => (x === null || x === undefined || x === "") ? "" : x;
let microHtml = PRODUCT_MICRO_FIELDS.map(f => {
let raw = micros[f.key];
if (raw != null && f.mult) raw = Math.round(raw / f.mult * 1000) / 1000;
return `<div class="form-group micro-field">
<label>${f.label}</label>
<input id="np-${f.key}" type="number" inputmode="decimal" step="any" placeholder="—" />
<input id="np-${f.key}" type="number" inputmode="decimal" step="any" value="${v(raw)}" placeholder="—" />
</div>`;
}).join("");
// Честный знак source: header note + composition/weight hint (not stored as columns).
let chznNote = pf.source === "chzn"
? `<div style="background:var(--surface,#1c1c1e);border:1px solid var(--border);border-radius:6px;padding:8px 10px;margin-bottom:8px;font-size:12px;color:var(--muted)">
Данные из Честного знака${pf.gtin ? " · GTIN " + esc(pf.gtin) : ""}${pf.net_weight_g ? " · нетто " + esc(pf.net_weight_g) + " г" : ""}
${pf.composition ? `<div style="margin-top:4px"><b>Состав:</b> ${esc(pf.composition)}</div>` : ""}
</div>`
: "";
showModal(`
<h3>Новый продукт</h3>
${chznNote}
<div class="form-group">
<label>Название</label>
<input id="np-name" type="text" placeholder="Тесто для чебуреков" />
<input id="np-name" type="text" value="${esc(v(pf.name))}" placeholder="Тесто для чебуреков" />
</div>
<div style="display:flex;gap:8px">
<div class="form-group" style="flex:1">
<label>ккал/100г</label>
<input id="np-kcal" type="number" inputmode="decimal" placeholder="260" />
<input id="np-kcal" type="number" inputmode="decimal" value="${v(pf.kcal_per_100g)}" placeholder="260" />
</div>
<div class="form-group" style="flex:1">
<label>Б</label>
<input id="np-protein" type="number" inputmode="decimal" step="0.1" placeholder="7" />
<input id="np-protein" type="number" inputmode="decimal" step="0.1" value="${v(pf.protein_per_100g)}" placeholder="7" />
</div>
<div class="form-group" style="flex:1">
<label>Ж</label>
<input id="np-fat" type="number" inputmode="decimal" step="0.1" placeholder="4" />
<input id="np-fat" type="number" inputmode="decimal" step="0.1" value="${v(pf.fat_per_100g)}" placeholder="4" />
</div>
<div class="form-group" style="flex:1">
<label>У</label>
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" placeholder="48" />
<input id="np-carbs" type="number" inputmode="decimal" step="0.1" value="${v(pf.carbs_per_100g)}" placeholder="48" />
</div>
</div>
<div class="form-group" style="margin-top:8px">
<label>Содержание воды (%) <small style="color:var(--muted)">87=молоко, 99=чай, 0=твёрдое</small></label>
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" placeholder="" />
<input id="np-water" type="number" inputmode="numeric" step="1" min="0" max="100" value="${v(pf.water_pct)}" placeholder="" />
</div>
<p style="color:var(--muted);font-size:12px;margin:8px 0 4px">Нутриенты на 100г (опционально):</p>
<div class="micro-grid">${microHtml}</div>`,
@ -1816,7 +1832,7 @@ function showAddProductModal(onSaved) {
fat_per_100g: parseFloat(overlay.querySelector("#np-fat").value) || null,
carbs_per_100g: parseFloat(overlay.querySelector("#np-carbs").value) || null,
water_pct: parseFloat(overlay.querySelector("#np-water").value) || null,
source: "manual",
source: pf.source === "chzn" ? "chzn" : "manual",
};
for (let f of PRODUCT_MICRO_FIELDS) {
let val = parseFloat(overlay.querySelector(`#np-${f.key}`).value) || null;
@ -1828,6 +1844,112 @@ function showAddProductModal(onSaved) {
});
}
// Честный знак scanner: camera (BarcodeDetector) with photo/manual fallbacks.
// On a decoded DataMatrix -> ChZ.resolve -> prefill the new-product modal.
function showChznScanModal(onSaved) {
if (!ChZ.hasSession()) {
appAlert("Сессия Честного знака не импортирована.\nНастройки → Честный знак → Импорт сессии (файл chzn_session.json из tools/chzn_export.sh).");
return;
}
let hasBD = ("BarcodeDetector" in window);
let detector = hasBD ? new BarcodeDetector({ formats: ["data_matrix", "qr_code"] }) : null;
let ov = document.createElement("div");
ov.className = "modal-overlay";
ov.innerHTML = `
<div class="modal">
<div class="modal-header"><button class="modal-back" id="cz-close">&#8592;</button></div>
<div class="modal-body">
<h3>Скан «Честный знак»</h3>
<div style="position:relative;background:#000;border-radius:8px;overflow:hidden;aspect-ratio:1/1;max-height:60vh">
<video id="cz-video" playsinline muted style="width:100%;height:100%;object-fit:cover"></video>
<div style="position:absolute;inset:15%;border:2px solid rgba(255,255,255,.7);border-radius:8px;pointer-events:none"></div>
</div>
<div id="cz-status" style="margin:10px 0;font-size:13px;color:var(--muted);min-height:18px">Запуск камеры...</div>
<div style="display:flex;gap:8px">
<button id="cz-photo" class="btn-secondary" style="flex:1;font-size:13px">Из фото</button>
<button id="cz-manual" class="btn-secondary" style="flex:1;font-size:13px">Ввести код</button>
</div>
</div>
</div>`;
document.body.appendChild(ov);
let stream = null, scanning = false, busy = false;
let base = winStack.length;
let statusEl = ov.querySelector("#cz-status");
let video = ov.querySelector("#cz-video");
function cleanup() {
scanning = false;
if (stream) { stream.getTracks().forEach(t => t.stop()); stream = null; }
}
winPush("chzn-scan", () => { cleanup(); ov.remove(); });
function close() {
let count = winStack.length - base;
for (let i = 0; i < count; i++) winStack.pop().close();
_skipPops = count;
if (count > 0) history.go(-count);
}
async function onDecoded(rawVal) {
if (busy) return;
busy = true; scanning = false;
statusEl.textContent = "Код распознан, запрашиваю КБЖУ...";
try {
let prefill = await ChZ.resolve(rawVal);
cleanup();
close();
showAddProductModal(onSaved, prefill);
} catch (e) {
busy = false;
statusEl.textContent = (e.notFound ? "Код не найден в Честном знаке. " : "Ошибка: " + e.message + ". ") + "Наведите ещё раз.";
if (detector && stream) { scanning = true; requestAnimationFrame(loop); }
}
}
async function loop() {
if (busy || !scanning) return;
try {
let codes = await detector.detect(video);
if (codes && codes.length) { scanning = false; onDecoded(codes[0].rawValue); return; }
} catch (e) { /* transient decode error, keep scanning */ }
requestAnimationFrame(loop);
}
async function startCamera() {
if (!detector) { statusEl.textContent = "Сканер недоступен в этом WebView. Выберите фото кода или введите вручную."; return; }
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: "environment" } } });
video.srcObject = stream;
await video.play();
scanning = true;
statusEl.textContent = "Наведите на код Честного знака...";
loop();
} catch (e) {
statusEl.textContent = "Камера недоступна (" + (e.name || e.message) + "). Выберите фото или введите код.";
}
}
ov.querySelector("#cz-photo").onclick = () => {
pickFile("image/*", async (buf) => {
if (!detector) { statusEl.textContent = "Сканер недоступен — введите код вручную."; return; }
try {
let bmp = await createImageBitmap(new Blob([buf]));
let codes = await detector.detect(bmp);
if (codes && codes.length) onDecoded(codes[0].rawValue);
else statusEl.textContent = "Код на фото не распознан. Попробуйте более резкое фото.";
} catch (e) { statusEl.textContent = "Ошибка распознавания: " + e.message; }
}, true);
};
ov.querySelector("#cz-manual").onclick = async () => {
let code = await appPrompt("Содержимое кода DataMatrix (01…21…93…):", "");
if (code) onDecoded(code.trim());
};
ov.querySelector("#cz-close").onclick = () => close();
startCamera();
}
function showEditProductModal(p, onSaved) {
let microHtml = PRODUCT_MICRO_FIELDS.map(f => {
let val = p[f.key] ?? "";
@ -5706,6 +5828,29 @@ async function renderSettings(container) {
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Честный знак</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Сессия</div>
<div class="settings-item-desc" id="s-chzn-status">${(() => {
let s = ChZ.getSession();
if (!s) return "Не импортирована";
let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0));
return `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`;
})()}</div>
</div>
<button class="btn-secondary" id="s-chzn-import" style="padding:6px 12px;font-size:13px;">Импорт</button>
</div>
<div class="settings-item">
<div class="settings-item-left">
<div class="settings-item-title">Обновить сессию</div>
<div class="settings-item-desc">Продлить sid через refresh (проверка связи)</div>
</div>
<button class="btn-secondary" id="s-chzn-refresh" style="padding:6px 12px;font-size:13px;">Обновить</button>
</div>
</div>
<div class="settings-section">
<div class="settings-section-title">Карты</div>
<div class="settings-item">
@ -6019,6 +6164,40 @@ async function renderSettings(container) {
showToast(`Удалено ${removed} продуктов`);
};
// Честный знак: import session JSON exported by tools/chzn_export.sh.
function chznRefreshStatus() {
let el = container.querySelector("#s-chzn-status");
if (!el) return;
let s = ChZ.getSession();
if (!s) { el.textContent = "Не импортирована"; return; }
let until = new Date((s.sidObtainedAt || 0) + (s.expiresInMs || 0));
el.textContent = `userId ${s.userId} · sid до ${until.toLocaleString("ru-RU")}`;
}
container.querySelector("#s-chzn-import")?.addEventListener("click", () => {
pickFile(".json,application/json", async (text) => {
try {
ChZ.importFromText(text);
showToast("Сессия Честного знака импортирована");
chznRefreshStatus();
} catch (e) {
showToast("Ошибка импорта: " + e.message);
}
});
});
container.querySelector("#s-chzn-refresh")?.addEventListener("click", async () => {
let btn = container.querySelector("#s-chzn-refresh");
if (!ChZ.hasSession()) { showToast("Сначала импортируйте сессию"); return; }
btn.disabled = true; btn.textContent = "...";
try {
await ChZ.refresh();
showToast("Сессия обновлена");
chznRefreshStatus();
} catch (e) {
showToast("Refresh не удался: " + e.message);
}
btn.disabled = false; btn.textContent = "Обновить";
});
// Import tiles from a file the user picks on the device (SAF picker on Android).
container.querySelector("#s-import-tiles").onclick = async () => {
let btn = container.querySelector("#s-import-tiles");

View file

@ -11,6 +11,8 @@
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" />

View file

@ -84,6 +84,41 @@ async fn http_request(url: String, method: String, body: Option<String>) -> Resu
.map_err(|e| e.to_string())?
}
// HTTP proxy for the "Честный знак" API. Unlike http_request it (a) sends a
// browser-ish User-Agent (the CRPT gateway is picky) and (b) returns the HTTP
// status alongside the body as a JSON envelope {"status":N,"body":"..."} so the
// caller can drive session logic: 204 = no nutrition data, 401/400 = refresh sid,
// 427 = version/attestation gate. http_status_as_error(false) keeps 4xx/5xx as
// normal responses instead of errors so we can read their status/body.
#[tauri::command]
async fn chzn_http(url: String, method: String, body: Option<String>) -> Result<String, String> {
tauri::async_runtime::spawn_blocking(move || -> Result<String, String> {
let agent: ureq::Agent = ureq::Agent::config_builder()
.timeout_connect(Some(std::time::Duration::from_secs(5)))
.timeout_global(Some(std::time::Duration::from_secs(15)))
.http_status_as_error(false)
.build()
.into();
let ua = "Mozilla/5.0 (Linux; Android 15)";
let mut resp = match method.to_uppercase().as_str() {
"POST" => {
let b = body.unwrap_or_default();
agent.post(&url)
.header("Content-Type", "application/json")
.header("User-Agent", ua)
.send(b.as_bytes())
.map_err(|e| e.to_string())?
}
_ => agent.get(&url).header("User-Agent", ua).call().map_err(|e| e.to_string())?,
};
let status = resp.status().as_u16();
let text = resp.body_mut().read_to_string().unwrap_or_default();
Ok(serde_json::json!({ "status": status, "body": text }).to_string())
})
.await
.map_err(|e| e.to_string())?
}
// Get the active points file name from gps_live_points.jsonl pointer
#[tauri::command]
fn get_points_filename(app: tauri::AppHandle) -> Result<String, String> {
@ -869,7 +904,7 @@ pub fn run() {
.add_migrations("sqlite:baldav.db", migrations)
.build(),
)
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
.invoke_handler(tauri::generate_handler![get_safe_insets, import_seed_products, import_tiles, download_file, get_points_filename, delete_points_file, gps_set_session_id, list_points_files, read_points_file, pick_file_open, get_picked_file, debug_native_paths, rebuild_roads, get_road_points, get_app_info, gps_service_command, gps_service_read_points, save_file, get_update_info, http_request, chzn_http, ble::ble_scan, ble::ble_scan_results, ble::ble_connect, ble::ble_disconnect, ble::ble_get_hr, ble::ble_is_connected, ble::ble_check_bluetooth, ble::ble_save_device, ble::ble_clear_hr])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}