Initial commit: aplicatie de cantarire si etichetare
Aplicatie Flask + SQLite pentru productie alimentara: cantarirea componentelor unui produs finit si tiparirea etichetei prin QZ Tray. - produse finite (retete) formate din componente cantarite - designer vizual de etichete (mm/px, zona sigura, campuri variabile, cod QR cu continut din mai multe bucati) - legare produs finit -> sablon de eticheta - navigare pe doua taburi: Cantarire / Setari - administrare: componente, produse finite, sabloane, campuri, istoric - backup, resetare baza de date, checklist de pregatire, /healthz - CLI de administrare si serviciu systemd pentru productie - vendor/syncpulse-scale-bridge (GPL-3.0-or-later) pentru citirea cantarului
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
# Copyright (C) 2026 Farshad Zamani — Cloudtart (https://cloudtart.com)
|
||||
"""
|
||||
bridge.json — the bridge's settings, access key and saved scale profiles.
|
||||
|
||||
It lives beside the program, so copying the folder copies the setup. When
|
||||
that folder is read-only (e.g. Program Files), %LOCALAPPDATA% is used.
|
||||
Files written by earlier versions (1.x) are read as they are.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import EXE_NAME
|
||||
from .profiles import MAX_PROFILES, validate
|
||||
|
||||
log = logging.getLogger("scale_bridge")
|
||||
|
||||
DEFAULT_PORT = 8765
|
||||
DEFAULTS: dict[str, Any] = {
|
||||
"listen_host": "0.0.0.0",
|
||||
"listen_port": DEFAULT_PORT,
|
||||
"token": "",
|
||||
# Empty = any machine that has the key. Put the platform server's IP here
|
||||
# to refuse everybody else, e.g. ["192.168.1.10"].
|
||||
"allowed_clients": [],
|
||||
"profiles": [],
|
||||
}
|
||||
|
||||
|
||||
def app_dir() -> str:
|
||||
if getattr(sys, "frozen", False):
|
||||
return os.path.dirname(os.path.abspath(sys.executable))
|
||||
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
|
||||
def _writable(directory: str) -> bool:
|
||||
try:
|
||||
fd, probe = tempfile.mkstemp(prefix=".write-test-", dir=directory)
|
||||
os.close(fd)
|
||||
os.remove(probe)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def default_path() -> str:
|
||||
beside = os.path.join(app_dir(), "bridge.json")
|
||||
if os.path.exists(beside) or _writable(app_dir()):
|
||||
return beside
|
||||
base = os.environ.get("LOCALAPPDATA") or os.path.expanduser("~")
|
||||
folder = os.path.join(base, EXE_NAME)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
return os.path.join(folder, "bridge.json")
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
class Config:
|
||||
def __init__(self, path: Optional[str] = None):
|
||||
self.path = os.path.abspath(path or default_path())
|
||||
self.lock = threading.RLock()
|
||||
self.data: dict[str, Any] = dict(DEFAULTS)
|
||||
self.load()
|
||||
|
||||
# -- file --------------------------------------------------------------
|
||||
def load(self) -> None:
|
||||
with self.lock:
|
||||
data = dict(DEFAULTS)
|
||||
if os.path.exists(self.path):
|
||||
try:
|
||||
with open(self.path, encoding="utf-8-sig") as f:
|
||||
loaded = json.load(f)
|
||||
if isinstance(loaded, dict):
|
||||
data.update(loaded)
|
||||
except (OSError, ValueError) as e:
|
||||
backup = self.path + ".broken"
|
||||
log.error("%s could not be read (%s); kept as %s and started fresh", self.path, e,
|
||||
backup)
|
||||
try:
|
||||
os.replace(self.path, backup)
|
||||
except OSError:
|
||||
pass
|
||||
profiles = []
|
||||
for raw in data.get("profiles") or []:
|
||||
try:
|
||||
profiles.append(validate(raw))
|
||||
except (ValueError, TypeError, KeyError) as e:
|
||||
log.warning("profile %r ignored: %s", (raw or {}).get("name"), e)
|
||||
data["profiles"] = profiles
|
||||
self.data = data
|
||||
if not data.get("token"):
|
||||
data["token"] = new_token()
|
||||
self.save()
|
||||
log.info("created %s with a new access key", self.path)
|
||||
|
||||
def save(self) -> None:
|
||||
with self.lock:
|
||||
folder = os.path.dirname(self.path)
|
||||
os.makedirs(folder, exist_ok=True)
|
||||
fd, tmp = tempfile.mkstemp(prefix=".bridge-", suffix=".json", dir=folder)
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
||||
json.dump(self.data, f, indent=2, ensure_ascii=False)
|
||||
os.replace(tmp, self.path)
|
||||
except BaseException:
|
||||
try:
|
||||
os.remove(tmp)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
# -- values ------------------------------------------------------------
|
||||
@property
|
||||
def token(self) -> str:
|
||||
return str(self.data.get("token") or "")
|
||||
|
||||
def rotate_token(self) -> str:
|
||||
with self.lock:
|
||||
self.data["token"] = new_token()
|
||||
self.save()
|
||||
return self.token
|
||||
|
||||
@property
|
||||
def listen_host(self) -> str:
|
||||
return str(self.data.get("listen_host") or "0.0.0.0")
|
||||
|
||||
@property
|
||||
def listen_port(self) -> int:
|
||||
try:
|
||||
return int(self.data.get("listen_port") or DEFAULT_PORT)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_PORT
|
||||
|
||||
@property
|
||||
def allowed_clients(self) -> list[str]:
|
||||
value = self.data.get("allowed_clients") or []
|
||||
return [str(v).strip() for v in value if str(v).strip()] if isinstance(value, list) else []
|
||||
|
||||
def set_allowed_clients(self, clients: list[str]) -> None:
|
||||
import ipaddress
|
||||
cleaned = []
|
||||
for c in clients:
|
||||
c = str(c).strip()
|
||||
if not c:
|
||||
continue
|
||||
ipaddress.ip_address(c) # raises ValueError
|
||||
cleaned.append(c)
|
||||
with self.lock:
|
||||
self.data["allowed_clients"] = cleaned
|
||||
self.save()
|
||||
|
||||
# -- profiles ----------------------------------------------------------
|
||||
def profiles(self) -> list[dict]:
|
||||
with self.lock:
|
||||
return [dict(p) for p in self.data.get("profiles") or []]
|
||||
|
||||
def profile(self, name: str) -> Optional[dict]:
|
||||
name = str(name or "").strip().lower()
|
||||
with self.lock:
|
||||
for p in self.data.get("profiles") or []:
|
||||
if p["name"].lower() == name:
|
||||
return dict(p)
|
||||
return None
|
||||
|
||||
def save_profile(self, raw: dict) -> dict:
|
||||
profile = validate(raw)
|
||||
with self.lock:
|
||||
items = [p for p in self.data.get("profiles") or []
|
||||
if p["name"].lower() != profile["name"].lower()]
|
||||
if len(items) >= MAX_PROFILES:
|
||||
raise ValueError(f"at most {MAX_PROFILES} profiles")
|
||||
items.append(profile)
|
||||
self.data["profiles"] = items
|
||||
self.save()
|
||||
return profile
|
||||
|
||||
def delete_profile(self, name: str) -> bool:
|
||||
with self.lock:
|
||||
items = self.data.get("profiles") or []
|
||||
kept = [p for p in items if p["name"].lower() != str(name).lower()]
|
||||
if len(kept) == len(items):
|
||||
return False
|
||||
self.data["profiles"] = kept
|
||||
self.save()
|
||||
return True
|
||||
Reference in New Issue
Block a user