Files
site/WESP_REL/config.py
T
2026-07-17 12:57:18 +03:00

1226 lines
63 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Настройки приложения WESP (весы, веб-интерфейс, синхронизация).
• Этот файл — «список переключателей» программы. Часть значений берётся отсюда
как запасной вариант, а главное — из переменных окружения (имена вида WESP_…).
• Переменная окружения — это текст, который вы задаёте системе ДО запуска программы
(в терминале, в файле .env, в настройках службы). Так безопаснее хранить пароли.
Как выбрать режим работы (разработка / боевая / тесты):
1) Задайте WESP_CONFIG одним из слов: development, production, testing
(можно короче: dev, prod, test).
2) Запустите приложение (например: python run.py).
Пример для Linux или macOS (в терминале, перед запуском):
export WESP_CONFIG=development
export WESP_SECRET_KEY='придумайте-длинную-случайную-строку'
python run.py
Важно:
• Пароли и токены лучше не вписывать в код — только через переменные окружения.
• Ниже у каждой группы настроек в комментариях указано «что это» и «как может
выглядеть значение».
"""
import json
import os
import tempfile
from datetime import timedelta
from pathlib import Path
from typing import Any, Dict, MutableMapping, Optional, Tuple
def _env_bool_with_default(name: str, default: bool) -> bool:
"""Переменная окружения перекрывает значение по умолчанию; пусто — default."""
raw = os.getenv(name)
if raw is None or not str(raw).strip():
return default
s = str(raw).strip().lower()
if s in ("0", "false", "no", "off"):
return False
if s in ("1", "true", "yes", "on"):
return True
return default
class Config:
"""
Базовые настройки. Наследуются режимами Development / Production / Testing.
Все имена переменных окружения начинаются с WESP_ — так их проще искать в системе.
"""
# -------------------------------------------------------------------------
# Веб-приложение и безопасность
# -------------------------------------------------------------------------
# SECRET_KEY — «секретный ключ» Flask: им подписываются сессии (вход в админку).
# Если оставить заводское значение в бою — злоумышленник может подделать сессию.
# Пример в терминале: export WESP_SECRET_KEY='очень-длинная-случайная-строка'
SECRET_KEY = os.getenv("WESP_SECRET_KEY", "change-me-in-production")
# -------------------------------------------------------------------------
# Папка проекта и базы данных
# -------------------------------------------------------------------------
# BASE_DIR — абсолютный путь к папке, где лежит этот config.py (корень проекта).
# Обычно трогать не нужно: считается автоматически.
BASE_DIR = os.path.abspath(os.path.dirname(__file__))
# Каталог данных приложения (SQLite, рядом с логами в data/logs).
DATA_DIR = os.path.join(BASE_DIR, "data")
# Часовой пояс для календарных границ и «сегодня» в UI (склад, графики).
# В БД и синхронизации используется UTC; здесь — только отображение/календарь.
# Пример: export WESP_DISPLAY_TIMEZONE=Asia/Yekaterinburg
WESP_DISPLAY_TIMEZONE = os.getenv("WESP_DISPLAY_TIMEZONE", "Europe/Moscow")
# Киоск: не вызывать интерактивное хранилище паролей из Python (keyring → Secret Service).
# export WESP_KIOSK_HEADLESS=1 — см. wesp_runtime_env.apply_kiosk_headless_env
# Для Chromium отдельно: флаг --password-store=basic в командной строке браузера.
# Основная база: рецепты, оборудование, метаданные синхронизации.
# Формат адреса (URI) зависит от СУБД. По умолчанию — один файл SQLite в data/.
# Примеры:
# sqlite:////полный/путь/recipes.db — файл SQLite (четыре слэша после двоеточия)
# postgresql://user:pass@host:5432/db — PostgreSQL (если настроите)
SQLALCHEMY_DATABASE_URI = os.getenv(
"WESP_RECIPES_DB_URI",
f"sqlite:///{os.path.join(DATA_DIR, 'recipes.db')}",
)
# Вторая база только для отчётов (отдельный файл/сервер).
# Ключ "reports" — внутреннее имя подключения в коде.
SQLALCHEMY_BINDS = {
"reports": os.getenv(
"WESP_REPORTS_DB_URI",
f"sqlite:///{os.path.join(DATA_DIR, 'reports.db')}",
),
}
# Отключаем устаревшее отслеживание изменений SQLAlchemy (меньше лишней работы).
SQLALCHEMY_TRACK_MODIFICATIONS = False
# -------------------------------------------------------------------------
# SQLite: как писать на диск (скорость и надёжность)
# -------------------------------------------------------------------------
# WAL — режим журнала: удобно, когда программа часто читает и пишет одновременно.
SQLITE_JOURNAL_MODE = "WAL"
# NORMAL — разумный баланс между скоростью и защитой от сбоя питания.
SQLITE_SYNCHRONOUS = "NORMAL"
# Во время Alembic на SD: паузы и PRAGMA — app/services/migration_pace.py (WESP_MIGRATION_*).
# Отложенный mDNS/sync/requeue — app/services/startup_defer.py (WESP_STARTUP_*_DELAY_SEC).
# Параллельные запросы (sync pull, админка): sqlite3 ждёт снятия блокировки, не падает сразу.
# Мс — PRAGMA busy_timeout на каждом подключении; сек — параметр sqlite3.connect(timeout).
SQLITE_BUSY_TIMEOUT_MS = int(os.getenv("WESP_SQLITE_BUSY_TIMEOUT_MS", "30000"))
SQLITE_CONNECT_TIMEOUT_SEC = float(os.getenv("WESP_SQLITE_CONNECT_TIMEOUT_SEC", "30"))
# Только для SQLite в основном URI (см. SQLALCHEMY_DATABASE_URI выше).
SQLALCHEMY_ENGINE_OPTIONS = (
{"connect_args": {"timeout": SQLITE_CONNECT_TIMEOUT_SEC}}
if str(SQLALCHEMY_DATABASE_URI).lower().startswith("sqlite:")
else {}
)
# -------------------------------------------------------------------------
# Весы и чип HX711: как часто мерить и как сглаживать цифры
# -------------------------------------------------------------------------
# READ_INTERVAL — пауза между опросами датчика, в секундах.
# 0.05 ≈ 20 раз в секунду. Больше число — реже опрос, меньше нагрузка.
READ_INTERVAL = float(os.getenv("WESP_READ_INTERVAL", "0.50"))
# За один проход берём несколько «сырых» измерений и дальше усредняем/фильтруем.
SAMPLES_PER_READ = int(os.getenv("WESP_SAMPLES_PER_READ", "3"))
# Сколько последних измерений держим в памяти для фильтров (буфер истории).
SAMPLE_HISTORY_SIZE = int(os.getenv("WESP_SAMPLE_HISTORY_SIZE", "200"))
# MEDIAN_WINDOW / AVG_WINDOW — размеры окон для медианы и среднего (штук измерений).
MEDIAN_WINDOW = int(os.getenv("WESP_MEDIAN_WINDOW", "6"))
AVG_WINDOW = int(os.getenv("WESP_AVG_WINDOW", "4"))
# Веса для взвешенного среднего: три числа через запятую, сумма обычно 1.0.
# Пример: export WESP_FILTER_WEIGHTS='0.2,0.3,0.5'
WEIGHTS = tuple(
float(x) for x in os.getenv("WESP_FILTER_WEIGHTS", "0.2,0.3,0.5").split(",")
)
# SMOOTHING_FACTOR — насколько сильно «тянуть» показание к предыдущему (0…1).
# Ближе к 1 — плавнее, но медленнее реакция на реальное изменение веса.
SMOOTHING_FACTOR = float(os.getenv("WESP_SMOOTHING_FACTOR", "0.3"))
# Округление веса до шага в килограммах, затем до целых (страница весов / API).
# 1 — шаг 1 кг; для шага 5 кг: export WESP_ROUND_TO_STEP=5
ROUND_TO_STEP = float(os.getenv("WESP_ROUND_TO_STEP", "1"))
# -------------------------------------------------------------------------
# Железо Raspberry Pi: пины и режим «без весов»
# -------------------------------------------------------------------------
# SIMULATION_MODE — устаревший env для pytest; в рантайме режим в hardware_settings (админка).
# Пример: export WESP_SIMULATION_MODE=1 — только начальное значение для новой строки БД.
SIMULATION_MODE = os.getenv("WESP_SIMULATION_MODE", "0") in {"1", "true", "yes"}
# Номера пинов в нумерации BCM (как в библиотеке RPi.GPIO), не «физические» дырки на плате.
# Светодиод статуса.
GPIO_LED_PIN = int(os.getenv("WESP_GPIO_LED_PIN", "18"))
# HX711: линии данных и тактовый сигнал (DOUT и PD_SCK).
HX711_DOUT_PIN = int(os.getenv("WESP_HX711_DOUT_PIN", "2"))
HX711_PD_SCK_PIN = int(os.getenv("WESP_HX711_PD_SCK_PIN", "3"))
# При обнулении (таре) усредняем столько последних «сырых» отсчётов.
TARE_RAW_WINDOW = int(os.getenv("WESP_TARE_RAW_WINDOW", "2"))
# -------------------------------------------------------------------------
# Публичный доступ с весов / киоска (без обычного логина в браузере)
# -------------------------------------------------------------------------
# CALIBRATION_PUBLIC — разрешить калибровку и API по секретному токену устройства,
# без сессии администратора. Вкл: 1 / true / yes.
CALIBRATION_PUBLIC = os.getenv("WESP_CALIBRATION_PUBLIC", "0") in {
"1",
"true",
"yes",
}
# Секретный «пароль устройства», который передаётся в заголовке HTTP (не в URL).
# Пример: export WESP_DEVICE_TOKEN='мой-секрет-123'
DEVICE_TOKEN = os.getenv("WESP_DEVICE_TOKEN", "")
# Имя заголовка, в котором клиент присылает токен. По умолчанию X-Device-Token.
# Пример: export WESP_DEVICE_TOKEN_HEADER='X-Device-Token'
DEVICE_TOKEN_HEADER = os.getenv("WESP_DEVICE_TOKEN_HEADER", "X-Device-Token")
# Сколько секунд живёт одноразовый токен для сопряжения по QR (потом нужен новый).
# Пример: export WESP_KIOSK_PAIR_TOKEN_TTL_SECONDS=300 (5 минут)
KIOSK_PAIR_TOKEN_TTL_SECONDS = int(
os.getenv("WESP_KIOSK_PAIR_TOKEN_TTL_SECONDS", "300")
)
# Сколько дней браузер помнит привязанный терминал (cookie).
KIOSK_AUTH_COOKIE_DAYS = int(
os.getenv("WESP_KIOSK_AUTH_COOKIE_DAYS", "18250")
)
# Если включено — API киоска доступны только устройствам, которые уже сопряжены.
# Выключить (0/false/no) — для отладки; в бою обычно оставляют включённым.
KIOSK_ENFORCE_PAIRED_ONLY = os.getenv("WESP_KIOSK_ENFORCE_PAIRED_ONLY", "1") in {
"1",
"true",
"yes",
}
# Если сайт открывают не с того адреса (прокси, другой порт), можно явно указать
# базовый URL для ссылок и QR. Пустая строка — программа сама угадывает из запроса.
# Пример: export WESP_KIOSK_PUBLIC_BASE_URL='https://vesy.example.com'
KIOSK_PUBLIC_BASE_URL = os.getenv(
"WESP_KIOSK_PUBLIC_BASE_URL", ""
).strip()
# -------------------------------------------------------------------------
# Синхронизация данных на сервере (Flask, маршруты /api/sync/…)
# -------------------------------------------------------------------------
# Сколько запросов синхронизации обрабатывать одновременно (защита от перегруза).
SYNC_MAX_CONCURRENT = int(os.getenv("WESP_SYNC_MAX_CONCURRENT", "4"))
# Второй POST /pull с тем же node_id ждёт lock (первый может долго делать снапшот), сек.
SYNC_PULL_CLIENT_LOCK_WAIT_SEC = float(os.getenv("WESP_SYNC_PULL_CLIENT_LOCK_WAIT_SEC", "180"))
# То же при заголовке X-WESP-Initial-Sync: 1 (первая синхронизация узла — дольше удержание снапшота).
SYNC_PULL_INITIAL_LOCK_WAIT_SEC = float(
os.getenv("WESP_SYNC_PULL_INITIAL_LOCK_WAIT_SEC", "600")
)
SYNC_PULL_RETRY_AFTER_SEC = int(os.getenv("WESP_SYNC_PULL_RETRY_AFTER_SEC", "2"))
SYNC_PULL_BOOTSTRAP_MODELS_PER_PULL = int(
os.getenv("WESP_SYNC_PULL_BOOTSTRAP_MODELS_PER_PULL", "1")
)
SYNC_PULL_SNAPSHOT_MODELS_PER_PULL = int(
os.getenv("WESP_SYNC_PULL_SNAPSHOT_MODELS_PER_PULL", "1")
)
SYNC_PULL_SNAPSHOT_MODELS_PER_PULL_INITIAL = int(
os.getenv("WESP_SYNC_SNAPSHOT_MODELS_PER_PULL_INITIAL", "5")
)
SYNC_PULL_BOOTSTRAP_MODELS_PER_PULL_INITIAL = int(
os.getenv("WESP_SYNC_BOOTSTRAP_MODELS_PER_PULL_INITIAL", "5")
)
# Если задача «зависла», через столько минут её можно снова поставить в очередь.
SYNC_REQUEUE_TIMEOUT_MINUTES = int(os.getenv("WESP_SYNC_REQUEUE_TIMEOUT_MINUTES", "5"))
# Как часто фоново проверять очередь (секунды).
SYNC_REQUEUE_INTERVAL_SEC = int(os.getenv("WESP_SYNC_REQUEUE_INTERVAL_SEC", "30"))
# Минимум задач в снимке, чтобы запускать некоторые сценарии (порог «пакета»).
SYNC_MIN_SNAPSHOT_TASKS = int(os.getenv("WESP_SYNC_MIN_SNAPSHOT_TASKS", "1"))
# После enqueue_personal_snapshot commit идёт под нагрузкой SQLite — больше попыток, короче пауза.
SYNC_SNAPSHOT_COMMIT_ATTEMPTS = int(os.getenv("WESP_SYNC_SNAPSHOT_COMMIT_ATTEMPTS", "25"))
SYNC_SNAPSHOT_COMMIT_BASE_DELAY = float(
os.getenv("WESP_SYNC_SNAPSHOT_COMMIT_BASE_DELAY", "0.15")
)
# Включить фоновый поток, который переставляет зависшие задачи (1/true/yes).
SYNC_BACKGROUND_REQUEUE = os.getenv("WESP_SYNC_BACKGROUND_REQUEUE", "1") in {
"1",
"true",
"yes",
}
# Миграции Alembic в фоне: HTTP сразу, /starting + /api/startup/status до готовности.
WESP_BACKGROUND_STARTUP = os.getenv("WESP_BACKGROUND_STARTUP", "1") in {
"1",
"true",
"yes",
}
# Waitress (systemd / scripts/wesp_waitress_serve.sh): потоки обработки HTTP.
# У waitress по умолчанию 4 — при sync + UI + /stream_weight очередь растёт (waitress.queue depth).
# Пример: export WESP_WAITRESS_THREADS=24
WAITRESS_THREADS = max(4, int(os.getenv("WESP_WAITRESS_THREADS", "20")))
# -------------------------------------------------------------------------
# Полевой клиент синхронизации (скрипт sync_client.py и т.п.)
# -------------------------------------------------------------------------
# Постоянные настройки — через WESP_* ниже; то, что меняется из интерфейса,
# хранится в файле sync_client_state.json (или путь из WESP_SYNC_CLIENT_STATE_PATH).
# Роль узла: client (полевая точка — pull в локальную БД) или server (центральный узел).
# Без WESP_SYNC_ROLE по умолчанию server: для киоска задайте WESP_SYNC_ROLE=client или role в sync_client_state.json.
SYNC_CLIENT_ROLE = os.getenv("WESP_SYNC_ROLE", "server").strip() or "client"
# Запускать sync_client (pull/push) в том же процессе, что и Flask. При role≠client поток не стартует.
# Отключение: export WESP_SYNC_CLIENT_AUTOSTART=0
SYNC_CLIENT_AUTOSTART = os.getenv("WESP_SYNC_CLIENT_AUTOSTART", "1").strip().lower() not in (
"0",
"false",
"no",
"off",
)
# Адрес сервера синхронизации (схема http/https — часть строки), только запасной вариант.
# Приоритет при разрешении URL: WESP_SYNC_SERVER_URL → server_url в sync_client_state.json → это поле.
# Если в JSON ключ server_url задан (в т.ч. пустой), Config сюда не подмешивается — см. resolve_sync_server_url().
# Локально: export WESP_SYNC_SERVER_URL=http://127.0.0.1
SYNC_CLIENT_SERVER_URL = os.getenv("WESP_SYNC_SERVER_URL", "").strip()
# Человекочитаемое имя этого клиента в списках на сервере.
SYNC_CLIENT_NAME = os.getenv("WESP_SYNC_CLIENT_NAME", "Sync Client").strip() or "Sync Client"
# Версии протокола sync (semver-строки): только из кода/env, не из sync_client_state.json.
SYNC_CLIENT_VERSION = os.getenv("WESP_SYNC_CLIENT_VERSION", "2.0.0").strip() or "2.0.0"
# Команда перезапуска после OTA (post_update.sh или systemctl restart).
UPDATE_RESTART_CMD = os.getenv("WESP_UPDATE_RESTART_CMD", "").strip()
# systemd unit на Pi (например myscript); post_update.sh и дефолтный restart админки.
WESP_SYSTEMD_UNIT = os.getenv("WESP_SYSTEMD_UNIT", "myscript").strip()
WESP_ENV_FILE = os.getenv("WESP_ENV_FILE", "/etc/wesp/wesp.env").strip()
SYNC_CLIENT_MIN_SERVER_VERSION = os.getenv("WESP_SYNC_CLIENT_MIN_SERVER_VERSION", "2.0.0").strip() or "2.0.0"
SYNC_CLIENT_MAX_SERVER_VERSION = os.getenv("WESP_SYNC_CLIENT_MAX_SERVER_VERSION", "2.0.0").strip() or "2.0.0"
# Как часто опрашивать сервер (секунды) и таймаут одного HTTP-запроса (секунды).
SYNC_CLIENT_POLL_INTERVAL_SEC = int(os.getenv("WESP_SYNC_CLIENT_POLL_INTERVAL_SEC", "14"))
# До завершения первой синхронизации — чаще (меньше секунд); по умолчанию = steady.
SYNC_CLIENT_POLL_INTERVAL_INITIAL_SEC = int(
os.getenv(
"WESP_SYNC_CLIENT_POLL_INTERVAL_INITIAL_SEC",
os.getenv("WESP_SYNC_CLIENT_POLL_INTERVAL_SEC", "14"),
)
)
SYNC_CLIENT_REQUEST_TIMEOUT_SEC = int(os.getenv("WESP_SYNC_CLIENT_REQUEST_TIMEOUT_SEC", "7"))
# Размер пачки pull: при первой синхронизации — умеренный, чтобы не раздувать JSON.
SYNC_CLIENT_DEFAULT_PULL_LIMIT = int(os.getenv("WESP_SYNC_CLIENT_DEFAULT_PULL_LIMIT", "100"))
SYNC_CLIENT_PULL_LIMIT_INITIAL = int(os.getenv("WESP_SYNC_CLIENT_PULL_LIMIT_INITIAL", "50"))
# 0 = без чанков; иначе вызывать on_remote_changes по chunk строк за раз (yield между чанками).
SYNC_CLIENT_APPLY_CHUNK_SIZE = int(os.getenv("WESP_SYNC_CLIENT_APPLY_CHUNK_SIZE", "0"))
# Ретраи POST /confirm при временных 5xx/сети (поверх pull_network внутри одного запроса).
SYNC_CLIENT_CONFIRM_NETWORK_RETRIES = int(os.getenv("WESP_SYNC_CLIENT_CONFIRM_NETWORK_RETRIES", "5"))
# Повтор POST /pull при 409/429, пока на сервере занят per-client lock или слоты.
SYNC_CLIENT_PULL_BUSY_RETRIES = int(os.getenv("WESP_SYNC_CLIENT_PULL_BUSY_RETRIES", "12"))
SYNC_CLIENT_PULL_BUSY_DELAY_SEC = float(
os.getenv("WESP_SYNC_CLIENT_PULL_BUSY_DELAY_SEC", "0.05")
)
SYNC_CLIENT_PULL_NETWORK_RETRIES = int(
os.getenv("WESP_SYNC_CLIENT_PULL_NETWORK_RETRIES", "4")
)
SYNC_CLIENT_PULL_NETWORK_BASE_DELAY_SEC = float(
os.getenv("WESP_SYNC_CLIENT_PULL_NETWORK_BASE_DELAY_SEC", "0.8")
)
SYNC_CLIENT_PULL_NETWORK_MAX_DELAY_SEC = float(
os.getenv("WESP_SYNC_CLIENT_PULL_NETWORK_MAX_DELAY_SEC", "8.0")
)
# Таймаут одного POST /pull (сервер может ждать lock и строить большой ответ) — секунды.
SYNC_CLIENT_PULL_HTTP_TIMEOUT_SEC = int(
os.getenv("WESP_SYNC_CLIENT_PULL_HTTP_TIMEOUT_SEC", "300")
)
# POST /pull до завершения первой синхронизации (пока initial_sync_active на сервере).
SYNC_CLIENT_INITIAL_PULL_HTTP_TIMEOUT_SEC = int(
os.getenv("WESP_SYNC_CLIENT_INITIAL_PULL_HTTP_TIMEOUT_SEC", "600")
)
# Сжатие тела запросов (gzip и т.д.): вкл/выкл и параметры.
SYNC_CLIENT_COMPRESSION_ENABLED = os.getenv("WESP_SYNC_CLIENT_COMPRESSION_ENABLED", "1") in {
"1",
"true",
"yes",
}
SYNC_CLIENT_COMPRESSION_LEVEL = int(os.getenv("WESP_SYNC_CLIENT_COMPRESSION_LEVEL", "6"))
SYNC_CLIENT_COMPRESSION_ALGORITHM = os.getenv("WESP_SYNC_CLIENT_COMPRESSION_ALGORITHM", "gzip").strip() or "gzip"
SYNC_CLIENT_COMPRESSION_MIN_SIZE = int(os.getenv("WESP_SYNC_CLIENT_COMPRESSION_MIN_SIZE", "0"))
SYNC_CLIENT_COMPRESSION_MIN_RATIO = float(os.getenv("WESP_SYNC_CLIENT_COMPRESSION_MIN_RATIO", "0"))
SYNC_CLIENT_COMPRESSION_MAX_SIZE = int(os.getenv("WESP_SYNC_CLIENT_COMPRESSION_MAX_SIZE", str(10 * 1024 * 1024)))
SYNC_CLIENT_COMPRESSION_FORCE = os.getenv("WESP_SYNC_CLIENT_COMPRESSION_FORCE", "1") in {
"1",
"true",
"yes",
}
# Автовыгрузка локального лога на сервер (POST /api/sync/client-log), реже основного цикла sync.
WESP_CLIENT_LOG_AUTO_UPLOAD = os.getenv("WESP_CLIENT_LOG_AUTO_UPLOAD", "1").strip().lower() not in (
"0",
"false",
"no",
"off",
)
WESP_CLIENT_LOG_AUTO_INTERVAL_SEC = max(
3600,
int(os.getenv("WESP_CLIENT_LOG_AUTO_INTERVAL_SEC", "86400")),
)
WESP_CLIENT_LOG_AUTO_UPLOAD_TIMEOUT_SEC = max(
15,
int(os.getenv("WESP_CLIENT_LOG_AUTO_UPLOAD_TIMEOUT_SEC", "120")),
)
# Первый запуск и привязка к серверу: флаги «уже настроено» и «жёстко выбранный сервер».
SYNC_FIRST_BOOTSTRAP_DONE = os.getenv("WESP_SYNC_FIRST_BOOTSTRAP_DONE", "0") in {
"1",
"true",
"yes",
}
SYNC_PINNED_SERVER_ID = os.getenv("WESP_SYNC_PINNED_SERVER_ID", "").strip()
# Этот клиент считается «главным» при конфликтах (1/true/yes) — по смыслу вашей схемы репликации.
SYNC_CLIENT_IS_MASTER = os.getenv("WESP_SYNC_CLIENT_IS_MASTER", "1") in {
"1",
"true",
"yes",
}
# -------------------------------------------------------------------------
# Вход администратора в веб-интерфейс
# -------------------------------------------------------------------------
# Логин и пароль для /api/auth/login. В бою обязательно смените через переменные окружения.
# Пример: export WESP_AUTH_LOGIN='ivan' и export WESP_AUTH_PASSWORD='сложный-пароль'
AUTH_LOGIN = os.getenv("WESP_AUTH_LOGIN", "admin")
AUTH_PASSWORD = os.getenv("WESP_AUTH_PASSWORD", "admin")
# Сколько дней помнить вход при галочке «Запомнить» (cookie сессии).
PERMANENT_SESSION_LIFETIME = timedelta(
days=int(os.getenv("WESP_SESSION_REMEMBER_DAYS", "7"))
)
# -------------------------------------------------------------------------
# Ограничение по MAC-адресу сетевой карты (опционально)
# -------------------------------------------------------------------------
# Если включить — пускать только с перечисленных «аппаратных адресов» сети.
# MAC выглядит как шесть групп hex: aa:bb:cc:dd:ee:ff (через запятую несколько штук).
MAC_LOCK_ENABLED = os.getenv("WESP_MAC_LOCK_ENABLED", "0") in {
"1",
"true",
"yes",
}
ALLOWED_MAC_ADDRESSES = [
x.strip()
for x in os.getenv("WESP_ALLOWED_MAC_ADDRESSES", "").split(",")
if x.strip()
]
# -------------------------------------------------------------------------
# Журнал сообщений (логи)
# -------------------------------------------------------------------------
# Уровень: DEBUG, INFO, WARNING, ERROR, CRITICAL — консоль и data/logs/wesp.log (тот же порог).
# Пример: export WESP_LOG_LEVEL=WARNING — меньше сообщений в проде.
LOG_LEVEL = os.getenv("WESP_LOG_LEVEL", "INFO")
# -------------------------------------------------------------------------
# Файловые логи приложения (каталог внутри проекта) и просмотр в админке
# -------------------------------------------------------------------------
# По умолчанию: <BASE_DIR>/data/logs/wesp.log, ротация в полночь, хранение за N суток (см. WESP_LOG_RETENTION_DAYS).
WESP_LOG_DIR = os.getenv("WESP_LOG_DIR", "").strip() or os.path.join(BASE_DIR, "data", "logs")
WESP_LOG_FILENAME = (os.getenv("WESP_LOG_FILENAME", "wesp.log") or "wesp.log").strip()
WESP_LOG_RETENTION_DAYS = max(1, int(os.getenv("WESP_LOG_RETENTION_DAYS", "3")))
_wesp_admin_log_override = os.getenv("WESP_ADMIN_LOG_PATH", "").strip()
WESP_ADMIN_LOG_PATH = _wesp_admin_log_override or os.path.join(WESP_LOG_DIR, WESP_LOG_FILENAME)
# Выгрузка логов с полевых клиентов на сервер (POST /api/sync/client-log), отдельно от sync БД.
WESP_CLIENT_LOG_UPLOAD_ENABLED = os.getenv(
"WESP_CLIENT_LOG_UPLOAD_ENABLED", "1"
).strip().lower() not in ("0", "false", "no", "off")
WESP_CLIENT_LOG_UPLOAD_SECRET = os.getenv("WESP_CLIENT_LOG_UPLOAD_SECRET", "").strip()
WESP_CLIENT_LOG_UPLOAD_MAX_BYTES = max(
4096,
int(os.getenv("WESP_CLIENT_LOG_UPLOAD_MAX_BYTES", str(8 * 1024 * 1024))),
)
_upload_dir_override = os.getenv("WESP_CLIENT_LOG_UPLOAD_DIR", "").strip()
WESP_CLIENT_LOG_UPLOAD_DIR = _upload_dir_override or os.path.join(WESP_LOG_DIR, "clients")
# Снимки CPU/RAM/swap/диск для графиков (JSONL). Если задана пустая строка в окружении — отключено.
if "WESP_ADMIN_HARDWARE_LOG_PATH" in os.environ:
WESP_ADMIN_HARDWARE_LOG_PATH = os.getenv("WESP_ADMIN_HARDWARE_LOG_PATH", "").strip()
else:
WESP_ADMIN_HARDWARE_LOG_PATH = os.path.join(WESP_LOG_DIR, "wesp-hardware.jsonl")
WESP_ADMIN_HARDWARE_LOG_INTERVAL_SEC = max(
5,
int(os.getenv("WESP_ADMIN_HARDWARE_LOG_INTERVAL_SEC", "60")),
)
WESP_ADMIN_HARDWARE_LOG_RETENTION_DAYS = max(
1,
min(30, int(os.getenv("WESP_ADMIN_HARDWARE_LOG_RETENTION_DAYS", "3"))),
)
# Журнал периферии (HX711, GPIO, пороги хоста): JSONL в WESP_LOG_DIR.
if "WESP_ADMIN_PERIPHERAL_LOG_PATH" in os.environ:
WESP_ADMIN_PERIPHERAL_LOG_PATH = os.getenv("WESP_ADMIN_PERIPHERAL_LOG_PATH", "").strip()
else:
WESP_ADMIN_PERIPHERAL_LOG_PATH = os.path.join(WESP_LOG_DIR, "wesp-peripherals.jsonl")
WESP_ADMIN_PERIPHERAL_LOG_RETENTION_DAYS = max(
1,
min(30, int(os.getenv("WESP_ADMIN_PERIPHERAL_LOG_RETENTION_DAYS", "7"))),
)
WESP_PERIPHERAL_EVENT_DEBOUNCE_SEC = max(
5.0,
float(os.getenv("WESP_PERIPHERAL_EVENT_DEBOUNCE_SEC", "30")),
)
WESP_HOST_ALERT_CPU_PCT = max(
50.0,
min(100.0, float(os.getenv("WESP_HOST_ALERT_CPU_PCT", "90"))),
)
WESP_HOST_ALERT_RAM_PCT = max(
50.0,
min(100.0, float(os.getenv("WESP_HOST_ALERT_RAM_PCT", "90"))),
)
WESP_HOST_ALERT_DISK_PCT = max(
50.0,
min(100.0, float(os.getenv("WESP_HOST_ALERT_DISK_PCT", "90"))),
)
# События из UI админки (строки setStatus): JSONL в WESP_LOG_DIR, обрезка по сроку.
if "WESP_ADMIN_UI_ACTIVITY_LOG_PATH" in os.environ:
WESP_ADMIN_UI_ACTIVITY_LOG_PATH = os.getenv("WESP_ADMIN_UI_ACTIVITY_LOG_PATH", "").strip()
else:
WESP_ADMIN_UI_ACTIVITY_LOG_PATH = os.path.join(WESP_LOG_DIR, "wesp-admin-ui-activity.jsonl")
WESP_ADMIN_UI_ACTIVITY_RETENTION_DAYS = max(
1,
min(30, int(os.getenv("WESP_ADMIN_UI_ACTIVITY_RETENTION_DAYS", "3"))),
)
# Журнал вызовов локального ИИ (ping, сводка, чат): JSONL в WESP_LOG_DIR.
if "WESP_ADMIN_LLM_LOG_PATH" in os.environ:
WESP_ADMIN_LLM_LOG_PATH = os.getenv("WESP_ADMIN_LLM_LOG_PATH", "").strip()
else:
WESP_ADMIN_LLM_LOG_PATH = os.path.join(WESP_LOG_DIR, "wesp-llm.jsonl")
WESP_ADMIN_LLM_LOG_RETENTION_DAYS = max(
1,
min(30, int(os.getenv("WESP_ADMIN_LLM_LOG_RETENTION_DAYS", "7"))),
)
# -------------------------------------------------------------------------
# Админ-панель: опциональное управление процессом (только для опытных)
# -------------------------------------------------------------------------
# Если задать — при нажатии «Перезапустить» / «Остановить» выполняется команда через subprocess
# (разделение аргументов как в shell: export WESP_ADMIN_RESTART_CMD='systemctl restart wesp').
WESP_ADMIN_RESTART_CMD = os.getenv("WESP_ADMIN_RESTART_CMD", "").strip()
WESP_ADMIN_STOP_CMD = os.getenv("WESP_ADMIN_STOP_CMD", "").strip()
WESP_ADMIN_REBOOT_CMD = os.getenv("WESP_ADMIN_REBOOT_CMD", "sudo /sbin/reboot").strip()
WESP_AUTO_REBOOT_ENABLED = os.getenv("WESP_AUTO_REBOOT_ENABLED", "1").strip()
WESP_AUTO_REBOOT_DELAY_SEC = max(
1, int(os.getenv("WESP_AUTO_REBOOT_DELAY_SEC", "3"))
)
WESP_FULL_SETUP_REBOOT_DELAY_SEC = max(
5, int(os.getenv("WESP_FULL_SETUP_REBOOT_DELAY_SEC", "10"))
)
WESP_KIOSK_AUTO_REBOOT = os.getenv("WESP_KIOSK_AUTO_REBOOT", "1").strip()
WESP_PI_BOOT_AUTO_REBOOT = os.getenv("WESP_PI_BOOT_AUTO_REBOOT", "1").strip()
WESP_PLYMOUTH_AUTO_REBOOT = os.getenv("WESP_PLYMOUTH_AUTO_REBOOT", "1").strip()
# Plymouth: каталог сборки кадров, команда установки (если sudo не через install_wesp_theme.sh).
WESP_PLYMOUTH_BUILD_DIR = os.getenv("WESP_PLYMOUTH_BUILD_DIR", "").strip()
WESP_PLYMOUTH_INSTALL_SCRIPT = os.getenv("WESP_PLYMOUTH_INSTALL_SCRIPT", "").strip()
WESP_PLYMOUTH_INSTALL_CMD = os.getenv("WESP_PLYMOUTH_INSTALL_CMD", "").strip()
WESP_PLYMOUTH_INSTALL_TIMEOUT = max(
60, int(os.getenv("WESP_PLYMOUTH_INSTALL_TIMEOUT", "600"))
)
# Pi boot: путь к config.txt и команда записи (если не хватает sudo cp).
WESP_PI_BOOT_CONFIG_PATH = os.getenv("WESP_PI_BOOT_CONFIG_PATH", "").strip()
WESP_PI_BOOT_CONFIG_WRITE_CMD = os.getenv("WESP_PI_BOOT_CONFIG_WRITE_CMD", "").strip()
# Kiosk boot: Chromium autostart для локального терминала (labwc / LightDM).
WESP_KIOSK_GUI_USER = os.getenv("WESP_KIOSK_GUI_USER", "komton").strip()
# Прямой URL в Chromium; пусто → /starting?next=… (см. TARGET / STARTUP_SCREEN).
WESP_KIOSK_URL = os.getenv("WESP_KIOSK_URL", "").strip()
WESP_KIOSK_TARGET_URL = os.getenv(
"WESP_KIOSK_TARGET_URL", "http://localhost/scales"
).strip()
WESP_KIOSK_STARTUP_SCREEN = os.getenv("WESP_KIOSK_STARTUP_SCREEN", "1").strip()
WESP_KIOSK_STARTUP_THEME = os.getenv("WESP_KIOSK_STARTUP_THEME", "dark").strip()
WESP_KIOSK_CHROMIUM = os.getenv("WESP_KIOSK_CHROMIUM", "/usr/bin/chromium").strip()
WESP_KIOSK_CHROMIUM_EXTRA_FLAGS = os.getenv("WESP_KIOSK_CHROMIUM_EXTRA_FLAGS", "").strip()
WESP_KIOSK_CHROMIUM_USER_DATA_DIR = os.getenv(
"WESP_KIOSK_CHROMIUM_USER_DATA_DIR", ""
).strip()
WESP_KIOSK_CHROMIUM_POLICY_PATH = os.getenv("WESP_KIOSK_CHROMIUM_POLICY_PATH", "").strip()
WESP_KIOSK_CHROMIUM_HOST_RULES = os.getenv("WESP_KIOSK_CHROMIUM_HOST_RULES", "").strip()
WESP_KIOSK_WRITE_CMD = os.getenv("WESP_KIOSK_WRITE_CMD", "").strip()
WESP_KIOSK_SYSTEM_LABWC_AUTOSTART = os.getenv(
"WESP_KIOSK_SYSTEM_LABWC_AUTOSTART", "/etc/xdg/labwc/autostart"
).strip()
# Список IP в дашборде: physical — только «физические» NIC (эвристика), all — все адреса psutil.
WESP_ADMIN_IP_ADDRESSES = os.getenv("WESP_ADMIN_IP_ADDRESSES", "physical").strip().lower()
# Файл состояния: дата первого запуска (для гарантии). Пусто — <DATA_DIR>/wesp_install_state.json
WESP_INSTALL_STATE_PATH = os.getenv("WESP_INSTALL_STATE_PATH", "").strip()
# Гарантия на систему: длительность в днях от first_launch_at (по умолчанию 1 год).
WESP_WARRANTY_DAYS = max(1, int(os.getenv("WESP_WARRANTY_DAYS", "365")))
# -------------------------------------------------------------------------
# Админка: локальный LLM (OpenAI-совместимый HTTP, например llama-server + GGUF)
# Только суперпользователь, /api/admin/llm/*
# -------------------------------------------------------------------------
# Каталог для логов/кэша ассистента; подкаталоги создаются при старте (если доступно).
# В Docker обычно: export WESP_ASSISTANT_DIR=/app/assistant
WESP_ASSISTANT_DIR = os.getenv(
"WESP_ASSISTANT_DIR", os.path.join(DATA_DIR, "assistant")
).strip()
# Эндпоинты /api/admin/llm/*; по умолчанию вкл. Файл wesp_security_settings.json (admin_llm_enabled), если нет WESP_ADMIN_LLM_ENABLED в окружении.
WESP_ADMIN_LLM_ENABLED = _env_bool_with_default("WESP_ADMIN_LLM_ENABLED", True)
# Базовый URL llama-server (без хвоста): POST /v1/chat/completions, GET /v1/models
WESP_LLM_BASE_URL = os.getenv("WESP_LLM_BASE_URL", "http://127.0.0.1:8080").strip().rstrip("/")
# Имя модели в API (как в llama-server -m; часто имя файла .gguf)
WESP_LLM_MODEL = os.getenv(
"WESP_LLM_MODEL",
"qwen2.5-1.5b-instruct-q5_k_m.gguf",
).strip()
WESP_LLM_API_KEY = os.getenv("WESP_LLM_API_KEY", "").strip()
WESP_LLM_TIMEOUT_SEC = float(os.getenv("WESP_LLM_TIMEOUT_SEC", "120"))
WESP_LLM_STATUS_TIMEOUT_SEC = float(os.getenv("WESP_LLM_STATUS_TIMEOUT_SEC", "3"))
WESP_LLM_MAX_CONTEXT_CHARS = max(
2000,
min(100_000, int(os.getenv("WESP_LLM_MAX_CONTEXT_CHARS", "12000"))),
)
WESP_LLM_MAX_TOKENS = max(
64,
min(4096, int(os.getenv("WESP_LLM_MAX_TOKENS", "512"))),
)
# Многоходовый чат: лимит ответа (экономия CPU/RAM на llama-server). 256 часто обрезает списки/объяснения.
WESP_LLM_CHAT_MAX_TOKENS = max(
32,
min(2048, int(os.getenv("WESP_LLM_CHAT_MAX_TOKENS", "1024"))),
)
WESP_LLM_CHAT_TIMEOUT_SEC = float(os.getenv("WESP_LLM_CHAT_TIMEOUT_SEC", "45"))
# В чат подмешивать среда + SQLite (compact), без полного ORM — тяжелее по токенам; файл llm_chat_db_context.
WESP_LLM_CHAT_INCLUDE_DB_CONTEXT = _env_bool_with_default(
"WESP_LLM_CHAT_INCLUDE_DB_CONTEXT", False
)
# Интервал между «тяжёлыми» POST llm/summary и llm/ping (один пользователь). Чат использует WESP_ADMIN_LLM_CHAT_RATE_LIMIT_SEC.
WESP_ADMIN_LLM_RATE_LIMIT_SEC = max(
0.0,
float(os.getenv("WESP_ADMIN_LLM_RATE_LIMIT_SEC", "12")),
)
WESP_ADMIN_LLM_CHAT_RATE_LIMIT_SEC = max(
0.0,
float(os.getenv("WESP_ADMIN_LLM_CHAT_RATE_LIMIT_SEC", "0")),
)
# При старте run.py/wsgi поднять llama-server (дочерний процесс). Файл wesp_security_settings.json (llm_autostart) перекрывает, если нет WESP_LLM_AUTOSTART в окружении.
WESP_LLM_AUTOSTART = _env_bool_with_default("WESP_LLM_AUTOSTART", True)
# Чат: встроенные slim-tools (SQL preview/execute, логи). Файл wesp_security_settings.json (llm_tools_enabled), если нет WESP_LLM_TOOLS_ENABLED в окружении.
WESP_LLM_TOOLS_ENABLED = _env_bool_with_default("WESP_LLM_TOOLS_ENABLED", True)
WESP_LLM_TOOL_MAX_ROUNDS = max(
1,
min(16, int(os.getenv("WESP_LLM_TOOL_MAX_ROUNDS", "6"))),
)
WESP_LLM_SQL_MAX_ROWS = max(
1,
min(2000, int(os.getenv("WESP_LLM_SQL_MAX_ROWS", "200"))),
)
WESP_LLM_LOG_MAX_BYTES = max(
4096,
min(4_000_000, int(os.getenv("WESP_LLM_LOG_MAX_BYTES", "262144"))),
)
WESP_LLM_LOG_TAIL_LINES = max(
50,
min(5000, int(os.getenv("WESP_LLM_LOG_TAIL_LINES", "400"))),
)
class DevelopmentConfig(Config):
"""Режим разработки: подробные сообщения об ошибках на экране (не для публичного сервера)."""
DEBUG = True
class ProductionConfig(Config):
"""Боевой режим: без отладочных страниц Flask."""
DEBUG = False
_DEFAULT_SECRET_KEY = "change-me-in-production"
_INSECURE_AUTH = frozenset({("admin", "admin")})
def validate_production_config(app) -> None:
"""
Проверяет обязательные секреты в production перед миграциями и bootstrap.
WESP_ALLOW_INSECURE_DEFAULTS=1 — только для локальной отладки на «чистой» машине.
"""
import logging
if app.config.get("TESTING"):
return
allow = os.getenv("WESP_ALLOW_INSECURE_DEFAULTS", "").strip().lower() in (
"1",
"true",
"yes",
"on",
)
log = logging.getLogger(__name__)
secret = str(app.config.get("SECRET_KEY") or "")
login = str(app.config.get("AUTH_LOGIN") or "")
password = str(app.config.get("AUTH_PASSWORD") or "")
if secret in ("", _DEFAULT_SECRET_KEY) and not allow:
raise RuntimeError(
"WESP_CONFIG=production: задайте WESP_SECRET_KEY (длинная случайная строка) "
"или WESP_ALLOW_INSECURE_DEFAULTS=1 только для локальной отладки."
)
if (login, password) in _INSECURE_AUTH and not allow:
log.warning(
"WESP_CONFIG=production: используются логин/пароль admin/admin — "
"задайте WESP_AUTH_LOGIN и WESP_AUTH_PASSWORD."
)
_WESP_TESTING_INSTALL_DIR = tempfile.mkdtemp(prefix="wesp-testing-install-")
class TestingConfig(Config):
"""Режим автотестов: отдельная БД в памяти, без лишних фоновых потоков."""
TESTING = True
# Не редиректить API/HTML на /setup во время pytest (см. pages._setup_wizard_guard).
# test_setup_wizard выставляет False в своём SetupConfig.
WESP_TESTING_BYPASS_SETUP_GUARD = True
SQLALCHEMY_DATABASE_URI = "sqlite:///:memory:"
SQLALCHEMY_BINDS = {}
SYNC_BACKGROUND_REQUEUE = False
WESP_BACKGROUND_STARTUP = False
# Не писать файловые логи и не требовать путь для /api/admin/server-log в общих тестах
WESP_ADMIN_LOG_PATH = ""
WESP_ADMIN_HARDWARE_LOG_PATH = ""
WESP_ADMIN_UI_ACTIVITY_LOG_PATH = ""
WESP_ADMIN_LLM_LOG_PATH = ""
WESP_ADMIN_IP_ADDRESSES = "all"
WESP_INSTALL_STATE_PATH = os.path.join(_WESP_TESTING_INSTALL_DIR, "wesp_install_state.json")
WESP_CLIENT_LOG_UPLOAD_DIR = os.path.join(_WESP_TESTING_INSTALL_DIR, "client_logs")
WESP_CLIENT_LOG_UPLOAD_SECRET = ""
WESP_CLIENT_LOG_AUTO_UPLOAD = False
WESP_ASSISTANT_DIR = os.path.join(_WESP_TESTING_INSTALL_DIR, "assistant")
WESP_ADMIN_LLM_ENABLED = False
WESP_LLM_AUTOSTART = False
WESP_LLM_BASE_URL = "http://127.0.0.1:8080"
WESP_LLM_MODEL = "qwen2.5-1.5b-instruct-q5_k_m.gguf"
WESP_LLM_CHAT_MAX_TOKENS = 256
WESP_LLM_CHAT_TIMEOUT_SEC = 45.0
WESP_ADMIN_LLM_RATE_LIMIT_SEC = 0.0
WESP_ADMIN_LLM_CHAT_RATE_LIMIT_SEC = 0.0
WESP_LLM_CHAT_INCLUDE_DB_CONTEXT = False
# В юнит-тестах чат без tool-calls по умолчанию (один мок POST); см. тесты LLM tools.
WESP_LLM_TOOLS_ENABLED = False
SYNC_CLIENT_AUTOSTART = False
def get_config_class() -> type:
"""
Выбирает класс настроек по переменной WESP_CONFIG (или устаревшему FLASK_ENV).
Примеры:
export WESP_CONFIG=production — как на реальном сервере
export WESP_CONFIG=development — удобно при разработке
export WESP_CONFIG=testing — для pytest и т.п.
Допустимые синонимы: prod/dev/test.
Если ничего не задано — по умолчанию development.
"""
raw = os.getenv("WESP_CONFIG", "").strip().lower()
if raw in ("production", "prod"):
return ProductionConfig
if raw in ("testing", "test"):
return TestingConfig
if raw in ("development", "dev"):
return DevelopmentConfig
if os.getenv("FLASK_ENV", "").strip().lower() == "production":
return ProductionConfig
return DevelopmentConfig
def _resolve_data_dir() -> Path:
"""DATA_DIR из активного Flask-приложения или класс Config (для тестов с подменой DATA_DIR)."""
try:
from flask import current_app, has_app_context, has_request_context
if has_request_context() or has_app_context():
d = current_app.config.get("DATA_DIR")
if d:
return Path(str(d)).expanduser()
except Exception:
pass
return Path(Config.DATA_DIR)
def _resolve_base_dir() -> Path:
try:
from flask import current_app, has_app_context, has_request_context
if has_request_context() or has_app_context():
b = current_app.config.get("BASE_DIR")
if b:
return Path(str(b)).expanduser()
except Exception:
pass
return Path(Config.BASE_DIR)
def get_sync_client_state_path() -> Path:
"""
Путь к JSON-файлу с изменяемым состоянием клиента синхронизации
(идентификатор, URL сервера после настройки в интерфейсе и т.д.).
По умолчанию файл в data/sync_client_state.json.
Свой путь: export WESP_SYNC_CLIENT_STATE_PATH='/var/lib/wesp/state.json'
"""
custom = os.getenv("WESP_SYNC_CLIENT_STATE_PATH", "").strip()
if custom:
p = Path(custom)
if not p.is_absolute():
p = _resolve_base_dir() / p
return p
return _resolve_data_dir() / "sync_client_state.json"
def get_wesp_data_config_path() -> Path:
"""Путь к data/config.json (legacy / модуль update.py: слияние при обновлении и пр.)."""
return _resolve_data_dir() / "config.json"
def read_wesp_data_config() -> Dict[str, Any]:
path = get_wesp_data_config_path()
if not path.is_file():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def read_sync_client_state() -> Dict[str, Any]:
"""Читает JSON состояния клиента; если файла нет или он битый — возвращает пустой словарь."""
path = get_sync_client_state_path()
if path.exists():
try:
parsed = json.loads(path.read_text(encoding="utf-8"))
return parsed if isinstance(parsed, dict) else {}
except Exception:
return {}
return {}
def coerce_sync_client_state(state: Dict[str, Any]) -> Dict[str, Any]:
"""Центральный узел (server) не ходит pull на server_url — очищаем URL."""
out = dict(state)
role = str(out.get("role") or "").strip().lower()
if role == "server":
if str(out.get("server_url") or "").strip():
out["server_url"] = ""
return out
def validate_sync_client_state_patch(partial: Dict[str, Any]) -> Tuple[bool, str]:
"""Проверка перед записью: server + непустой server_url недопустимо."""
role = str(partial.get("role") or "").strip().lower()
if "server_url" in partial:
url = str(partial.get("server_url") or "").strip()
if role == "server" and url:
return (
False,
"Для роли server адрес удалённого сервера не задаётся — этот узел сам принимает sync.",
)
if role == "server" and "server_url" not in partial:
st = read_sync_client_state()
merged_role = role or str(st.get("role") or "").strip().lower()
if merged_role == "server" and str(st.get("server_url") or "").strip():
return True, ""
return True, ""
def write_sync_client_state(partial: Dict[str, Any]) -> None:
"""Дополняет файл состояния клиента новыми полями и перезаписывает файл."""
ok, err = validate_sync_client_state_patch(partial)
if not ok:
raise ValueError(err)
current = read_sync_client_state()
current.update(partial)
current = coerce_sync_client_state(current)
path = get_sync_client_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
def repair_sync_client_state_on_disk() -> bool:
"""При старте: если role=server, но остался server_url — починить файл."""
current = read_sync_client_state()
fixed = coerce_sync_client_state(current)
if fixed != current:
path = get_sync_client_state_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(fixed, ensure_ascii=False, indent=2), encoding="utf-8")
logging.getLogger(__name__).warning(
"sync_client_state: роль server — удалён server_url (узел не должен быть клиентом самому себе)"
)
return True
return False
def resolve_sync_server_url(
*,
state: Optional[Dict[str, Any]] = None,
config_defaults: Optional[Any] = None,
) -> Tuple[str, bool]:
"""URL центрального узла для HTTP pull/push и флаг «задан явно».
Должен совпадать с логикой sync_client.SyncClient: иначе админка показывает одно,
а фоновый поток ходит на другое.
Приоритет: WESP_SYNC_SERVER_URL → server_url в sync_client_state.json → Config.SYNC_CLIENT_SERVER_URL.
Если в JSON есть ключ server_url (даже пустая строка), Config не подставляет URL — поле в файле
полностью задаёт адрес и схему (http/https); пустой URL = синк не идёт, без «дописывания» из config.
"""
url_env = os.getenv("WESP_SYNC_SERVER_URL", "").strip()
if url_env:
return url_env.rstrip("/"), True
st = state if state is not None else read_sync_client_state()
if "server_url" in st:
su = str(st.get("server_url") or "").strip()
return su.rstrip("/"), True
cfg_url = ""
if config_defaults is not None:
cfg_url = str(getattr(config_defaults, "SYNC_CLIENT_SERVER_URL", "") or "").strip()
if cfg_url:
return cfg_url.rstrip("/"), True
return "", False
def resolve_effective_sync_role(
*,
state: Optional[Dict[str, Any]] = None,
config_defaults: Optional[Any] = None,
) -> Tuple[str, bool]:
"""Роль узла: WESP_SYNC_ROLE → sync_client_state.role → Config.SYNC_CLIENT_ROLE.
Второй элемент — True, если роль зафиксирована окружением (не из JSON).
"""
role_env = os.getenv("WESP_SYNC_ROLE", "").strip()
if role_env:
return (role_env or "server").strip() or "server", True
st = state if state is not None else read_sync_client_state()
sr = str(st.get("role") or "").strip()
if sr:
return sr, False
default = str(getattr(config_defaults, "SYNC_CLIENT_ROLE", "server") or "server").strip() or "server"
return default, False
# ---------------------------------------------------------------------------
# Локальные настройки безопасности / киоска (перекрывают класс Config, если нет WESP_* в окружении)
# ---------------------------------------------------------------------------
SECURITY_SETTINGS_ENV_KEYS: Dict[str, str] = {
"mac_lock_enabled": "WESP_MAC_LOCK_ENABLED",
"allowed_mac_addresses": "WESP_ALLOWED_MAC_ADDRESSES",
"kiosk_enforce_paired_only": "WESP_KIOSK_ENFORCE_PAIRED_ONLY",
"calibration_public": "WESP_CALIBRATION_PUBLIC",
"device_token": "WESP_DEVICE_TOKEN",
"device_token_header": "WESP_DEVICE_TOKEN_HEADER",
"kiosk_pair_token_ttl_seconds": "WESP_KIOSK_PAIR_TOKEN_TTL_SECONDS",
"kiosk_auth_cookie_days": "WESP_KIOSK_AUTH_COOKIE_DAYS",
"kiosk_public_base_url": "WESP_KIOSK_PUBLIC_BASE_URL",
"session_remember_days": "WESP_SESSION_REMEMBER_DAYS",
"llm_autostart": "WESP_LLM_AUTOSTART",
"admin_llm_enabled": "WESP_ADMIN_LLM_ENABLED",
"llm_chat_db_context": "WESP_LLM_CHAT_INCLUDE_DB_CONTEXT",
"llm_tools_enabled": "WESP_LLM_TOOLS_ENABLED",
}
def get_security_settings_path() -> Path:
custom = os.getenv("WESP_SECURITY_SETTINGS_PATH", "").strip()
if custom:
p = Path(custom)
if not p.is_absolute():
p = Path(Config.BASE_DIR) / p
return p
return Path(Config.DATA_DIR) / "wesp_security_settings.json"
def read_security_settings() -> Dict[str, Any]:
path = get_security_settings_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def write_security_settings(partial: Dict[str, Any]) -> None:
current = read_security_settings()
for k, v in partial.items():
current[k] = v
path = get_security_settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
def get_network_diagnostics_path() -> Path:
custom = os.getenv("WESP_NETWORK_DIAGNOSTICS_PATH", "").strip()
if custom:
p = Path(custom)
if not p.is_absolute():
p = Path(Config.BASE_DIR) / p
return p
return Path(Config.DATA_DIR) / "wesp_network_diagnostics.json"
def read_network_diagnostics_settings() -> Dict[str, Any]:
path = get_network_diagnostics_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def write_network_diagnostics_settings(partial: Dict[str, Any]) -> None:
current = read_network_diagnostics_settings()
for k, v in partial.items():
current[k] = v
path = get_network_diagnostics_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
def security_env_lock_flags() -> Dict[str, bool]:
"""True, если для поля задана переменная окружения (имеет приоритет над файлом и админкой)."""
return {field: (env_name in os.environ) for field, env_name in SECURITY_SETTINGS_ENV_KEYS.items()}
def coerce_security_bool(val: Any) -> bool:
if isinstance(val, bool):
return val
s = str(val).strip().lower()
return s in {"1", "true", "yes", "on"}
def _mac_list_from_value(val: Any) -> list[str]:
if val is None:
return []
if isinstance(val, list):
raw = [str(x).strip() for x in val if str(x).strip()]
else:
raw = [x.strip() for x in str(val).split(",") if x.strip()]
return [x.lower() for x in raw]
def _apply_security_file_to_config_mapping(
cfg: MutableMapping[str, Any], data: Dict[str, Any], locks: Dict[str, bool]
) -> None:
"""Переносит значения из файла в Flask config (только незаблокированные env поля)."""
if not data:
return
if not locks.get("mac_lock_enabled") and "mac_lock_enabled" in data:
cfg["MAC_LOCK_ENABLED"] = coerce_security_bool(data["mac_lock_enabled"])
if not locks.get("allowed_mac_addresses") and "allowed_mac_addresses" in data:
cfg["ALLOWED_MAC_ADDRESSES"] = _mac_list_from_value(data["allowed_mac_addresses"])
if not locks.get("kiosk_enforce_paired_only") and "kiosk_enforce_paired_only" in data:
cfg["KIOSK_ENFORCE_PAIRED_ONLY"] = coerce_security_bool(
data["kiosk_enforce_paired_only"]
)
if not locks.get("calibration_public") and "calibration_public" in data:
cfg["CALIBRATION_PUBLIC"] = coerce_security_bool(data["calibration_public"])
if not locks.get("device_token") and "device_token" in data:
cfg["DEVICE_TOKEN"] = str(data["device_token"] or "").strip()
if not locks.get("device_token_header") and "device_token_header" in data:
h = str(data["device_token_header"] or "").strip()
if h:
cfg["DEVICE_TOKEN_HEADER"] = h
if not locks.get("kiosk_pair_token_ttl_seconds") and "kiosk_pair_token_ttl_seconds" in data:
try:
ttl = int(data["kiosk_pair_token_ttl_seconds"])
cfg["KIOSK_PAIR_TOKEN_TTL_SECONDS"] = max(30, min(ttl, 86400))
except (TypeError, ValueError):
pass
if not locks.get("kiosk_auth_cookie_days") and "kiosk_auth_cookie_days" in data:
try:
days = int(data["kiosk_auth_cookie_days"])
cfg["KIOSK_AUTH_COOKIE_DAYS"] = max(1, min(days, 36500))
except (TypeError, ValueError):
pass
if not locks.get("kiosk_public_base_url") and "kiosk_public_base_url" in data:
cfg["KIOSK_PUBLIC_BASE_URL"] = str(data["kiosk_public_base_url"] or "").strip()
if not locks.get("session_remember_days") and "session_remember_days" in data:
try:
days = int(data["session_remember_days"])
days = max(1, min(days, 3650))
cfg["PERMANENT_SESSION_LIFETIME"] = timedelta(days=days)
except (TypeError, ValueError):
pass
if not locks.get("llm_autostart") and "llm_autostart" in data:
cfg["WESP_LLM_AUTOSTART"] = coerce_security_bool(data["llm_autostart"])
if not locks.get("admin_llm_enabled") and "admin_llm_enabled" in data:
cfg["WESP_ADMIN_LLM_ENABLED"] = coerce_security_bool(data["admin_llm_enabled"])
if not locks.get("llm_chat_db_context") and "llm_chat_db_context" in data:
cfg["WESP_LLM_CHAT_INCLUDE_DB_CONTEXT"] = coerce_security_bool(
data["llm_chat_db_context"]
)
if not locks.get("llm_tools_enabled") and "llm_tools_enabled" in data:
cfg["WESP_LLM_TOOLS_ENABLED"] = coerce_security_bool(data["llm_tools_enabled"])
def apply_security_settings_to_app(app) -> None:
"""После load Config: подмешивает wesp_security_settings.json (с учётом блокировок env)."""
# В TESTING не читаем файл из data/, иначе локальный wesp_security_settings.json
# ломает изоляцию тестов; явный WESP_SECURITY_SETTINGS_PATH (как в тестах админки) — по-прежнему учитывается.
if app.config.get("TESTING") and not os.getenv("WESP_SECURITY_SETTINGS_PATH", "").strip():
return
file_data = read_security_settings()
if not file_data:
return
locks = security_env_lock_flags()
_apply_security_file_to_config_mapping(app.config, file_data, locks)
# ---------------------------------------------------------------------------
# Локальные сетевые настройки (имя в LAN, публичный URL, mDNS)
# ---------------------------------------------------------------------------
DEFAULT_NETWORK_LOCAL_HOSTNAME = "komton_srv_1.local"
NETWORK_SETTINGS_ENV_KEYS: Dict[str, str] = {
"local_hostname": "WESP_NETWORK_LOCAL_HOSTNAME",
"public_base_url": "WESP_KIOSK_PUBLIC_BASE_URL",
"mdns_enabled": "WESP_MDNS_ENABLED",
}
def get_network_settings_path() -> Path:
custom = os.getenv("WESP_NETWORK_SETTINGS_PATH", "").strip()
if custom:
p = Path(custom)
if not p.is_absolute():
p = Path(Config.BASE_DIR) / p
return p
return Path(Config.DATA_DIR) / "wesp_network_settings.json"
def read_network_settings_raw() -> Dict[str, Any]:
path = get_network_settings_path()
if not path.exists():
return {}
try:
data = json.loads(path.read_text(encoding="utf-8"))
return data if isinstance(data, dict) else {}
except Exception:
return {}
def write_network_settings(partial: Dict[str, Any]) -> None:
current = read_network_settings_raw()
for k, v in partial.items():
current[k] = v
path = get_network_settings_path()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(current, ensure_ascii=False, indent=2), encoding="utf-8")
def network_env_lock_flags() -> Dict[str, bool]:
return {field: (env_name in os.environ) for field, env_name in NETWORK_SETTINGS_ENV_KEYS.items()}
def normalize_local_hostname(value: Any) -> str:
name = str(value or "").strip().lower().rstrip(".")
if not name:
return ""
if not name.endswith(".local"):
name = f"{name}.local"
return name
def validate_local_hostname(value: Any) -> Tuple[bool, str]:
"""
Проверка имени для mDNS. Возвращает (ok, normalized_or_error_message).
"""
import re
normalized = normalize_local_hostname(value)
if not normalized:
return False, "Укажите имя хоста, например komton_srv_1.local"
label = normalized[: -len(".local")] if normalized.endswith(".local") else normalized
if label.isdigit():
return False, "Имя не может состоять только из цифр; пример: komton_srv_1.local"
if len(label) < 2:
return False, "Имя слишком короткое; пример: komton_srv_1.local"
if not re.fullmatch(r"[a-z0-9](?:[a-z0-9_-]{0,61}[a-z0-9])?", label):
return False, "Допустимы латиница, цифры, дефис и _; пример: komton_srv_1.local"
return True, normalized
def read_network_settings_merged() -> Dict[str, Any]:
"""Файл network + fallback public_base_url из security settings."""
data = dict(read_network_settings_raw())
pub = str(data.get("public_base_url") or "").strip().rstrip("/")
if not pub:
sec = read_security_settings()
pub = str(sec.get("kiosk_public_base_url") or "").strip().rstrip("/")
if pub:
data["public_base_url"] = pub
data["_public_base_url_migrated_from_security"] = True
if "local_hostname" in data and data["local_hostname"]:
data["local_hostname"] = normalize_local_hostname(data["local_hostname"])
return data
def _apply_network_file_to_config_mapping(
cfg: MutableMapping[str, Any], data: Dict[str, Any], locks: Dict[str, bool]
) -> None:
if not data:
return
if not locks.get("local_hostname") and "local_hostname" in data:
cfg["WESP_NETWORK_LOCAL_HOSTNAME"] = normalize_local_hostname(data.get("local_hostname"))
if not locks.get("public_base_url") and "public_base_url" in data:
cfg["KIOSK_PUBLIC_BASE_URL"] = str(data.get("public_base_url") or "").strip().rstrip("/")
if not locks.get("mdns_enabled") and "mdns_enabled" in data:
cfg["WESP_MDNS_ENABLED"] = coerce_security_bool(data.get("mdns_enabled"))
def apply_network_settings_to_app(app) -> None:
"""После security: wesp_network_settings.json перекрывает KIOSK_PUBLIC_BASE_URL и mDNS."""
if app.config.get("TESTING") and not os.getenv("WESP_NETWORK_SETTINGS_PATH", "").strip():
return
file_data = read_network_settings_merged()
if not file_data:
return
locks = network_env_lock_flags()
_apply_network_file_to_config_mapping(app.config, file_data, locks)