""" Настройки приложения 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") # ------------------------------------------------------------------------- # Файловые логи приложения (каталог внутри проекта) и просмотр в админке # ------------------------------------------------------------------------- # По умолчанию: /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() # Файл состояния: дата первого запуска (для гарантии). Пусто — /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)