1226 lines
63 KiB
Python
1226 lines
63 KiB
Python
"""
|
||
Настройки приложения 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)
|