commit 0beb1397534e54cfe45eb7316b58124d9585dd36 Author: matteo porta Date: Wed Jun 1 18:37:19 2022 +0200 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d3a72f2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,17 @@ +__pycache__/ +.idea/ +.ropeproject/ +*.bak +*.prof +*.pyc +/*.asc +/*.exe +/*.log +/*.pdf +/*.spec +/build/ +/data/* +/dist/ +/src/lib/db/*imports*/ +/tmp/ +/venv*/ diff --git a/TODO.txt b/TODO.txt new file mode 100644 index 0000000..28481be --- /dev/null +++ b/TODO.txt @@ -0,0 +1,12 @@ +Funzioni di gestione commesse: creazione, modifica, cancellazione +Sistema di gestione diversi modelli di etichetta tramite software Zebra Designer +Controllo tester di prova tenuta Tecna T3 tramite interfaccia USB +Controllo elettrovalvole di selezione alta/bassa pressione tramite I/O digitali + +Ciclo di lavoro ordinario: +acquisizione del barcode del componente sotto test, se previsto da ricetta +test tenuta a pressione 1 (es. 5 bar) +test tenuta a pressione 2 (es. 20 bar)(opzionale per ricetta) +stampa etichetta test OK +Salvataggio dati dei test su portale di tracciabilità www.r5portal.it sia OK che scarti +Visualizzazione locale archivio test effettuati diff --git a/build.py b/build.py new file mode 100644 index 0000000..b209169 --- /dev/null +++ b/build.py @@ -0,0 +1,43 @@ +import os + +import PyInstaller.__main__ +from PyQt5.Qt import QT_VERSION_STR + +args = [ + # "--clean", + "--distpath", "./dist", + "--icon", "./src/ui/imgs/neo.ico", + "--log-level", "WARN", + "--name", "stb-gui", + "--onefile", + "--paths", f"./src{os.pathsep}/c/Qt/{QT_VERSION_STR}", # if missing dlls the extra qt folder might be wrong + "--python-option", "-u -O", + # "--specpath", ".", # breaks data paths making them relative from specpath + "--windowed", + "--workpath", "./build", + "-y", + "./src/main.py", +] + [ + "--collect-binaries", "can", + "--collect-submodules", "can", +] + +included_data = [ + f"./src/ui/imgs{os.pathsep}ui/imgs", +] + +for root, dirnames, filenames in os.walk(os.path.join("src", "ui")): + for filename in filenames: + if filename.endswith(".ui") or filename.endswith(".qml"): + path = os.path.join(root, filename) + dest = path.split(os.sep)[1:-1] + if not len(dest): + dest = ["."] + dest = os.path.join(*dest) + included_data.append(f"{path}{os.pathsep}{dest}") + +for spec in included_data: + args.append("--add-data") + args.append(spec) + +PyInstaller.__main__.run(args) diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..ee4c06b --- /dev/null +++ b/build.sh @@ -0,0 +1,39 @@ +#!/bin/bash -e +cd "$(dirname "$0")" + +echo "---------- clean build ----------" +rm -rf "build" "dist" +rm -f "*.spec" + +# echo "---------- detect build deps ----------" +# pipreqs --force "./src" + +echo "---------- run init script ----------" +./init.sh + +source "./venv/Scripts/activate" || source "./venv/bin/activate" + +# pip install --upgrade pint +# this might be needed to oveerride a python-obd dependency +# to an older pint version not compatible with python 3.10 + +echo "---------- install builders ----------" +pip install --upgrade pyinstaller +# also you should install +# pipreqs +# "Microsoft Visual C++ Build Tools": https://visualstudio.microsoft.com/downloads/ +# IPython pywin32 pycairo + +echo "---------- build ----------" +python "build.py" + +echo "---------- copy data ----------" +mkdir -p "./dist/data" +cp -fr "./data" "./dist" +mkdir -p "./dist/config" +cp -fr "./config" "./dist" +mkdir -p "./dist/src/prove" +cp "./src/prove/p.csv" "./dist/src/prove/" +cp "./src/prove/t.csv" "./dist/src/prove/" + +echo "---------- done. ----------" diff --git a/built_runme.sh b/built_runme.sh new file mode 100755 index 0000000..5b31e5b --- /dev/null +++ b/built_runme.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e +cd "$(dirname "$0")/dist" +mkdir -p "data/logs" +"./stb-gui" $* 2>&1 | tee -a "data/logs/$(date +"%Y-%m-%d_%H:%M:%S").txt" diff --git a/built_simulate.sh b/built_simulate.sh new file mode 100755 index 0000000..dd8a253 --- /dev/null +++ b/built_simulate.sh @@ -0,0 +1,27 @@ +#!/bin/bash -e +cd "$(dirname "$0")/dist" +# set -m +# ulimit -Sv 4000000 +export QT_DEBUG_PLUGINS=0 +export QML_IMPORT_TRACE=0 +export QT_MESSAGE_PATTERN="[%{type}] %{appname} %{pid}.%{threadid}.%{qthreadptr} (%{file}:%{line} in %{function}) - %{backtrace [depth=9999] [separator='|']} - %{message}" +export QMAKE_LFLAGS+=-rdynamic +export QT_FATAL_WARNINGS=0 +export QT_NO_DEBUG_OUTPUT=1 +export QT_NO_INFO_OUTPUT=1 +export QT_NO_WARNING_OUTPUT=0 +"./stb-gui" \ +--sim-centralina \ +--sim-checker \ +--sim-ginkgo \ +--sim-ginkgo-channel-0 \ +--sim-ginkgo-channel-1 \ +--sim-inverter \ +--sim-pcan \ +--sim-psu \ +--sim-torsiometro \ +--sim-uds \ +--style windows \ +$* 2> >(sed $'s/.*/\e[31m&\e[m/' >&2) # & +# sudo renice -n -10 $! +# fg diff --git a/built_test.sh b/built_test.sh new file mode 100755 index 0000000..0790218 --- /dev/null +++ b/built_test.sh @@ -0,0 +1,2 @@ +#!/bin/bash -e +./built_simulate.sh --test --auto $* diff --git a/config/label_templates/5803001456.prn b/config/label_templates/5803001456.prn new file mode 100644 index 0000000..a05351d --- /dev/null +++ b/config/label_templates/5803001456.prn @@ -0,0 +1,14 @@ +CT~~CD,~CC^~CT~ +^XA~TA000~JSN^LT0^MNW^MTT^PON^PMN^LH0,0^JMA^PR4,4~SD15^JUS^LRN^CI0^XZ +^XA +^MMT +^PW378 +^LL0213 +^LS0 +^FT213,199^BQN,2,6 +^FH\^FDLA,5803001456^FS +^FT28,58^A0N,33,33^FH\^FD5803001456^FS +^FT28,104^A0N,33,33^FH\^FDN: $NUM^FS +^FT28,148^A0N,33,33^FH\^FD$DATE^FS +^FT28,185^A0N,33,33^FH\^FD$TIME^FS +^PQ1,0,1,Y^XZ diff --git a/config/machine_settings/defaults.ini b/config/machine_settings/defaults.ini new file mode 100644 index 0000000..a2baf11 --- /dev/null +++ b/config/machine_settings/defaults.ini @@ -0,0 +1,24 @@ +[test] +parameter: default + +[vision_saver] +time_format: %Y-%m-%d_%H-%M-%S +path: ./data/images +minimum_disk_free_space_gb: 20 + +[archive_synchronizer] +archive_endpoint: https://r5portal.it/api/echo/ +images_path: data/images +poll_time: 60 +hold_time: 1 +service_account_json: data/secrets/MACHINE-bucket-sevice-account_errecinque-prodserver-HEXHEXHEXHEX.json +bucket_id: MACHINE + +[label_printer] +platform: cups +printer: + + +[tecna_marposs_provaset_t3] +address: COM3 +baudrate: 115200 diff --git a/config/machine_settings/hostnames.ini b/config/machine_settings/hostnames.ini new file mode 100644 index 0000000..8c7b949 --- /dev/null +++ b/config/machine_settings/hostnames.ini @@ -0,0 +1,2 @@ +[hostnames] +this: this diff --git a/config/machine_settings/this.ini b/config/machine_settings/this.ini new file mode 100644 index 0000000..d0776f8 --- /dev/null +++ b/config/machine_settings/this.ini @@ -0,0 +1,2 @@ +[test] +parameter: this diff --git a/diagnostic.sh b/diagnostic.sh new file mode 100755 index 0000000..eb19971 --- /dev/null +++ b/diagnostic.sh @@ -0,0 +1,2 @@ +#!/bin/bash -e +./simulate.sh --diagnostic $* diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..dfecb4a --- /dev/null +++ b/init.sh @@ -0,0 +1,15 @@ +#!/bin/bash -e +set -x +here="$(realpath "$(dirname "$0")")" +cd "$here" + +echo "---------- initialize venv ----------" +lsof "./venv/bin/python" | awk 'NR > 1 {print $2}' | xargs kill || : +lsof "./venv/Scripts/activate" | awk 'NR > 1 {print $2}' | xargs kill || : +python -m pip install --upgrade pip +python -m venv venv +source "./venv/bin/activate" || source "./venv/Scripts/activate" || : +python -m pip install --upgrade pip +python -m pip install --upgrade -r "src/requirements.txt" + +cd "$here" diff --git a/profilate.sh b/profilate.sh new file mode 100755 index 0000000..b51812e --- /dev/null +++ b/profilate.sh @@ -0,0 +1,6 @@ +#!/bin/bash -e +cd "$(dirname "$0")" +source "./venv/Scripts/activate" || source "./venv/bin/activate" +# pip install yappi snakeviz --user +python -O -m yappi -o program.prof "./src/main.py" $* +snakeviz program.prof diff --git a/run_chart_example.sh b/run_chart_example.sh new file mode 100755 index 0000000..6b7f73c --- /dev/null +++ b/run_chart_example.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e +cd "$(dirname "$0")" +source "./venv/Scripts/activate" || source "./venv/bin/activate" +python -O "./src/lib/charts/MultiAxisExample.py" diff --git a/run_lifecycle.sh b/run_lifecycle.sh new file mode 100755 index 0000000..49e80f6 --- /dev/null +++ b/run_lifecycle.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e +cd "$(dirname "$0")" +source "./venv/Scripts/activate" || source "./venv/bin/activate" +python -O "./src/lifecycle.py" diff --git a/runme.sh b/runme.sh new file mode 100755 index 0000000..bec3ef5 --- /dev/null +++ b/runme.sh @@ -0,0 +1,4 @@ +#!/bin/bash -e +cd "$(dirname "$0")" +source "./venv/bin/activate" || source "./venv/Scripts/activate" || : +python -O "./src/main.py" diff --git a/simulate.sh b/simulate.sh new file mode 100755 index 0000000..63b4487 --- /dev/null +++ b/simulate.sh @@ -0,0 +1,29 @@ +#!/bin/bash -e +cd "$(dirname "$0")" +# set -m +# ulimit -Sv 4000000 +source "./venv/Scripts/activate" || source "./venv/bin/activate" +export QT_DEBUG_PLUGINS=0 +export QML_IMPORT_TRACE=0 +export QT_MESSAGE_PATTERN="[%{type}] %{appname} %{pid}.%{threadid}.%{qthreadptr} (%{file}:%{line} in %{function}) - %{backtrace [depth=9999] [separator='|']} - %{message}" +export QMAKE_LFLAGS+=-rdynamic +export QT_FATAL_WARNINGS=0 +export QT_NO_DEBUG_OUTPUT=1 +export QT_NO_INFO_OUTPUT=1 +export QT_NO_WARNING_OUTPUT=0 +# export QT_QPA_PLATFORM=${XDG_SESSION_TYPE} +python -B -u "./src/main.py" \ +--auto-login-admin \ +--auto-select \ +--sim-os-label-printer \ +--style windows \ +$* 2> >(sed $'s/.*/\e[31m&\e[m/' >&2) # & +# --about \ +# --archive \ +# --auto-login-user \ +# --autotests-archive \ +# --sim-archiver \ +# --sim-serial-label-printer \ +# --users-management \ +# sudo renice -n -10 $! +# fg diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/components/__init__.py b/src/components/__init__.py new file mode 100644 index 0000000..b0ec1b2 --- /dev/null +++ b/src/components/__init__.py @@ -0,0 +1,6 @@ +from .archive_synchronizer import ArchiveSynchronizer +from .os_label_printer import Os_Label_Printer +from .remote_api import RemoteAPI +from .serial_label_printer import Serial_Label_Printer +from .test_component import TestComponent +from .vision_saver import VisionSaver diff --git a/src/components/archive_synchronizer.py b/src/components/archive_synchronizer.py new file mode 100644 index 0000000..b08c973 --- /dev/null +++ b/src/components/archive_synchronizer.py @@ -0,0 +1,109 @@ +import json +import re +import sys +import traceback + +import requests +from google.api_core.exceptions import Forbidden +from google.cloud import storage +from lib.db import Archive, db +from PyQt5.QtCore import QThread +from requests.adapters import HTTPAdapter, Retry +from urllib3.exceptions import InsecureRequestWarning + +from .component import Component + +# Suppress insecure request warning +requests.packages.urllib3.disable_warnings(category=InsecureRequestWarning) + + +class ArchiveSynchronizer(Component): + def __init__(self, config=None, name=None, period=1, lazy=True, paused=False, threaded=True): + super().__init__(config=config, name=name, period=period, lazy=lazy, paused=paused, threaded=threaded) + self.simulate = "--sim-archiver" in sys.argv + + def config_changed(self): + self.machine_id = self.config.machine_id + config = self.config["archive_synchronizer"] + self.archive_endpoint = config["archive_endpoint"] + self.images_path = config["images_path"] + self._do_set_period({"period": float(config["poll_time"])}) + self.hold_time = round(float(config["hold_time"]) * 1000) + self.gcs_client = storage.Client.from_service_account_json(config["service_account_json"]) + self.gcs_client._http.mount("", HTTPAdapter(max_retries=Retry(total=0))) # this seems to be useless + self.gcs_client._http.adapters.move_to_end("", last=False) # this seems to be useless + self.bucket_id = config["bucket_id"] + self.gcs_bucket = None + + @db.connection_context() + @db.atomic() + def _get(self): + for record in Archive.select().where((Archive.archived != True) | (Archive.uploaded != True)): # using "is not True" breaks the query. + if not self.simulate: + if record.archived is not True: + record.archived = self.remote_archive(record) is True + if record.uploaded is not True: + record.uploaded = self.remote_store(record) is True + record.save() + if self.hold_time > 0: + QThread.msleep(self.hold_time) + self.gcs_bucket = None + super()._get() + + def remote_archive(self, record): + try: + if not self.simulate: + with requests.Session() as s: + s.mount("", HTTPAdapter(max_retries=Retry(total=0))) # this disables retries + r = requests.post(self.archive_endpoint, params={ + "machine_id": self.machine_id, + "time": record.time.isoformat(), + "user": record.user.username, + "recipe": record.recipe.name, + "test_data": json.dumps(record.test_data), + "result": record.result, + "overridden": record.overridden, + }, timeout=5, verify=False) + if r.status_code != 200: + raise AssertionError("bad status response") + except AssertionError as e: + self.log.warning(f"id: {record.id}: failed to archive remotely: {str(e)}: {r.status_code}: {r.content}") + return False + except (requests.ConnectionError, requests.Timeout) as e: + self.log.warning(f"id: {record.id}: failed to archive remotely, archive_endpoint might be unreachable: {str(e)}") + return False + except Exception: + self.log.error(f"id: {record.id}: failed to archive remotely:\n{traceback.format_exc()}:\n{r.status_code}: {r.content}") + return False + self.log.info(f"id: {record.id}: archived remotely") + return True + + def remote_store(self, record): + try: + self.gcs_bucket = self.gcs_client.get_bucket(self.bucket_id, timeout=(5, 30), retry=None) # retry=None seems to be ignored + except Exception: + self.log.warning(f"id {record.id}: failed to connect to bucket {repr(self.bucket_id)}:\n{traceback.format_exc()}") + self.gcs_bucket = None + if self.gcs_bucket is None: + return False + dt = record.vision_time + timestamp = dt.strftime(self.time_format) + img_in = f"{self.images_path}/frames/{dt.strftime('%Y')}/{dt.strftime('%m')}/{timestamp}.png" + img_out = f"{self.bench.type}/{dt.strftime('%Y')}/{dt.strftime('%m')}/{timestamp}.png" + try: + blob = self.gcs_bucket.blob(img_out) + if not self.simulate: + blob.upload_from_filename(filename=img_in) + except FileNotFoundError: + self.log.error(f"id {record.id}: {img_in}: file not found.") + return False + except Forbidden as e: + if re.match("^Object '.*?' is subject to bucket's retention policy and cannot be deleted, overwritten or archived until .*?$", e._response.json()["error"]["message"]) is not None: + self.log.info(f"id {record.id}: {img_in}: already stored.") + else: + self.log.warning(f"id: {record.id}: failed to store remotely: {str(e)}: {e._response.json()}") + return False + except Exception: + self.log.error(f"id: {record.id}: failed to store remotely:\n{traceback.format_exc()}") + self.log.info(f"id: {record.id}: stored remotely") + return True diff --git a/src/components/component.py b/src/components/component.py new file mode 100644 index 0000000..63a5b32 --- /dev/null +++ b/src/components/component.py @@ -0,0 +1,226 @@ +import logging + +from lib.helpers import timing +from PyQt5.QtCore import QObject, QSemaphore, Qt, QTimer, pyqtSignal + + +class Component(QObject): + out = pyqtSignal(list) + _pause = pyqtSignal() + _resume = pyqtSignal() + _set_sources = pyqtSignal(dict) + _set_period = pyqtSignal(dict) + + def __init__( + self, + config=None, + name=None, + period=None, # period to call _get + lazy=True, # whether or not accumulate periodic _get calls if falling behind + paused=False, + threaded=True, + ): + super().__init__() + self.config = config + self.name = name if name is not None else str(id(self)) + self._threaded = threaded + self._period = period + self._single_shot = lazy + self._paused = paused + self._started = False + self._running = False + self.sources = {} + if self._threaded: + self._lock = QSemaphore(1) + self._lock.acquire(max(self._lock.available(), 1)) + self._timer = None + self.log = logging.getLogger(f"{self.__class__.__name__} ({self.name})") + if not self._threaded: + self.start() + + def _config_changed(self): + self.log.info("reconfigure") + self.config_changed() + self.log.debug(f"config: {self.config}") + + def config_changed(self): + pass + + def start(self): + self._pause.connect(self._do_pause) + self._resume.connect(self._do_resume) + self._set_sources.connect(self._do_set_sources) + self._set_period.connect(self._do_set_period) + self.config.updated.connect(self._config_changed) + self._config_changed() + self._init_periodic() + self._started = True + if not self._paused: + self._do_resume() + elif self._threaded: + self._lock.release() + self.log.info("started") + + @property + def started(self): + if self._threaded: + self._lock.acquire(max(self._lock.available(), 1)) + started = self._started + if self._threaded: + self._lock.release() + return started + + @property + def running(self): + if self._threaded: + self._lock.acquire(max(self._lock.available(), 1)) + running = self._running + if self._threaded: + self._lock.release() + return running + + def wait_ready(self, timeout=5): + if self._threaded: + timeout = round(timeout * 1000) + if self._lock.tryAcquire(max(self._lock.available(), 1), timeout): + self._lock.release() + else: + self._lock.release() + raise RuntimeError(f"{self.name} was not ready before timeout of {timeout}ms") + + def pause(self): + if self._threaded: + self._lock.acquire(max(self._lock.available(), 1)) + if self._running is False: + if self._threaded: + self._lock.release() + return + if self._threaded: + self._pause.emit() + self.wait_ready() + else: + self._do_pause() + + def resume(self): + if self._threaded: + self._lock.acquire(max(self._lock.available(), 1)) + if self._running is True: + if self._threaded: + self._lock.release() + return + if self._threaded: + self._resume.emit() + self.wait_ready() + else: + self._do_resume() + + def set_sources(self, sources=None): # sources should be {"source_name": signal_to_connect} + if self._threaded: + self._lock.acquire(max(self._lock.available(), 1)) + self._set_sources.emit(sources) + self.wait_ready() + else: + self._do_set_sources(sources) + + def _init_periodic(self): + if self._period is not None: + if self._timer is None: + self._timer = QTimer() + self._timer.setTimerType(Qt.PreciseTimer) + self._timer.setSingleShot(self._single_shot) + self._timer.setInterval(round(self._period * 1000)) + self.log.debug(f"init periodic: period: {self._period}, single shot: {self._single_shot}") + else: + self.log.debug("no init periodic") + + def set_period(self, period=None, lazy=True): + if self._threaded: + self._lock.acquire(max(self._lock.available(), 1)) + self._set_sources.emit({"period": period, "lazy": lazy}) + self.wait_ready() + else: + self._do_set_period({"period": period, "lazy": lazy}) + + def _start_periodic(self): + if self._timer is not None: + self._timer.timeout.connect(self._get) + self._timer.start() + self.log.debug(f"started periodic: {list(self.sources)}") + else: + self.log.debug("no started periodic") + + def _stop_periodic(self): + if self._timer is not None: + self._timer.stop() + try: + self._timer.timeout.disconnect() + except TypeError: + pass + self.log.debug(f"stopped periodic: {list(self.sources)}") + else: + self.log.debug("no stopped periodic") + + def _connect_sources(self): + if self.sources is not None: + for source in self.sources.values(): + source.connect(self._get) + self.log.debug(f"connected sources: {list(self.sources)}") + else: + self.log.debug("no connected sources") + + def _disconnect_sources(self): + if self.sources is not None: + for source in self.sources.values(): + try: + source.disconnect() + except TypeError: + pass + self.log.debug(f"disconnected sources: {list(self.sources)}") + else: + self.log.debug("no disconnected sources") + + def _do_resume(self): + self._start_periodic() + self._connect_sources() + self._running = True + self.log.info("resumed") + if self._threaded: + self._lock.release() + + def _do_pause(self): + self._stop_periodic() + self._disconnect_sources() + self._running = False + self.log.info("paused") + if self._threaded: + self._lock.release() + + def _do_set_sources(self, sources): + if self._running: + self._disconnect_sources() + self.sources = sources + if self._running: + self._connect_sources() + self.log.info("set sources") + if self._threaded: + self._lock.release() + + def _do_set_period(self, spec): + self._period = spec.get("period", None) + self._single_shot = spec.get("lazy", True) + self._init_periodic() + self.log.info("set period") + if self._threaded: + self._lock.release() + + def _get(self, data=None): + if data is None: + data = [None] + got = [{"time": timing(), self.name: d} for d in data] + self.out.emit(got) + self.log.debug(f"_get: {got}") + if self._single_shot: + self._timer.start() + + def set(self, val): + self.log.debug(f"set: {val}") diff --git a/src/components/os_label_printer.py b/src/components/os_label_printer.py new file mode 100644 index 0000000..34198b8 --- /dev/null +++ b/src/components/os_label_printer.py @@ -0,0 +1,49 @@ +import subprocess +import sys + +from PyQt5.QtWidgets import QMessageBox + +from .component import Component + + +class Os_Label_Printer(Component): + def __init__(self, config=None, name=None): + super().__init__(config=config, name=name, threaded=False) + if "--sim-os-label-printer" in sys.argv: + self.simulate = True + + def config_changed(self): + self.platform = self.config["label_printer"]["platform"] + # for windows: + # cmd + # wmic printer list brief + # powershell + # Get-Printer + # for cups (linux, osx) + # lpstat -p -d + self.printer = self.config["label_printer"]["printer"] + + def print_label(self, template, archived): + # LOAD LABEL TEMPLATE + with open(f"config/label_templates/{template}.prn") as f: + label = f.read() + # LABEL PRINT + label = label.replace("$PH1", archived.barcode).replace("$PH2", archived.barcode) + label_file = "a" + if self.platform == "windows": + cmd = f'print /d:"{self.printer}" "{label_file}"' + elif self.platform == "cups": + cmd = f'lp -d "{self.printer}" "{label_file}"' + else: + raise NotImplementedError(f"platform {self.platform!r} is not supported") + if not self.simulate: + p = subprocess.run(cmd, stderr=subprocess.STDOUT, stdout=subprocess.PIPE, shell=True) # unsafe + if p.returncode != 0: + self.log.exception(f"failed to print: returncode: {p.returncode}\noutput:\n{p.stdout}") + QMessageBox.critical( + None, + "Errore Stampante", + f"Non e stato possibile stampare l'etichetta.\n\nErrore:\nreturncode: {p.returncode}\noutput:\n{p.stdout}" + ) + return False + return True diff --git a/src/components/remote_api.py b/src/components/remote_api.py new file mode 100644 index 0000000..49099a8 --- /dev/null +++ b/src/components/remote_api.py @@ -0,0 +1,155 @@ +import json +import os +import shlex +import subprocess +import tarfile +import traceback + +import cv2 +from bottle import post, request, response, route, run +from PyQt5.QtCore import QObject, pyqtSignal, pyqtSlot +from PyQt5.QtWidgets import QMessageBox + +from .component import Component + + +def aupdate_available_msg(): + QMessageBox.warning( + None, + "Aggiornamento disponibile", + "È disponibile un agiornamento per il banco.\nRiavviare il programma appena possibile.", + ) + + +class RemoteAPI(Component): + api_cmd = pyqtSignal(str) + + def __init__(self, config=None, name=None, period=1, lazy=True, paused=False, main=None, threaded=True): + super().__init__(config=config, name=name, period=period, lazy=lazy, paused=paused, threaded=threaded) + self.main = main + + @pyqtSlot() + def start(self): + @route("/") + def root(): + return "Usage: /api/<cmd>" + + @route("/api/identify") + def identify(): + return f"{self.config.system_id!r} as {self.config.machine_id!r}" + + # @route("/api/profile") + # def profile(): + # response.content_type = "application/json" + # return json.dumps(self.parent.watcher.watches) + + # @route("/api/get_det") + # def get_det(): + # response.content_type = "application/json" + # return json.dumps(self.parent.processed_detections) + + # @route("/api/get_img") + # def get_img(): + # type = request.query.type or None + # if type in ["original", "o", "frame", "f"]: + # img = self.bench.inputs["frame"].last_frame + # elif type in ["gui", "g"]: + # img = self.bench.inputs["frame"].last_frame_scaled + # elif type in ["terminals", "t"]: + # img = self.bench.inputs["terminals"].last_frame + # elif type in ["wires", "w"]: + # img = self.bench.inputs["wires"].last_frame + # elif type in ["drawn", "d"]: + # img = self.bench.inputs["renderer"].last_drawn + # elif type in ["zoomed", "z"]: + # img = self.bench.inputs["renderer"].last_zoomed + # else: + # img = self.bench.inputs["frame"].last_frame_scaled + # response.content_type = "image/jpeg" + # _, img = cv2.imencode(".jpeg", cv2.cvtColor(img, cv2.COLOR_RGB2BGR)) + # return img.tobytes() + + # @route("/api/get_plc") + # def get_plc(): + # response.content_type = "application/json" + # return json.dumps(self.parent.plc.get_all_vars()) + + # @route("/api/set_model") + # @post("/api/set_model") + # def set_model(): + # name = request.query.name or None + # if name is None: + # response.status = 400 + # return "missing model 'name' parameter" + # try: + # self.parent.vision_test.load_model(name) # very unsafe + # return "ok" + # except Exception: + # e = traceback.format_exc() + # self.log.exception(e) + # response.status = 400 + # return e + + # @route("/api/new_model") + # @post("/api/new_model") + # def new_model(): + # file = request.files.get("model") or None + # if file is None: + # response.status = 400 + # return "missing 'model' parameter" + # try: + # with tarfile.open(mode="r", fileobj=file.file) as file: + # file.extractall("neural_networks") + # return "ok" + # except Exception: + # e = traceback.format_exc() + # self.log.exception(e) + # response.status = 400 + # return e + + @route("/api/git") + @post("/api/git") + def git(): + try: + parameters = request.forms.get("command", "") + cmd_str = f"git {parameters}" + cmd = shlex.split(cmd_str) # very important + r = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + response.content_type = "application/json" + return json.dumps({ + "command": cmd_str, + "result": r.stdout.decode("utf-8"), + "return_code": r.returncode, + }) + except Exception: + e = traceback.format_exc() + self.log.exception(e) + response.status = 400 + return e + + @route("/api/auto_update") + def auto_update(): + try: + r = subprocess.run(shlex.split("git diff --exit-code"), stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if r.returncode != 0: + response.status = 409 + response.content_type = "text/plain" + return r.stdout.decode("utf-8") + r = subprocess.run(shlex.split("git pull"), stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + if r.returncode != 0: + response.status = 409 + response.content_type = "text/plain" + return r.stdout.decode("utf-8") + self.main.main_window.do.emit({"f": aupdate_available_msg}) + return "ok" + except Exception: + e = traceback.format_exc() + self.log.exception(e) + response.status = 400 + return e + + super().start() + run(host="0.0.0.0", port=8080) + + def update_data(self, new_data): + self.current_data = new_data diff --git a/src/components/serial_label_printer.py b/src/components/serial_label_printer.py new file mode 100644 index 0000000..db32926 --- /dev/null +++ b/src/components/serial_label_printer.py @@ -0,0 +1,45 @@ +import sys + +if "--sim-serial-label-printer" in sys.argv: + import lib.dummies.serial as serial +else: + import serial + +from PyQt5.QtWidgets import QMessageBox + +from .component import Component + + +class Serial_Label_Printer(Component): + def __init__(self, config=None, name=None): + super().__init__(config=config, name=name, threaded=False) + + def config_changed(self): + self.address = self.config["label_printer"]["address"] + self.baudrate = int(self.config["label_printer"]["baudrate"]) + self.stopbits = getattr(serial, self.config["label_printer"].get("stopbits", "stopbits_one").upper()) + self.parity = getattr(serial, self.config["label_printer"].get("parity", "parity_none").upper()) + self.bytesize = getattr(serial, self.config["label_printer"].get("bytesize", "eightbits").upper()) + + def print_label(self, template, archived): + # LOAD LABEL TEMPLATE + with open(f"config/label_templates/{template}.prn") as f: + label = f.read() + # LABEL PRINT + label = label.replace("$PH1", archived.barcode).replace("$PH2", archived.barcode) + try: + conn = serial.Serial( + self.address, + baudrate=self.baudrate, + stopbits=self.stopbits, + parity=self.parity, + bytesize=self.bytesize, + ) + conn.write(bytes(label, encoding="ascii")) + conn.close() + except serial.serialutil.SerialException as e: + QMessageBox.critical( + None, + "Errore Connessione Stampante", + "Non e stato possibile connettersi alla stampante di etichette\nL'etichetta non verra stampata.\n\nErrore:\n" + str(e) + ) diff --git a/src/components/tecna_marposs_provaset_t3.py b/src/components/tecna_marposs_provaset_t3.py new file mode 100644 index 0000000..f70b84c --- /dev/null +++ b/src/components/tecna_marposs_provaset_t3.py @@ -0,0 +1,120 @@ +import sys +import traceback + +import serial +from lib.helpers import timing +from pymodbus.constants import Endian +from pymodbus.exceptions import ModbusIOException +from PyQt5.QtCore import QMutex, Qt, QThread, QTimer, pyqtSlot + +from .atv320_registers import registers + +if "--sim-inverter" not in sys.argv: + from pymodbus.client.sync import ModbusSerialClient as ModbusClient +else: + from lib.dummies.pymodbus import ModbusClient + +from random import random + +from .component import Component + +# from pymodbus.client.sync import ModbusSerialClient as ModbusClient +# import serial +# client = ModbusClient(method="rtu", port="COM3", stopbits=serial.STOPBITS_ONE, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE, baudrate=115200, timeout=1, strict=False) +# client.connect() +# client.read_holding_registers(1, count=1) + + +class TecnaMarpossProvasetT3(Component): + def __init__(self, config=None, name=None, period=1, lazy=True, paused=False, threaded=True): + super().__init__(config=config, name=name, period=period, lazy=lazy, paused=paused, threaded=threaded) + self.lock = QMutex() + + def config_changed(self): + self.address = self.config["tecna_marposs_provaset_t3"]["address"] + self.baudrate = int(self.config["tecna_marposs_provaset_t3"]["baudrate"]) + self.stopbits = getattr(serial, self.config["tecna_marposs_provaset_t3"].get("stopbits", "stopbits_one").upper()) + self.parity = getattr(serial, self.config["tecna_marposs_provaset_t3"].get("parity", "parity_none").upper()) + self.bytesize = getattr(serial, self.config["tecna_marposs_provaset_t3"].get("bytesize", "eightbits").upper()) + self.timeout = int(self.config["tecna_marposs_provaset_t3"].get("timeout", 1)) + self.lock.lock() + self.client = ModbusClient( + method="rtu", + port=self.address, + stopbits=self.stopbits, + bytesize=self.bytesize, + parity=self.parity, + baudrate=self.baudrate, + timeout=self.timeout, + strict=False + ) + if not self.client.connect(): + raise ConnectionError("device not reachable (could not connect): {} ({})".format(self.name, self.port)) + if not self.client.is_socket_open(): + raise ConnectionError("device not reachable (socket not open): {} ({})".format(self.name, self.port)) + self.lock.unlock() + self.registers = registers + self.last_get = {} + self.last_set = {} + self.last_error = 0 + + def _read(self, register): + if type(register) is str: + r = self.registers[register] + else: + r = register + self.lock.lock() + read = self.client.read_holding_registers(r, count=1) + self.lock.unlock() + if read.isError(): + self.log.exception(traceback.format_exception(read)) + else: + return read.registers[0] + + decoder = BinaryPayloadDecoder.fromRegisters(result.registers, byteorder=Endian.Big, wordorder=Endian.Big) + + @staticmethod + def tob(r, n=16): + if r is None: + return None + return "{0:0{n}b}".format(r, n=n) + + @pyqtSlot() + def _get(self): + # print("ATV320", str(int(QThread.currentThreadId())), flush=True) + # READ INFO + info = { + "motor speed": self._read("RFRD"), + } + self.last_get = info + self.last_get = self.last_set + self.update.emit([{"time": timing(), self.name: self.last_get}]) + self._timer.start() + + def set(self, register, value): + if type(register) is str: + r = self.registers[register] + else: + r = register + builder = BinaryPayloadBuilder(byteorder=Endian.Big, wordorder=Endian.Big) + self.lock.lock() + wrote = self.client.write_register(r, value) + self.lock.unlock() + if wrote.isError(): + self.log.exception(traceback.format_exception(wrote)) + + def run_motor(self, rpm): + self.set(rpm, "LFRD") + self.set("on") + self.set("enable") + + def pause_motor(self): + self.set("disable") + self.set("off") + self.set("stop") + + def __del__(self, event=None): + self.lock.lock() + if self.client.is_socket_open(): + self.client.close() + self.lock.unlock() diff --git a/src/components/tecna_marposs_provaset_t3_registers.py b/src/components/tecna_marposs_provaset_t3_registers.py new file mode 100644 index 0000000..dac1e19 --- /dev/null +++ b/src/components/tecna_marposs_provaset_t3_registers.py @@ -0,0 +1,169 @@ +registers = { +1 U16 Instrument type +2 U32 Test counter: FAILED +4 U32 Test counter: TOTAL TESTS +6 U32 Life counter: FAILED +8 U32 Life counter: TOTAL TESTS +11 S32 Test circuit pressure, in real time +13 S32 Measured leak, in real time +15 S16 Regulated pressure, in real time +16 U16 Active alarm flags +21 U16 Relative pressure variable format - high resolution +22 U16 Format of the variables related to the measurement of the differential leak pressure +23 U16 Relative pressure variable format - low resolution +24 U16 Format of the variables related to the calculated leak flow +25 U16 Format of volume variables +26 U16 Format of time variables +27 U16 Format of variables related to flow measurements +31 U16 Instrument status: table of parameters in use +32 U16 Instrument status: active phase +# 0 = NO TEST EXECUTED: WAITING START NEW TEST +# 10 = TEST IN PROGRESS: CHECK BARCODE CODE (if enabled) +# 20 = TEST IN PROGRESS: WAITING READING BARCODE CODE (if enabled) +# 30 = TEST IN PROGRESS: TEST INITIALIZATION +# 40 = TEST IN PROGRESS: AUTOMATIC CLOSING 1 (CAGE) +# 50 = TEST IN PROGRESS: AUTOMATIC CLOSING 2 (PLUG) +# 60 = TEST IN PROGRESS: ACTIVE TEST PROGRAM INITIALIZATION +# 70 = TEST IN PROGRESS: WAITING ACKNOWLEDGE WITH BI-START COMMAND (if used) +# 80 = TEST IN PROGRESS: WAITING DELAY (PSDEL parameter) +# 90 = TEST IN PROGRESS: WAITING CONSENT SIGNAL (PSIN parameter) +# 100 = TEST IN PROGRESS: PHASE T0 - PRE-FILLING +# 110 = TEST IN PROGRESS: PHASE T1 - FILLING +# 120 = TEST IN PROGRESS: PHASE T2 - ASSESTMENT +# 130 = TEST IN PROGRESS: PHASE T3- SIZE +# 140 = TEST IN PROGRESS: WAITING CONFIRMATION EXAMINED BY OPERATOR (if used) +# 150 = TEST IN PROGRESS: TEST RESULT PRESENT +# 160 = TEST IN PROGRESS: MARKING (only if last table of a sequence or not in sequence mode) +# 170 = TEST IN PROGRESS: PRESSURE DISCHARGE +# 180 = TEST IN PROGRESS: END OF TESTING TABLE - IF IN SEQUENCE MODE THE CYCLE STARTS FROM +# PHASE 60 WITH THE NEXT TABLE OF THE SEQUENCE +# 190 = TEST IN PROGRESS: AUTOMATIC OPENING 2 (BUFFER) +# 200 = TEST IN PROGRESS: AUTOMATIC OPENING 1 (CAGE) +# 210 = TEST TERMINATED: WAITING THE START OF A NEW TEST +33 U16 Running test: active phase backwards time +34 S32 Running test: T1 phase end pressure +36 S32 Running test: T2 phase end pressure +38 S32 Running test: burst pressure +40 S32 Running test: measured leak +42 S32 Running test: calculated leak flow rate +44 S32 Running test: calculate RVP% +46 U16 Running test: result +# 1 = LEAK TEST PASSED +# 2 = BURST TEST PASSED WITH BURST +# 3 = BURST TEST PASSED WITHOUT BURST +# 4 = not used +# 5 = BLOCKAGE TEST PASSED +# 100 = LEAK TEST FAILED - UPPER LIMIT +# 101 = LEAK TEST – FAILED ANOMALY +# 102 = LEAK TEST - MAXIMUM LEAK FAILED +# 103 = BURST - BREAKAGE PRESSURE DEFLECTION +# 104 = VOLUMETRIC CONTROL - RVP% FAILED +# 105 = not used +# 106 = not used +# 107 = BLOCKAGE - MAX PRESSURE FAILED +# 108 = BLOCKAGE - MIN PRESSURE FAILED +# 109 = BURST - MINIMUM PRESSURE FAILED +# 200 = LEAK TEST FAILED – PR% PRESSURE MINUM +# 201 = LEAK TEST FAILED - PR% PRESSURE MAX +# 202 = LEAK TEST FAILED – P0% PRESSURE MINUM +# 203 = LEAK TEST FAILED - P0% PRESSURE MAX +# 204 = ERROR - INTERNAL ALARMS +# 205 = ERROR - RELATIVE PRESSURE OUT OF RANGE +# 206 = ERROR - DIFFERENTIAL PRESSURE OUT OF RANGE +# 207 = ERROR – PRE-FILLING VALVE NOT OPENED +# 250 = TEST ABORTED +47 U16 Running test: type of test +# 1 = LEAK TEST +# 2 = BLOCKAGE TEST +# 3 = LEAK TEST WITH VOLUME CHECK +# 4 = BURST TEST +48 U16 Testing in progress: progressive sequence index +49 U16 Testing in progress: graphical sampling rate +50 U16 Testing in progress: number of samples of the graph +601 U16 DISLPAY and SOUND: Language +# 0=ITALIANO +# 1=ENGLISH +602 U16 INSTRUMENT SETTING: Test table from +# 0=PARAMETER +# 1=PLC BASE 1 +# 2=PLC BASE 2 +606 U16 MEASURE UNITS: pressure measure units +# 0=mH2O 1=mbar 2=kPa 3=mmHg 4=inH2O 5=psi 6=mmH2O (se fondoscala <=6 bar) +607 U16 MEASURE UNITS: Leak measure units +# 0=mmH2O 1=mbar 2=Pa 3=mmHg 4=inH2O 5=psi +608 U16 MEASURE UNITS: leak flow rate measure units +# 0=cm3/min 1=cm3/h +609 U16 MEASURE UNITS: Volume +# 0=litri 1=cm3 +617 U16 MEASURE UNITS: Flow rate measure units +# 0=liters/min 1=liters/h 2=m3/h +618 U16 AUTOMATION: Cage - closing time +# Format: x.x seconds +619 U16 AUTOMATION: Cage - opening time +# Format: x.x seconds +620 U16 INSTRUMENT SETTINGS: MAX pressure +621 U16 AUTOMATION: Buffer - closing time +# Format: x.x seconds +622 U16 AUTOMATION: Buffer - opening time +# Format: x.x seconds +623 U16 AUTOMATION: Marking - result +# 0= Only passed +# 1= Only failed +# 2=All +624 U16 AUTOMATION: Marking – driving time +# Format: x.x seconds +701 U16 Type of test +# 1=LEAK TEST +# 2=BLOCKAGE TEST +# 3=LEAK TEST WITH VOLUME +# 4=BURST TEST +702 U16 Test flags +# T0/Pr: Phase filling mode T0 0= TIME 1= PRESSURE +# T1/Pr: Phase filling mode T1 0= TIME 1=PRESSURE +# T3/Q: Phase mode T3 0= TIME 1=TERMINATE IMMEDIATELY IF FAILED +# TYPE PID: 0=FAST 1=MEDIUM 2=SLOW 4 = FIXED 5 = AUTOMATIC 6= PULSES (if enabled SWLP option) +# P0-: 0=Positive P0 pressure 1=P0 negative pressure (if enabled V or N option for vacuum tests) +# Pr-: 0=Positive pressure Pr 1=Pr negative pressure (if enabled V or N option for vacuum tests) +# Q+: 0=Q + positive parameter 1=parameter Q + negative +# Q-: 0=Q-positive parameter 1=Q-negative parameter +# AT: 0=Tare pressure disabled 1=Tare pressure enabled +# HR: 0=Resolution on loss 1 Pa 1=Resolution on loss 0.1 Pa (models with full scale <= 2 bar) +# CH: Selected test channel (2-channel T3P2C model only) +704 U16 T0 – Pre-filling time +705 U16 P0 – Pre-filling pressure +# In order to use a negative (vacuum) value, this parameter must however be written positively, but the P0- bit must +# also be selected in register 702 +706 U16 T1 – Filling time +707 U16 T2 – Settling time +708 U16 T3 – Measure time +709 U16 PREL – Nominal test pressure +# To set a negative pressure (vacuum) this parameters must be written in absolute value and then set +# bit Pr- in register 702 +710 U16 PR%- Lower tolerance on pressure +# LEAK: Format: x.x % +# P- Minimum pressure BLOCKAGE: Format as indicated in register 23 +# PR- Minimum pressure% BURST: Format x.x% +711 U16 Q + Upper limit +# Format as indicated in the register 22 +712 U16 Q- Leak limit +# Format as indicated in the register 22 +713 U16 FST –discharge time +# Format as indicated in the register 26 +714 U16 VP – Equivalent volume +# Format as indicated in the register 25 +717 U16 P% Pressure tolerance +# BLOCKAGE: Format x.x % +743 U16 PB – Minimum burst pressure +# BURST: Format as indicated in the register 23 +744 U16 BD – Delta burst +# BURST: Format as indicated in the register 23 +745 U16 FSL – discharge limit +# Format as indicated in the register 23 +747 U16 PR% + Superior tolerance on pressure +# LEAK: Format: x.x % +# P + Pressure max BURST: Format as indicated in the register 23 +750 U16 RVP%: volumetric ratio +# VOLUME+LEAK: Format x.xx (from 0.00 to 649.99) +751 U16 RVP%: max tolerance +# VOLUME+LEAK: Format: x.xx +} diff --git a/src/components/test_component.py b/src/components/test_component.py new file mode 100644 index 0000000..3af4c2c --- /dev/null +++ b/src/components/test_component.py @@ -0,0 +1,27 @@ +from random import random + +from .component import Component + + +class TestComponent(Component): + def __init__( + self, + config=None, + name=None, + period=1, + lazy=True, + paused=False, + threaded=True, + ): + super().__init__( + config=config, + name=name, + period=period, + lazy=lazy, + paused=paused, + threaded=threaded, + ) + self.parameter = self.config["test"]["parameter"] + + def _get(self, data=None): + super()._get([self.parameter] + [random() for i in range(2)]) diff --git a/src/components/vision_saver.py b/src/components/vision_saver.py new file mode 100755 index 0000000..e112906 --- /dev/null +++ b/src/components/vision_saver.py @@ -0,0 +1,70 @@ +import glob +import os +import shutil +from datetime import datetime +from pathlib import Path + +import cv2 +import numpy as np + +from .component import Component + + +class VisionSaver(Component): + def __init__(self, config=None, name=None): + super().__init__(config=config, name=name, threaded=False) + + def config_changed(self): + self.location = Path(self.config["vision_saver"]["path"]) + os.makedirs(self.location, exist_ok=True) + self.mask_zones = self.config["vision_saver"].get("mask_zones", None) + self.minimum_disk_free_space_gb = self.config["vision_saver"].get("minimum_disk_free_space_gb", None) + if self.minimum_disk_free_space_gb is not None: + self.minimum_disk_free_space_gb = float(self.minimum_disk_free_space_gb) + self.time_format = self.config["vision_saver"]["time_format"] + + def save(self, save_time, img, mask=True): + timestamp = datetime.fromtimestamp(save_time).strftime(self.time_format) + save_dir = self.location / save_time.strftime("%Y") / save_time.strftime("%m") + os.makedirs(save_dir, exist_ok=True) + out_path = save_dir / f"{timestamp}.png" + self.log.info(f"saving {out_path}") + img = cv2.cvtColor(img, cv2.COLOR_RGB2BGR) + if mask: + height, width, channels = img.shape + out = np.full( + [height, width, channels], + [0] * channels + ) + for zone_name in self.mask_zones: + zone = self.bench.zones[zone_name]["box"] + out[zone[1]:zone[3], zone[0]:zone[2]] = img[zone[1]:zone[3], zone[0]:zone[2]] + else: + out = img + cv2.imwrite(out_path, out) + return out_path + + def remove_older_images_if_needed(self): + if self.minimum_disk_free_space_gb is None: + return + minimum_disk_free_bytes = self.minimum_disk_free_space_gb * 10**9 + archive = os.path.abspath(self.location) + free = shutil.disk_usage(archive)[-1] + if free < minimum_disk_free_bytes: + self.log.warning(f"LOW DISK SPACE {(free / 10 ** 9):3.2f}GB/{(minimum_disk_free_bytes / 10 ** 9):3.2f}GB), removing older vision saves") + sections = sorted([os.path.dirname(section) for section in glob.glob(f"{archive}/*/")]) + years = sorted({os.path.basename(os.path.dirname(year)) for section in sections for year in glob.glob(f"{section}/*/")}) + while free < minimum_disk_free_bytes and len(years) > 0: + year = years.pop(0) + months = sorted({os.path.basename(os.path.dirname(month)) for section in sections for month in glob.glob(f"{section}/{year}/*/")}) + while free < minimum_disk_free_bytes and len(months) > 0: + month = months.pop(0) + for section in sections: + self.log.info(f"REMOVING '{section}/{year}/{month}'") + shutil.rmtree(f"{section}/{year}/{month}", ignore_errors=True) + free = shutil.disk_usage(archive)[-1] + if len(months) == 0: + for section in sections: + self.log.info(f"REMOVING '{section}/{year}'") + shutil.rmtree(f"{section}/{year}", ignore_errors=True) + free = shutil.disk_usage(archive)[-1] diff --git a/src/lib/db/__init__.py b/src/lib/db/__init__.py new file mode 100644 index 0000000..6d8ae33 --- /dev/null +++ b/src/lib/db/__init__.py @@ -0,0 +1,89 @@ +import ast +import csv +import json +import logging + +from playhouse.sqlite_ext import JSONField + +from .models import Archive, Autotests, Log, Recipes, Session, Users, db + +models_reference = { + "archive": Archive, + "autotests": Autotests, + "log": Log, + "recipes": Recipes, + "users": Users, +} + +db.connect() +db.create_tables(list(models_reference.values())) + +log = logging.getLogger("db") + + +def init_db(): + tables = db.get_tables() + tables.sort() + for table in tables: + count = 0 + try: + with open("src/lib/db/imports/{}.csv".format(table), "r") as f: + table = models_reference[table] + fields = list(table._meta.fields) + log.info(f"importing {table._meta.table_name}") + reader = csv.DictReader(f) + for row in reader: + obj = {} + for field in fields: + if type(table._meta.fields[field]) is JSONField: + obj[field] = json.loads(row[field]) + else: + try: + obj[field] = ast.literal_eval(row[field]) + except (SyntaxError, ValueError): + obj[field] = row[field] + table.insert(**obj).on_conflict_replace().execute() + count += 1 + log.info(f"{table._meta.table_name}: imported {count} rows.") + except FileNotFoundError: + pass + + +init_db() + + +# register or reset admin +admin = Users.get_user("ADMIN") +if admin is None or not admin.is_admin: + Users.register(username="ADMIN", password="123123", roles=["admin"]) +# register or reset user +Users.register(username="USER", password="user") +# register test recipe +Recipes.replace(id=0, name="TEST", spec={ + # recipe + "client": "TEST_CLIENT", + "part_number": "TEST_PART_NUMBER", + "station": "TEST_STATION", + # pressure + "pressure_min": 0, + "pressure_test": 1, + "pressure_max": 2, + "pressure_ramp": 3, + # test + "cleaning_time": 4, + "tolerance": 5, + "test_duration": 6, + "flush_duration": 7, + # stabilizarion + "stabilization_time": 8, + "stabilization_level_min": 9, + "stabilization_level_max": 10, + "stabilization_settling_time": 11, + "stabilization_cycles": 12, + # description + "description": "TEST_DESCRIPTION", +}, archived=False).execute() + +if True: + # crud_db must be imported after db and models_reference are available + from .crud_db import Crud_DB diff --git a/src/lib/db/crud_db.py b/src/lib/db/crud_db.py new file mode 100755 index 0000000..6ae47e5 --- /dev/null +++ b/src/lib/db/crud_db.py @@ -0,0 +1,88 @@ +from peewee import TextField +from playhouse.shortcuts import model_to_dict + +from . import db, models_reference + + +class Crud_DB: + def __init__(self, table_name, select=None, filters=None, pagination=250): + self.table_model = models_reference[table_name] + self.table_pk = self.table_model._meta.primary_key + self.table_fields = list(self.table_model._meta.fields.keys()) + self.pagination = pagination + self.default_filters = {} + if filters is None: + filters = {} + for column_name, filter in filters.items(): + self.filter(column_name, filter, filter_storage=self.default_filters) + self.revert() + + def commit(self, data, deleted_rows=None): + with db.atomic() as trx: + try: + self.table_model.delete().where(self.table_pk << deleted_rows).execute() + # SQLITE DOES NOT SUPPORT UPDATE, ONLY REPLACE + complete_data = [] + for rn, row in enumerate(data): + pk = row[self.table_pk.name] + if pk is not None: + db_row = model_to_dict(self.table_model.get_by_id(pk)) + db_row.update(row) + else: + db_row = row + complete_data.append(db_row) + self.table_model.insert_many(complete_data).on_conflict_replace().execute() + except Exception as e: + trx.rollback() + raise e + + def revert(self): + self.sorting = {} + self.filters = {} + + def get(self, page=0): + if self.table_model._meta.composite_key: + raise NotImplementedError("need to implement composite_key") + query = self.table_model.select() + if len(self.default_filters) + len(self.filters) > 0: + query = query.where(*self.default_filters.values(), *self.filters.values()) + if len(self.sorting) > 0: + query = query.order_by(*self.sorting.values()) + total_count = query.count() + last_page = int(total_count / self.pagination) + if page < 0: + page = last_page - page + 1 + page = min(max(page, 0), last_page) + self.page = page + if self.pagination is not False: + query = query.paginate(self.page + 1, self.pagination) + self.data = query.dicts().execute() + self.shown_keys = [r[self.table_pk.name] for r in self.data] + return self.data, total_count, self.page, last_page + + def sort(self, column_name, is_ascending=None, sorting_storage=None): + if sorting_storage is None: + sorting_storage = self.sorting + if column_name is not None: + if is_ascending is None: + sorting_storage.pop(column_name, None) + else: + field = self.table_model._meta.fields[column_name] + if not is_ascending: + field = -field + sorting_storage[column_name] = field + else: + sorting_storage.clear() + + def filter(self, column_name, filter, filter_storage=None): # only contains/equality filter + if filter_storage is None: + filter_storage = self.filters + if filter is None or filter == "": + if column_name in self.filters: + filter_storage.pop(column_name, None) + else: + field = self.table_model._meta.fields[column_name] + if type(field) is TextField: + filter_storage[column_name] = field.contains(filter) + else: + filter_storage[column_name] = field == filter diff --git a/src/lib/db/models/__init__.py b/src/lib/db/models/__init__.py new file mode 100644 index 0000000..c450943 --- /dev/null +++ b/src/lib/db/models/__init__.py @@ -0,0 +1,6 @@ +from .archive import Archive +from .autotests import Autotests +from .base_model import db +from .log import Log +from .recipes import Recipes +from .users import Session, Users diff --git a/src/lib/db/models/archive.py b/src/lib/db/models/archive.py new file mode 100644 index 0000000..7099d56 --- /dev/null +++ b/src/lib/db/models/archive.py @@ -0,0 +1,34 @@ +from datetime import datetime + +from peewee import AutoField, BooleanField, DateTimeField, ForeignKeyField +from playhouse.sqlite_ext import JSONField + +from .base_model import BaseModel, db +from .recipes import Recipes +from .users import Users + + +class Archive(BaseModel): + id = AutoField(primary_key=True, unique=True, null=False) + time = DateTimeField(unique=True, null=False, default=datetime.now) + user = ForeignKeyField(Users, Users.username, null=False) + recipe = ForeignKeyField(Recipes, null=False) + result = BooleanField(null=False) + overridden = BooleanField(null=False) + test_data = JSONField(null=False) + archived = BooleanField(null=False, default=False) + uploaded = BooleanField(null=False, default=False) + + @classmethod + @db.atomic() + def archive(cls, recipe, test_data, result, overridden): + return cls.create( + user=Users.get_session().user, + recipe=recipe, + test_data=test_data, + result=result, + overridden=overridden, + ) + + class Meta: + table_name = "archive" diff --git a/src/lib/db/models/autotests.py b/src/lib/db/models/autotests.py new file mode 100755 index 0000000..891f881 --- /dev/null +++ b/src/lib/db/models/autotests.py @@ -0,0 +1,39 @@ +from datetime import datetime + +from peewee import (AutoField, BooleanField, DateTimeField, ForeignKeyField, + TextField, fn) +from playhouse.sqlite_ext import JSONField + +from .base_model import BaseModel, db +from .recipes import Recipes +from .users import Users + + +class Autotests(BaseModel): + id = AutoField(primary_key=True, unique=True, null=False) + time = DateTimeField(unique=True, null=False, default=datetime.now) + user = ForeignKeyField(Users, Users.username, null=False) + recipe = ForeignKeyField(Recipes, null=False) + result = BooleanField(null=False) + overridden = BooleanField(null=False) + reason = TextField(null=False) + test_data = JSONField(null=False) + + @classmethod + @db.atomic() + def archive(cls, recipe, test_data, result, overridden, reason): + return cls.create( + user=Users.get_session().user, + recipe=recipe, + test_data=test_data, + result=result, + overridden=overridden, + reason=reason, + ) + + @staticmethod + def get_last_time(): + return Autotests.select(fn.MAX(Autotests.time)).scalar() + + class Meta: + table_name = "autotests" diff --git a/src/lib/db/models/base_model.py b/src/lib/db/models/base_model.py new file mode 100644 index 0000000..11a4dd7 --- /dev/null +++ b/src/lib/db/models/base_model.py @@ -0,0 +1,29 @@ +import os + +from peewee import Model +from playhouse.sqlite_ext import SqliteExtDatabase + +db_path = "./data/database" +os.makedirs(db_path, exist_ok=True) + +db = SqliteExtDatabase( + db_path + "/sqlite.db", + pragmas={ # see https://www.sqlite.org/pragma.html + "auto_vacuum": 1, + "busy_timeout": 5000, + "cache_size": round(-64e3), + "foreign_keys": 1, + "ignore_check_constraints": 0, + "journal_mode": "wal", + "synchronous": 0, + }, + timeout=5 +) + + +class BaseModel(Model): + """A base model that will use our Sqlite database.""" + + class Meta: + global db + database = db diff --git a/src/lib/db/models/log.py b/src/lib/db/models/log.py new file mode 100755 index 0000000..d3d231b --- /dev/null +++ b/src/lib/db/models/log.py @@ -0,0 +1,24 @@ +import logging +from datetime import datetime + +from peewee import AutoField, DateTimeField, TextField + +from .base_model import BaseModel, db + +log = logging.getLogger("db_log") + + +class Log(BaseModel): + id = AutoField(primary_key=True, unique=True, null=False) + time = DateTimeField(unique=True, null=False, default=datetime.now) + info_type = TextField(null=False) + info = TextField(null=True) + + @classmethod + @db.atomic() + def log(cls, info_type, info=None): + cls.create(info_type=info_type, info=info) + log.info(f"{info_type}: {info}") + + class Meta: + table_name = "log" diff --git a/src/lib/db/models/recipes.py b/src/lib/db/models/recipes.py new file mode 100644 index 0000000..ebb6f9f --- /dev/null +++ b/src/lib/db/models/recipes.py @@ -0,0 +1,20 @@ +from peewee import AutoField, BooleanField, TextField +from playhouse.sqlite_ext import JSONField + +from .base_model import BaseModel + + +class Recipes(BaseModel): + id = AutoField(primary_key=True, unique=True, null=False) + name = TextField(null=False) + spec = JSONField(null=False) # keys inside spec must not overlap withthe model + archived = BooleanField(null=False, default=False) + + @classmethod + def delete(cls, *args, **kwargs): + # OVERRIDE DELETION + # so that deleting a user will only archive it + return cls.update(archived=True) + + class Meta: + table_name = "recipes" diff --git a/src/lib/db/models/users.py b/src/lib/db/models/users.py new file mode 100755 index 0000000..623cb43 --- /dev/null +++ b/src/lib/db/models/users.py @@ -0,0 +1,151 @@ +import json +from functools import wraps + +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from lib.helpers import JSONEncoder +from peewee import AutoField, TextField +from playhouse.sqlite_ext import JSONField + +from .base_model import BaseModel, db +from .log import Log + +hasher = PasswordHasher() + +current_session = None # THIS SHOULD NEVER BE IMPORTED +# if imported it will not change unless you re-import it +# USE Users.get_session() INSTEAD. +# also this is not thread safe + + +class Session: + def __init__(self, user): + if type(user) is not Users: + raise AssertionError(f"user is wrong type: {type(user)} instead of {Users}") + self.user = user + self.username = self.user.username + self.roles = Users.parse_roles(self.user.roles) + self.is_admin = self.user.is_admin + + +def json_dumps_roles(roles): + return json.dumps(roles, cls=JSONEncoder) + + +def json_loads_roles(roles): + return Users.parse_roles(json.loads(roles)) + + +class Users(BaseModel): + id = AutoField(primary_key=True, unique=True, null=False) + username = TextField(unique=True, null=False) + password = TextField(null=False) # enable nulling to enable passwordless login + roles = JSONField(null=True, json_dumps=json_dumps_roles, json_loads=json_loads_roles) + + @classmethod + def get_users(cls): + return cls.select().where(cls.password != b"").execute() + + @classmethod + def get_usernames(cls): + return sorted(set([str(user.username) for user in cls.get_users()])) + + @classmethod + def get_user(cls, username): + return cls.get_or_none(cls.username == username, cls.password != b"") + + @classmethod + def generate(cls, username, password, roles=None): + if username is None or not len(username): + raise AssertionError("Il nome utente non può essere vuoto") + if password is None or not len(password): + raise AssertionError("La password non può essere vuota") + if any(map(lambda x: x == u"\u2022", password)): + raise AssertionError("La password non può contenere il carattere proibito: \u2022") + return dict(username=username, password=hasher.hash(password), roles=list(cls.parse_roles(roles))) + + @classmethod + @db.atomic() + def register(cls, username, password, roles=None): + generated_user = cls.generate(username=username, password=password, roles=roles) + cls.replace(**generated_user).execute() + Log.log("Sign up", "username: \"{}\", roles \"{}\"".format(username, roles)) + + @classmethod + def parse_roles(cls, roles=None): + if roles is None: + return set() + if type(roles) is str: + roles = set(map(lambda role: role.strip(" \n\r\t\v,").lower(), roles.split(","))) + roles = set(map(lambda role: role.lower(), roles)) + roles.discard(None) + roles.discard("") + return roles + + @classmethod + @db.atomic() + def login(cls, username, password): + Log.log("Login", f"Login attempt: username: {username!r}") + user = cls.get_user(username) + if user is None: + Log.log("Login", f"username: {username!r}: BAD username") + return False + if user.verify(password): + Log.log("Login", f"username: {user.username!r}: SUCCESSFUL, roles: {user.roles!r}") + global current_session + current_session = Session(user) + return current_session + else: + Log.log("Login", "username: {user.username!r}, password {password!r}: BAD password") + return False + + @classmethod + def get_session(cls): + global current_session + return current_session + + @db.atomic() + def verify(self, password): + if self.password is None: + return True + if self.password == "": + return False + try: + hasher.verify(self.password, password) + except VerifyMismatchError: + return False + if hasher.check_needs_rehash(self.password): + self.password = hasher.hash(password) + self.save() + return True + + @classmethod + @db.atomic() + def delete_by_username(cls, username): + cls.update(password="").where(cls.username == username).execute() + + @classmethod + def delete(cls, *args, **kwargs): + # OVERRIDE DELETION + # so that deleting a user will only disable it + return cls.update(password="") + + @property + def is_admin(self): + try: + return "admin" in self.parse_roles(self.roles) + except Exception: + return False + + class Meta: + table_name = "user" + + +def admin_login_required(func): + @wraps(func) + def function(*args, **kwargs): + session = Users.get_session() + if session is None or not session.is_admin: + raise RuntimeError("method not authorized for non superusers") + return func(*args, **kwargs) + return function diff --git a/src/lib/dummies/serial/__init__.py b/src/lib/dummies/serial/__init__.py new file mode 100755 index 0000000..31be0b6 --- /dev/null +++ b/src/lib/dummies/serial/__init__.py @@ -0,0 +1,5 @@ +import serial + +from .serial import Serial + +serial.Serial = Serial diff --git a/src/lib/dummies/serial/serial.py b/src/lib/dummies/serial/serial.py new file mode 100755 index 0000000..b5663fe --- /dev/null +++ b/src/lib/dummies/serial/serial.py @@ -0,0 +1,18 @@ +import logging + + +class Serial: + def __init__(self, *args, **kwargs): + self.log = logging.getLogger(f"dummy {self.__class__.__name__} ({id(self)})") + self.log.debug(f"initialized with *{args}, **{kwargs}") + + def write(self, d): + self.log.debug(f"write: {d!r}") + + def read(self, n): + d = b"\x00" * n + self.log.debug(f"read: {d!r}") + return d + + def close(self): + self.log.debug("close") diff --git a/src/lib/helpers/__init__.py b/src/lib/helpers/__init__.py new file mode 100644 index 0000000..ad38086 --- /dev/null +++ b/src/lib/helpers/__init__.py @@ -0,0 +1,20 @@ +from .config_reader import ConfigReader +from .custom_json_encoder import JSONEncoder +from .dict_merger import merge_dicts +from .get_nested import get_nested +from .performancer import clock_this, log_this, time_this +from .qthread_catcher import ReloadingBase, ReloadingWrapper +from .qthread_synchronizer import SynchronizingBase, SynchronizingWrapper +from .resources import get_path, get_resource +from .timing import timing + + +class ReloadingSynchronizingWrapper(ReloadingWrapper, SynchronizingWrapper): + def __init__(meta, classname, bases, old__dict__): + SynchronizingWrapper.__init__(meta, classname, bases, old__dict__) + ReloadingWrapper.__init__(meta, classname, bases, old__dict__) + + def __new__(meta, classname, bases, old__dict__): + s = SynchronizingWrapper.__new__(meta, classname, bases, old__dict__) + r = ReloadingWrapper.__new__(meta, classname, bases, old__dict__) + return diff --git a/src/lib/helpers/config_reader.py b/src/lib/helpers/config_reader.py new file mode 100644 index 0000000..8e8b9af --- /dev/null +++ b/src/lib/helpers/config_reader.py @@ -0,0 +1,113 @@ +import socket +from configparser import ConfigParser +from pathlib import Path + +from PyQt5.QtCore import QFileSystemWatcher, QObject, pyqtSignal + +from .dict_merger import merge_dicts + + +class ConfigReader(QObject): + updated = pyqtSignal() + + def __init__(self, system_id=None, *args, **kwargs): + super().__init__() + self.system_id = system_id if system_id is not None else socket.gethostname() + self._args, self._kwargs = args, kwargs + self.configs_dir = Path(".") / "config" / "machine_settings" + self._watcher = QFileSystemWatcher() + self._watcher.fileChanged.connect(self._update) + self.extra_paths = [] + self._update() + + def _update(self, *args, signal=True): + hostnames = self.read_config_file(self.configs_dir / "hostnames.ini") + config_paths = [self.configs_dir / "defaults.ini"] + specific_config = hostnames.get("hostnames", {}).get(self.system_id, None) + if specific_config is not None: + self.machine_id = specific_config + config_paths.append(self.configs_dir / f"{specific_config}.ini") + else: + self.machine_id = self.system_id + config_paths += self.extra_paths + # remove duplicate paths keeping their order (keeps the last) + config_paths = list(reversed(dict.fromkeys(reversed(config_paths)))) + values = {} + for config_path in config_paths: + config = self.read_config_file(config_path) + values = merge_dicts(values, config) + self._values = values + if signal: + self.updated.emit() + # replace watched paths + self._watcher.removePaths(self._watcher.directories() + self._watcher.files()) + self._watcher.addPaths(map(str, config_paths)) + + def read_config_file(self, config_path): + config_path = str(config_path) + config = ConfigParser(*self._args, **self._kwargs) + read = config.read(config_path) + if len(read) != 1 or config_path != read[0]: + raise AssertionError(f"Config file {config_path} could not be read.") + return dict(config._sections) + + def add_config_paths(self, paths): + self.extra_paths += paths + # remove duplicate paths keeping their order (keeps the last) + paths = list(reversed(dict.fromkeys(reversed(paths)))) + self._update() + + def remove_config_paths(self, paths): + # remove paths keeping order + self.extra_paths = list(dict.fromkeys(self.extra_paths).keys() - dict.fromkeys(paths).keys()) + self._update() + + # PROXY SOME _VALUES (DICT) METHODS TROUGH + + def __contains__(self, *args, **kwargs): + return self._values.__contains__(*args, **kwargs) + + def __eq__(self, *args, **kwargs): + return self._values.__eq__(*args, **kwargs) + + def __format__(self, *args, **kwargs): + return self._values.__format__(*args, **kwargs) + + def __getitem__(self, *args, **kwargs): + return self._values.__getitem__(*args, **kwargs) + + def __iter__(self, *args, **kwargs): + return self._values.__iter__(*args, **kwargs) + + def __ne__(self, *args, **kwargs): + return self._values.__ne__(*args, **kwargs) + + def __reduce__(self, *args, **kwargs): + return self._values.__reduce__(*args, **kwargs) + + def __reduce_ex__(self, *args, **kwargs): + return self._values.__reduce_ex__(*args, **kwargs) + + def __repr__(self, *args, **kwargs): + return self._values.__repr__(*args, **kwargs) + + def __reversed__(self, *args, **kwargs): + return self._values.__reversed__(*args, **kwargs) + + def __setitem__(self, *args, **kwargs): + return self._values.__setitem__(*args, **kwargs) + + def __str__(self, *args, **kwargs): + return self._values.__str__(*args, **kwargs) + + def get(self, *args, **kwargs): + return self._values.get(*args, **kwargs) + + def items(self, *args, **kwargs): + return self._values.items(*args, **kwargs) + + def keys(self, *args, **kwargs): + return self._values.keys(*args, **kwargs) + + def values(self, *args, **kwargs): + return self._values.values(*args, **kwargs) diff --git a/src/lib/helpers/custom_json_encoder.py b/src/lib/helpers/custom_json_encoder.py new file mode 100644 index 0000000..ba10123 --- /dev/null +++ b/src/lib/helpers/custom_json_encoder.py @@ -0,0 +1,8 @@ +import json + + +class JSONEncoder(json.JSONEncoder): + def default(self, obj): + if isinstance(obj, set): + return list(obj) + return super().default(obj) diff --git a/src/lib/helpers/dict_merger.py b/src/lib/helpers/dict_merger.py new file mode 100644 index 0000000..e65b94c --- /dev/null +++ b/src/lib/helpers/dict_merger.py @@ -0,0 +1,27 @@ +from collections.abc import MutableMapping, MutableSequence +from copy import deepcopy + + +def recursive_merge(a, b): + if type(a) is not type(b): + return b + if isinstance(a, MutableMapping): + for k, v in b.items(): + if k in a: + a[k] = merge_dicts(a[k], v) + else: + a[k] = v + elif isinstance(a, MutableSequence): + for i, v in enumerate(b): + if i < len(a): + a[i] = merge_dicts(a[i], v) + else: + a.append(v) + else: + return b + return a + + +def merge_dicts(a, b): + # merge a and b preferring b when items differ + return recursive_merge(deepcopy(a), deepcopy(b)) # do not change the passed mutables diff --git a/src/lib/helpers/get_nested.py b/src/lib/helpers/get_nested.py new file mode 100644 index 0000000..3586d0d --- /dev/null +++ b/src/lib/helpers/get_nested.py @@ -0,0 +1,16 @@ +from collections.abc import Hashable + + +def get_nested(data, key, raise_on_error=True): + if isinstance(key, Hashable): + key = [key] + v = data + try: + for k in key: + v = v[k] + return v + except (TypeError, KeyError, IndexError) as e: + if raise_on_error: + raise e + else: + return None diff --git a/src/lib/helpers/performancer.py b/src/lib/helpers/performancer.py new file mode 100644 index 0000000..92dda89 --- /dev/null +++ b/src/lib/helpers/performancer.py @@ -0,0 +1,40 @@ +from time import perf_counter + + +def time_this(f): + def wrapper(*arg, **kwargs): + ts = perf_counter() + res = f(*arg, **kwargs) + te = perf_counter() + if ts != te: + ms = (te - ts) * 1000 + print("{:<32} {:>8.3f}ms {:>8.2f}fps".format(f.__qualname__, ms, 1000 / ms), flush=True) + else: + print("{:<32} {:>8.3f}ms {:>8.2f}fps".format(f.__qualname__, 0, "inf"), flush=True) + return res + return wrapper + + +last = {} + + +def clock_this(f): + last[f] = perf_counter() + + def wrapper(*arg, **kwargs): + ts = perf_counter() + res = f(*arg, **kwargs) + msc = (ts - last[f]) * 1000 + print("{:<32} {:>8.3f}ms".format(f.__qualname__, msc), flush=True) + last[f] = ts + return res + return wrapper + + +def log_this(f): + def wrapper(*arg, **kwargs): + print("{:<32}".format(f.__qualname__), "begin", flush=True) + res = f(*arg, **kwargs) + print("{:<32}".format(f.__qualname__), "end", flush=True) + return res + return wrapper diff --git a/src/lib/helpers/qthread_catcher.py b/src/lib/helpers/qthread_catcher.py new file mode 100644 index 0000000..62c301b --- /dev/null +++ b/src/lib/helpers/qthread_catcher.py @@ -0,0 +1,143 @@ +import copy +import sys +import traceback +from functools import wraps +from types import FunctionType + +from PyQt5.QtCore import QCoreApplication, QObject, QThread, QTimer + + +def save_init_state(self, *args, **kwargs): + if "__init_args__" not in self.__dict__: + self.__init_args__ = (copy.deepcopy(args), copy.deepcopy(kwargs)) + if "__attributes__" not in self.__dict__: + self.__attributes__ = set() # to include __attributes__ in itself + self.__attributes__.update(dir(self)) + + +def reinit(self): + # for attr_name in set(dir(self)) - self.__attributes__: + # delattr(self, attr_name) + self.__init__(*self.__init_args__[0], **self.__init_args__[1]) + + +def try_run(self, function, *args, **kwargs): + try: + return function(*args, **kwargs) + except AssertionError as exception: + traceback.print_exc() + print(f"{repr(self)} threw {repr(exception)} on {repr(function)}(args={args}, kwargs={kwargs})") + print(f"reinitializing {repr(self)}.") + reinit(self) + return exception + + +def wrap(function, name): + if name == "__init__": + if function is not None: + @wraps(function) + def wrapper(self, *args, **kwargs): + save_init_state(self, *args, **kwargs) + return function(self, *args, **kwargs) + else: + def wrapper(self, *args, **kwargs): + save_init_state(self, *args, **kwargs) + return super(type(self), self).__init__(*args, **kwargs) + elif function is None: + return None + elif issubclass(type(function), FunctionType): + @wraps(function) + def wrapper(self, *args, **kwargs): + return try_run(self, function, self, *args, **kwargs) + elif issubclass(type(function), classmethod): + @wraps(function) + def wrapper(self, *args, **kwargs): + return try_run(self, function.__func__, self.__class__, *args, **kwargs) + elif issubclass(type(function), staticmethod): + @wraps(function) + def wrapper(self, *args, **kwargs): + return try_run(self, function.__func__, *args, **kwargs) + elif issubclass(type(function), property): + wrapper = property( + fget=wrap(function.fget, function.fget.__name__) if function.fget is not None else None, + fset=wrap(function.fset, function.fset.__name__) if function.fset is not None else None, + fdel=wrap(function.fdel, function.fdel.__name__) if function.fdel is not None else None, + doc=function.__doc__, + ) + else: + raise NotImplementedError(f"wrap for type {type(function)} not implemented") + return wrapper + + +class ReloadingWrapper(type(QObject), type): + def __new__(meta, classname, bases, old__dict__): + new__dict__ = {} + for name, attr in old__dict__.items(): + if name != "__init__": + if issubclass(type(attr), FunctionType) or issubclass(type(attr), classmethod) or issubclass(type(attr), staticmethod) or issubclass(type(attr), property): + attr = wrap(attr, name) + new__dict__[name] = attr + new__dict__["__init__"] = wrap(new__dict__.get("__init__", None), "__init__") + # return super(ReloadingWrapper, meta).__new__(meta, classname, bases, new__dict__) + return type.__new__(meta, classname, bases, new__dict__) + + +class ReloadingBase(QObject, metaclass=ReloadingWrapper): + pass + + +if __name__ == "__main__": + + class TObject(QObject): + def __init__(self, *args, **kwargs): + print("TObject.__init__", self, args, kwargs) + super().__init__() + + class Thrower(TObject, metaclass=ReloadingWrapper): + def __init__(self, *args, **kwargs): + print("Thrower.__init__", self, args, kwargs) + super().__init__(*args, **kwargs) + + def start(self): + self.timer = QTimer() + self.timer.setInterval(1000) + self.timer.timeout.connect(self.throw) + self.timer.timeout.connect(self.throw_classmethod) + self.timer.timeout.connect(self.throw_staticmethod) + self.timer.timeout.connect(lambda: self.throw_property) + self.timer.start() + + def throw(self): + raise AssertionError("error") + + @classmethod + def throw_classmethod(cls): + raise AssertionError("error") + + @staticmethod + def throw_staticmethod(): + raise AssertionError("error") + + @property + def throw_property(self): + raise AssertionError("error") + + app = QCoreApplication(sys.argv) + + thrower = Thrower("test_arg", test_kwarg="test_kwarg") + + thread = QThread() + thread.setTerminationEnabled(True) + thrower.moveToThread(thread) + thread.started.connect(thrower.start) + thread.start() + + timer = QTimer() + timer.setInterval(1000) + timer.timeout.connect(thrower.throw) + timer.timeout.connect(thrower.throw_classmethod) + timer.timeout.connect(thrower.throw_staticmethod) + timer.timeout.connect(lambda: thrower.throw_property) + timer.start() + + sys.exit(app.exec_()) diff --git a/src/lib/helpers/qthread_synchronizer.py b/src/lib/helpers/qthread_synchronizer.py new file mode 100644 index 0000000..5115aa8 --- /dev/null +++ b/src/lib/helpers/qthread_synchronizer.py @@ -0,0 +1,156 @@ +import sys +from functools import wraps +from queue import Queue +from types import FunctionType + +from PyQt5.QtCore import QCoreApplication, QObject, QSemaphore, QThread, QTimer + + +def thread_command_execute(self): + while not self.__thread_command_queue.empty(): # only one consumer so no lock required + q = self.__thread_command_queue.get() + q["r"] = q["f"](*q["a"], **q["k"]) + q["s"].release(1) # mark as consumed + self.__thread_command_timer.start() + + +def create_thread_command_executor(self): + self.__thread_command_id = int(QThread.currentThreadId()) + self.__thread_command_queue = Queue() + self.__thread_command_timer = QTimer() + # self.__thread_command_timer.setTimerType(Qt.PreciseTimer) + self.__thread_command_timer.setInterval(10) + self.__thread_command_timer.setSingleShot(True) + self.__thread_command_timer.timeout.connect(self._thread_command_execute) + self.__thread_command_timer.start() + + +def run_or_enqueue(self, function, *args, **kwargs): + if self.__thread_command_id is None or int(QThread.currentThreadId()) == self.__thread_command_id: + return function(*args, **kwargs) + else: + s = {"f": function, "a": args, "k": kwargs, "s": QSemaphore(0), "r": None} + self.__thread_command_queue.put(s) + s["s"].acquire(max(s["s"].available(), 1)) # wait untill consumed + s["s"].release() + return s["r"] + + +def wrap(function, name): + if name == "__init__": + if function is not None: + @wraps(function) + def wrapper(self, *args, **kwargs): + self.__thread_command_id = None + return function(self, *args, **kwargs) + else: + def wrapper(self, *args, **kwargs): + self.__thread_command_id = None + return super(type(self), self).__init__(*args, **kwargs) + elif function is None: + return None + elif issubclass(type(function), FunctionType): + @wraps(function) + def wrapper(self, *args, **kwargs): + return run_or_enqueue(self, function, self, *args, **kwargs) + elif issubclass(type(function), classmethod): + @wraps(function) + def wrapper(self, *args, **kwargs): + return run_or_enqueue(self, function.__func__, self.__class__, *args, **kwargs) + elif issubclass(type(function), staticmethod): + @wraps(function) + def wrapper(self, *args, **kwargs): + return run_or_enqueue(self, function.__func__, *args, **kwargs) + elif issubclass(type(function), property): + wrapper = property( + fget=wrap(function.fget, function.fget.__name__) if function.fget is not None else None, + fset=wrap(function.fset, function.fset.__name__) if function.fset is not None else None, + fdel=wrap(function.fdel, function.fdel.__name__) if function.fdel is not None else None, + doc=function.__doc__, + ) + else: + raise NotImplementedError(f"wrap for type {type(function)} not implemented") + return wrapper + + +class SynchronizingWrapper(type(QObject), type): + def __new__(meta, classname, bases, old__dict__): + new__dict__ = {} + for name, attr in old__dict__.items(): + if name != "__init__": + if issubclass(type(attr), FunctionType) or issubclass(type(attr), classmethod) or issubclass(type(attr), staticmethod) or issubclass(type(attr), property): + attr = wrap(attr, name) + new__dict__[name] = attr + new__dict__["__init__"] = wrap(new__dict__.get("__init__", None), "__init__") + new__dict__["_create_thread_command_executor"] = create_thread_command_executor + new__dict__["_thread_command_execute"] = thread_command_execute + # return super(SynchronizingWrapper, meta).__new__(meta, classname, bases, new__dict__) + return type.__new__(meta, classname, bases, new__dict__) + + +class SynchronizingBase(QObject, metaclass=SynchronizingWrapper): + pass + + +if __name__ == "__main__": + + class RObject(QObject): + def __init__(self, *args, **kwargs): + print("RObject.__init__", self, args, kwargs, "thread:", int(QThread.currentThreadId())) + super().__init__() + + class Runner(RObject, metaclass=SynchronizingWrapper): + def __init__(self, *args, **kwargs): + print("Runner.__init__", self, args, kwargs, "thread:", int(QThread.currentThreadId())) + super().__init__(*args, **kwargs) + + def start(self): + self._create_thread_command_executor() + print("Runner.start", self, "thread:", int(QThread.currentThreadId())) + self.timer = QTimer() + self.timer.setInterval(1000) + self.timer.timeout.connect(self.run) + self.timer.timeout.connect(self.run_classmethod) + self.timer.timeout.connect(self.run_staticmethod) + self.timer.timeout.connect(lambda: self.run_property) + self.timer.start() + + def run(self): + print(f"run thread: {int(QThread.currentThreadId())}") + return True + + @classmetho + def run_classmethod(cls): + print(f"run_classmethod thread: {int(QThread.currentThreadId())}") + return True + + @staticmeth + def run_staticmethod(): + print(f"run_staticmethod thread: {int(QThread.currentThreadId())}") + return True + + @property + def run_property(self): + print(f"run_property thread: {int(QThread.currentThreadId())}") + return True + + app = QCoreApplication(sys.argv) + + runner = Runner() + # runner._create_thread_command_executor() + + thread = QThread() + thread.setTerminationEnabled(True) + runner.moveToThread(thread) + thread.started.connect(runner.start) + thread.start() + + timer = QTimer() + timer.setInterval(1000) + timer.timeout.connect(runner.run) + timer.timeout.connect(runner.run_classmethod) + timer.timeout.connect(runner.run_staticmethod) + timer.timeout.connect(lambda: runner.run_property) + timer.start() + + sys.exit(app.exec_()) diff --git a/src/lib/helpers/resources.py b/src/lib/helpers/resources.py new file mode 100644 index 0000000..aa0862f --- /dev/null +++ b/src/lib/helpers/resources.py @@ -0,0 +1,13 @@ +import os +import sys + + +def get_resource(path): + if hasattr(sys, "_MEIPASS"): + return os.path.join(sys._MEIPASS, path) + else: + return os.path.join("./src", path) + + +def get_path(): + return getattr(sys, "_MEIPASS", ".") + ";" + os.environ["PATH"] diff --git a/src/lib/helpers/timing.py b/src/lib/helpers/timing.py new file mode 100644 index 0000000..cae4c92 --- /dev/null +++ b/src/lib/helpers/timing.py @@ -0,0 +1,9 @@ +from time import perf_counter, time + +ref = time() - perf_counter() + + +def timing(): + global ref + t = ref + perf_counter() + return t diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..f8c4d7f --- /dev/null +++ b/src/main.py @@ -0,0 +1,165 @@ +#!/usr/bin/env python3 +import faulthandler +import logging +import os +# import pdb +import signal +import sys +import traceback +from datetime import datetime +from pathlib import Path + + +def quit_app(signalnum, handler): + print(signalnum, handler) + global app + app.quit() + quit() + + +# SETUP QUITTING ON CTRL+C +signal.signal(signal.SIGINT, quit_app) + +# SETUP FAULTHANDLER +faulthandler.enable(file=sys.stderr, all_threads=True) + +# SETUP LOGS +logs_dir = Path(".") / "data" / "logs" +os.makedirs(logs_dir, exist_ok=True) +logging.basicConfig( + format="{asctime}:{name}:{levelname}:{message}", + datefmt="%Y-%m-%dT%H-%M-%S%z", + style="{", + level="INFO", + handlers=[ + logging.StreamHandler(stream=sys.stderr), + logging.FileHandler( + logs_dir / f"{datetime.now().isoformat()}.log", + mode="a", + encoding="utf-8", + delay=False, + errors="surrogateescape" + ), + ], + force=True, + encoding="utf-8", + errors="surrogateescape", +) + +try: + # IMPORT PROJECT ONLY AFTER SETTING UP SIGNAL, FAULTHANDLER AND LOGGHING + from components import (ArchiveSynchronizer, Os_Label_Printer, RemoteAPI, + TestComponent, VisionSaver) + from lib.db import Users + from lib.helpers import ConfigReader + from PyQt5.QtCore import QObject, QThread, pyqtSignal + from PyQt5.QtWidgets import QApplication, QMessageBox + from ui import (About, Archive, Autotests_Archive, Login, Main_Window, + Test, Users_Management) + + class Main(QObject): + do = pyqtSignal(dict) + + @staticmethod + def _do(config): + return config["f"](*config.get("a", []), **config.get("k", {})) + + def __init__(self, parent=None): + # print(f"MAIN {int(QThread.currentThreadId())}", flush=True) + super().__init__() + self.do.connect(self._do) + try: + # READ CONFIG + self.config = ConfigReader() + # INIT COMPONENT + self.components_specs = { + "archive_synchronizer": {"c": ArchiveSynchronizer, "a": [], "k": {}, "t": True}, + "remote_api": {"c": RemoteAPI, "a": [], "k": {"main": self}, "t": True}, + "test_component": {"c": TestComponent, "a": [], "k": {}, "t": True}, + "vision_savert": {"c": VisionSaver, "a": [], "k": {}, "t": False}, + "label_printer": {"c": Os_Label_Printer, "a": [], "k": {}, "t": False}, + } + self.components = {} + self.threads = {} + for component_name, spec in self.components_specs.items(): + self.components[component_name] = spec["c"](*spec["a"], config=self.config, name=component_name, **spec["k"]) + if spec["t"]: + self.threads[component_name] = QThread() + self.threads[component_name].setTerminationEnabled(True) + self.components[component_name].moveToThread(self.threads[component_name]) + for component_name, thread in self.threads.items(): + component = self.components[component_name] + thread.started.connect(component.start) + thread.start() + component.wait_ready() + except Exception as e: + logging.exception(traceback.format_exc()) + QMessageBox.critical(None, "Errore Banco", f"Non e stato possibile connettersi al banco:\n\n{e}") + quit() + # GUI INIT + # self.main_window = Main_Window(self.bench) + self.main_window = Main_Window() + # CONNECT MAIN WINDOW ACTIONS + self.main_window.archive_a.triggered.connect(self.open_archive) + if "--archive" in sys.argv: + self.main_window.archive_a.trigger() + self.main_window.autotests_archive_a.triggered.connect(self.open_autotests_archive) + if "--autotests-archive" in sys.argv: + self.main_window.autotests_archive_a.trigger() + self.main_window.about_a.triggered.connect(self.open_about) + if "--about" in sys.argv: + self.main_window.about_a.trigger() + self.main_window.admin_m.menuAction().setVisible(False) # admin menu should not be visible before an admin logs in + self.main_window.users_management_a.triggered.connect(self.open_users_management) + if "--users-management" in sys.argv: + self.main_window.users_management_a.trigger() + # OPEN LOGIN TAB + self.open_login() + # SHOW MAIN WINDOW + if "--panel" in sys.argv: + self.main_window.show() + elif "--maximized" in sys.argv: + self.main_window.showMaximized() + elif "--full-screen" in sys.argv: + self.main_window.showFullScreen() + else: + self.main_window.show() + + def open_archive(self): + self.main_window.open_dialog(Archive()) + + def open_autotests_archive(self): + self.main_window.open_dialog(Autotests_Archive()) + + def open_users_management(self): + self.main_window.open_dialog(Users_Management()) + + def open_about(self): + about_widget = About() + self.main_window.open_dialog(about_widget) + + def open_login(self): + tab = Login() + tab.successful_login.connect(self.logghed_in) + self.main_window.open_tab(tab) + + def logghed_in(self): + session = Users.get_session() + if session is not None: + if session.is_admin: + self.main_window.admin_m.menuAction().setVisible(True) + else: + self.main_window.admin_m.menuAction().setVisible(False) + self.open_test() + + def open_test(self): + self.main_window.open_tab(Test(self.config.machine_id)) + + if __name__ == "__main__": + app = QApplication(sys.argv) + main = Main() + app.exec() +except Exception: + logging.exception(traceback.format_exc()) + # extype, value, tb = sys.exc_info() + # pdb.post_mortem(tb) diff --git a/src/requirements.txt b/src/requirements.txt new file mode 100644 index 0000000..d7726bb --- /dev/null +++ b/src/requirements.txt @@ -0,0 +1,12 @@ +argon2-cffi +bottle +google-cloud-storage +numpy +opencv-python-headless +peewee +pillow +pymodbus +pyqt5 +pyserial +requests +zebra diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..c530c1e --- /dev/null +++ b/src/ui/__init__.py @@ -0,0 +1,18 @@ +from .about import About +from .archive import Archive +from .autotests_archive import Autotests_Archive +from .dialog import Dialog +from .login import Login +from .main_window import Main_Window +from .qml_circular_gauge import Qml_Circular_Gauge +from .qml_led import Qml_Led +from .qml_switch import Qml_Switch +from .qml_widget import Qml_Widget +from .recipe_editor import Recipe_Editor +from .recipe_selection import Recipe_Selection +from .test import Test +from .test_autotest import Test_Autotest +from .test_home import Test_Home +from .users_management import Users_Management +from .widget import Widget +from .window import Window diff --git a/src/ui/about/__init__.py b/src/ui/about/__init__.py new file mode 100644 index 0000000..511a8a8 --- /dev/null +++ b/src/ui/about/__init__.py @@ -0,0 +1 @@ +from .about import About diff --git a/src/ui/about/about.py b/src/ui/about/about.py new file mode 100644 index 0000000..39daf1b --- /dev/null +++ b/src/ui/about/about.py @@ -0,0 +1,21 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap +from ui.widget import Widget + + +class About(Widget): + def __init__(self): + super().__init__() + # LOGO + self.logo_img = QPixmap("src/ui/imgs/logo_neo.png") + self.resizeEvent() + + def resizeEvent(self, event=None): + self.logo_l.setPixmap( + self.logo_img.scaled( + self.logo_l.width(), + self.logo_l.height(), + Qt.KeepAspectRatio, + Qt.SmoothTransformation + ) + ) diff --git a/src/ui/about/about.ui b/src/ui/about/about.ui new file mode 100644 index 0000000..d446ab9 --- /dev/null +++ b/src/ui/about/about.ui @@ -0,0 +1,130 @@ + + + About + + + + 0 + 0 + 316 + 262 + + + + + + + + 0 + 0 + + + + font: 87 7pt "Arial Black"; + + + POWERED BY + + + Qt::AutoText + + + false + + + + + + + - + + + + + + + Contatti + + + + + + Website: + + + + + + + www.neosystems.it + + + + + + + E-mail: + + + + + + + info@neosystems.it + + + + + + + Telefono: + + + + + + + +39 0118000876 + + + + + + + P.IVA: + + + + + + + 11836090016 + + + + + + + Indirizzo: + + + + + + + Via Vittime delle Foibe, 10 10036 - Settimo Torinese (TO) + + + + true + + + + + + + + + + + diff --git a/src/ui/archive/__init__.py b/src/ui/archive/__init__.py new file mode 100755 index 0000000..6cc2e3f --- /dev/null +++ b/src/ui/archive/__init__.py @@ -0,0 +1 @@ +from .archive import Archive diff --git a/src/ui/archive/archive.py b/src/ui/archive/archive.py new file mode 100755 index 0000000..d706234 --- /dev/null +++ b/src/ui/archive/archive.py @@ -0,0 +1,70 @@ +from lib.db import Users +from PyQt5.QtWidgets import QAbstractItemView +from ui.crud import Crud, Json_External_Dialog_Cell_Widget +from ui.helpers import replace_widget +from ui.widget import Widget + + +class Archive(Widget): + def __init__(self, printer=None): + super().__init__() + self.printer = printer + session = Users.get_session() + if session is not None and session.is_admin: + crud_aliases = { + "id": "Id", + "time": "Data e ora", + "user": "Operatore", + "recipe": "Ricetta", + "result": "Esito", + "overridden": "Esito forzato", + "test_data": "Dati del test", + "archived": "Archiviato sul portale", + "uploaded": "Immagine in cloud", + } + readonly = ["id"] + else: + crud_aliases = { + "time": "Data e ora", + "user": "Operatore", + "recipe": "Ricetta", + "result": "Esito", + "overridden": "Esito forzato", + "test_data": "Dati del test", + } + readonly = True + self.crud = Crud( + "archive", + display_name="Archivio", + readonly=readonly, + select=list(crud_aliases.keys()), + fields_aliases=crud_aliases, + widget_classes={ + "test_data": Json_External_Dialog_Cell_Widget, + }, + ) + replace_widget(self, "crud_w", self.crud) + self.selected = None + self.print_b.setEnabled(False) + self.crud.db_tw.setSelectionBehavior(QAbstractItemView.SelectRows) + self.crud.db_tw.setSelectionMode(QAbstractItemView.SingleSelection) + self.crud.db_tw.itemSelectionChanged.connect(self.check) + self.print_b.clicked.connect(self.print_label) + + def check(self): + if not self.crud.modified: + selected = self.crud.get_selected_rows() + if len(selected) == 1: + selected = selected[0] - 1 # - 1 because rn starts from 1 (filters line) + if selected >= 0 and selected < len(self.crud.data_index): + selected = self.crud.data_index[selected] + self.selected = self.crud.db.table_model.get_by_id(selected) + self.print_b.setEnabled(True) + return + self.selected = None + self.print_b.setEnabled(False) + + def print_label(self): + self.check() + if self.selected is not None and self.printer is not None: + self.printer.print_archive_label(self.selected) diff --git a/src/ui/archive/archive.ui b/src/ui/archive/archive.ui new file mode 100755 index 0000000..1b18bd1 --- /dev/null +++ b/src/ui/archive/archive.ui @@ -0,0 +1,37 @@ + + + Test archive + + + + 0 + 0 + 98 + 61 + + + + + + + + 0 + 0 + + + + Stampa + + + + + + + + + + print_b + + + + diff --git a/src/ui/autotests_archive/__init__.py b/src/ui/autotests_archive/__init__.py new file mode 100755 index 0000000..b80d471 --- /dev/null +++ b/src/ui/autotests_archive/__init__.py @@ -0,0 +1 @@ +from .autotests_archive import Autotests_Archive diff --git a/src/ui/autotests_archive/autotests_archive.py b/src/ui/autotests_archive/autotests_archive.py new file mode 100755 index 0000000..8768564 --- /dev/null +++ b/src/ui/autotests_archive/autotests_archive.py @@ -0,0 +1,70 @@ +from lib.db import Users +from PyQt5.QtWidgets import QAbstractItemView +from ui.crud import Crud, Json_External_Dialog_Cell_Widget +from ui.helpers import replace_widget +from ui.widget import Widget + + +class Autotests_Archive(Widget): + def __init__(self, printer=None): + super().__init__() + self.printer = printer + session = Users.get_session() + if session is not None and session.is_admin: + crud_aliases = { + "id": "Id", + "time": "Data e ora", + "user": "Operatore", + "recipe": "Ricetta", + "result": "Esito", + "reason": "Motivo", + "overridden": "Esito forzato", + "test_data": "Dati del test", + } + readonly = ["id"] + else: + crud_aliases = { + "time": "Data e ora", + "user": "Operatore", + "recipe": "Ricetta", + "result": "Esito", + "reason": "Motivo", + "overridden": "Esito forzato", + "test_data": "Dati del test", + } + readonly = True + self.crud = Crud( + "autotests", + display_name="Archivio autotest", + readonly=readonly, + select=list(crud_aliases.keys()), + fields_aliases=crud_aliases, + widget_classes={ + "test_data": Json_External_Dialog_Cell_Widget, + }, + ) + replace_widget(self, "crud_w", self.crud) + self.selected = None + self.print_b.setEnabled(False) + self.crud.db_tw.setSelectionBehavior(QAbstractItemView.SelectRows) + self.crud.db_tw.setSelectionMode(QAbstractItemView.SingleSelection) + self.crud.db_tw.itemSelectionChanged.connect(self.check) + self.print_b.clicked.connect(self.print_label) + + def check(self): + if not self.crud.modified: + selected = self.crud.get_selected_rows() + if len(selected) == 1: + selected = selected[0] - 1 # - 1 because rn starts from 1 (filters line) + if selected >= 0 and selected < len(self.crud.data_index): + selected = self.crud.data_index[selected] + self.selected = self.crud.db.table_model.get_by_id(selected) + self.print_b.setEnabled(True) + return + self.selected = None + self.print_b.setEnabled(False) + + def print_label(self): + self.check() + if self.selected is not None and self.printer is not None: + self.printer.print_autotest_label(self.selected) diff --git a/src/ui/autotests_archive/autotests_archive.ui b/src/ui/autotests_archive/autotests_archive.ui new file mode 100755 index 0000000..f4cf414 --- /dev/null +++ b/src/ui/autotests_archive/autotests_archive.ui @@ -0,0 +1,37 @@ + + + Autotests archive + + + + 0 + 0 + 98 + 61 + + + + + + + + 0 + 0 + + + + Stampa + + + + + + + + + + print_b + + + + diff --git a/src/ui/crud/CopyPastableCrudQTableWidget.py b/src/ui/crud/CopyPastableCrudQTableWidget.py new file mode 100644 index 0000000..298e00b --- /dev/null +++ b/src/ui/crud/CopyPastableCrudQTableWidget.py @@ -0,0 +1,74 @@ +import sys + +from PyQt5.QtCore import QEvent, Qt +from PyQt5.QtWidgets import QApplication, QTableWidget + + +class CopyPastableCrudQTableWidget(QTableWidget): + crud = None + + def eventFilter(self, target, event): + if event.type() == QEvent.KeyPress: + if event.key() == Qt.Key_C and (event.modifiers() & Qt.ControlModifier): + if event.key() == Qt.Key_C and (event.modifiers() & Qt.ControlModifier): + copied_cells = sorted(self.selectedIndexes()) + min_row, max_row, min_col, max_col = sys.maxsize, 0, sys.maxsize, 0 + for cell in copied_cells: + row = cell.row() + min_row, max_row = min(row, min_row), max(row, max_row) + col = cell.column() + min_col, max_col = min(col, min_col), max(col, max_col) + if min_row == 0: # if filters row + return False + copy_sparse = {} + for cell in copied_cells: + row = cell.row() + col = cell.column() + if row not in copy_sparse: + copy_sparse[row] = {} + cell = self.cellWidget(row, col) + if cell is not None: + copy_sparse[row][col] = cell.parse(row_number=row, crud=self.crud) + copy_dense = [["" for col in range(max_col - min_col + 1)] for row in range(max_row - min_row + 1)] + for row, row_cells in copy_sparse.items(): + for col, cell_text in row_cells.items(): + if cell_text is not None: + copy_dense[row - min_row][col - min_col] = str(cell_text) + copy_text = "\n".join(["\t".join(row) for row in copy_dense]) + QApplication.clipboard().setText(copy_text) + super().keyPressEvent(event) + QApplication.sendEvent(self, event) + return True + elif event.key() == Qt.Key_V and (event.modifiers() & Qt.ControlModifier): + min_row = self.currentRow() + min_col = self.currentColumn() + if min_row == 0: # if filters row + return False + copy_text = QApplication.clipboard().text() + copy_dense = [row.split("\t") for row in copy_text.split("\n")] + if len(copy_dense) > 1 and len(copy_dense[-1]) == 1 and not len(copy_dense[-1][-1]): + copy_dense.pop(-1) + for row, row_cells in enumerate(copy_dense): + for col, cell_text in enumerate(row_cells): + r = min_row + row + c = min_col + col + cell = self.cellWidget(r, c) + if cell is not None: + cell.render(cell_text, field_name=self.crud.select[c] if self.crud is not None else None, row_number=r, crud=self.crud) + super().keyPressEvent(event) + QApplication.sendEvent(self, event) + return True + return super().eventFilter(target, event) + + def setCellWidget(self, row, column, widget): + widget.installEventFilter(self) + super().setCellWidget(row, column, widget) + + +if __name__ == "__main__": + app = QApplication(sys.argv) + table = CopyPastableCrudQTableWidget() + table.setRowCount(10) + table.setColumnCount(10) + table.show() + sys.exit(app.exec()) diff --git a/src/ui/crud/__init__.py b/src/ui/crud/__init__.py new file mode 100755 index 0000000..d1d2db0 --- /dev/null +++ b/src/ui/crud/__init__.py @@ -0,0 +1,2 @@ +from .CopyPastableCrudQTableWidget import CopyPastableCrudQTableWidget +from .crud import * diff --git a/src/ui/crud/crud.py b/src/ui/crud/crud.py new file mode 100755 index 0000000..124b470 --- /dev/null +++ b/src/ui/crud/crud.py @@ -0,0 +1,448 @@ +import ast +import json +import traceback +from datetime import datetime + +from lib.db import Crud_DB +from peewee import TextField +from PyQt5.QtCore import pyqtSignal +from PyQt5.QtWidgets import (QAbstractItemView, QComboBox, QDialog, + QGridLayout, QHeaderView, QLineEdit, QMessageBox, + QPlainTextEdit, QPushButton) +from ui.widget import Widget + + +def to_str(data): + if data is None: + return None + elif type(data) is bytes: + data = data.decode("UTF-8", errors="replace") + elif type(data) is datetime: + data = data.strftime("%Y-%m-%d %H:%M:%S") + return str(data) + + +def from_str(data, field=None): + if data is not None and len(data): + if type(field) is TextField: + return data + try: + return ast.literal_eval(data) + except (SyntaxError, ValueError): + return data + return None + + +class Cell: + modified = pyqtSignal(bool) + + def __init__(self, readonly=True, autocomplete=None, field_name=None, field_alias=None, field=None): + self.readonly = readonly + self.autocomplete = autocomplete + self.field_name = field_name + self.field_alias = field_alias + self.field = field + self.set_readonly(self.readonly) + self.value = None + self.is_modified = False + self.connected_modified = False + self.do_autocomplete(self.autocomplete) + + def set_readonly(self, readonly): + raise NotImplementedError() + + def do_autocomplete(self, autocomplete): + self.render(autocomplete) + + def connect_modified(self): + raise NotImplementedError() + + def _render(self, data, *args, **kwargs): + self.value = data + self.is_modified = False + self.render(data, *args, **kwargs) + if not self.connected_modified: + # only connect after first render + # to avoid false modified signals trigghered by autocomplete + self.connect_modified() + self.connected_modified = True + + def render(self, data, field_name=None, row_number=None, crud=None): + raise NotImplementedError() + + def check_modified(self, *args, **kwargs): + try: + value = self.parse() + fail = False + except Exception: + self.log.exception(traceback.format_exc()) + value = None + fail = True + if fail or value != self.value: + self.is_modified = True + else: + self.is_modified = False + self.modified.emit(self.is_modified) + return self.is_modified + + def _parse(self, *args, **kwargs): + if not self.is_modified: + return self.value + return self.parse(*args, **kwargs) + + def parse(self, row_number=None, crud=None): + raise NotImplementedError() + + +class Line_Edit_Cell_Widget(QLineEdit, Cell): + def __init__(self, readonly=True, autocomplete=None, field_name=None, field_alias=None, field=None): + super().__init__() + Cell.__init__(self, readonly=readonly, autocomplete=autocomplete, field_name=field_name, field_alias=field_alias, field=field) + + def set_readonly(self, readonly): + self.setReadOnly(readonly) + + def connect_modified(self): + self.textChanged.connect(self.check_modified) + + def render(self, data, field_name=None, row_number=None, crud=None): + self.value = data + self.setText(to_str(data)) + + def parse(self, row_number=None, crud=None): + return from_str(self.text(), self.field) + + +class Combo_Box_Cell_Widget(QComboBox, Cell): + def __init__(self, readonly=True, autocomplete=None, field_name=None, field_alias=None, field=None): + super().__init__() + Cell.__init__(self, readonly=readonly, autocomplete=autocomplete, field_name=field_name, field_alias=field_alias, field=field) + + def set_readonly(self, readonly): + self.setEditable(not readonly) + + def do_autocomplete(self, autocomplete): + if autocomplete is not None: + self.addItems(list(map(str, autocomplete))) + + def connect_modified(self): + self.editTextChanged.connect(self.check_modified) + + def render(self, data, field_name=None, row_number=None, crud=None): + self.setCurrentText(to_str(data)) + + def parse(self, row_number=None, crud=None): + return from_str(self.currentText(), self.field) + + +class External_Dialog_Cell_Widget(QPushButton, Cell): + def __init__(self, readonly=True, autocomplete=None, field_name=None, field_alias=None, field=None): + self.dialog = QDialog() + self.editor = QPlainTextEdit() + self.dialog.setLayout(QGridLayout()) + self.dialog.layout().setSpacing(0) + self.dialog.layout().setContentsMargins(0, 0, 0, 0) + self.dialog.layout().addWidget(self.editor, 0, 0, -1, -1) + super().__init__(u"\u238B apri") + Cell.__init__(self, readonly=readonly, autocomplete=autocomplete, field_name=field_name, field_alias=field_alias, field=field) + self.dialog.setWindowTitle(self.field_alias) + self.clicked.connect(self.dialog.show) + + def set_readonly(self, readonly): + self.editor.setReadOnly(readonly) + + def connect_modified(self): + self.editor.textChanged.connect(self.check_modified) + + def render(self, data, field_name=None, row_number=None, crud=None): + self.editor.setPlainText(to_str(data)) + + def parse(self, row_number=None, crud=None): + return from_str(self.editor.toPlainText(), self.field) + + +class Json_External_Dialog_Cell_Widget(External_Dialog_Cell_Widget): + def render(self, data, field_name=None, row_number=None, crud=None): + self.editor.setPlainText(json.dumps(data, indent="\t")) + + def parse(self, row_number=None, crud=None): + return json.loads(self.editor.toPlainText()) + + +class Crud(Widget): + modified = pyqtSignal(bool) + selected = pyqtSignal(list) + + def __init__(self, table_name, readonly=False, select=None, filters=None, fields_aliases=None, autocomplete=None, pagination=250, display_name=None, row_upgrader=None, widget_classes=None, row_filter=None): + super().__init__() + self.table_name = table_name + self.readonly = readonly + self.db_gb.setTitle(display_name if display_name is not None else self.table_name) + self.db = Crud_DB(self.table_name, filters=filters, pagination=pagination) + if select is not None and len(select): + self.select = select + else: + self.select = self.db.table_fields + self.select_index = {f: i for i, f in enumerate(self.select)} + if fields_aliases is not None: + self.fields_aliases = {fn: fields_aliases.get(fn, fn) for fn in self.select} + else: + self.fields_aliases = {fn: fn for fn in self.select} + self.autocomplete = autocomplete if autocomplete is not None else {} + self.pagination = pagination + self.row_upgrader = row_upgrader if row_upgrader is not None else self.default_row_upgrader + self.default_widget_class = Line_Edit_Cell_Widget + self.widget_classes = widget_classes if widget_classes is not None else {} + self.row_filter = row_filter if row_filter is not None else self.default_row_filter + self.page = 0 + self.filters = {} + self.db_tw.crud = self + self.refresh("init") + self.db_tw.horizontalHeader().sectionClicked.connect(self.toggleSort) + # self.db_tw.horizontalHeader().setStretchLastSection(True) + self.db_tw.horizontalHeader().setSectionResizeMode(QHeaderView.Interactive) + # self.db_tw.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeToContents) + self.db_tw.setSelectionBehavior(QAbstractItemView.SelectRows) + if self.readonly is None or self.readonly is True: + self.db_tw.setSelectionMode(QAbstractItemView.SingleSelection) + else: + self.db_tw.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.db_tw.itemSelectionChanged.connect(self.show_selection) + self.revert_b.clicked.connect(lambda: self.refresh("revert")) + if self.pagination is not False: + self.start_b.clicked.connect(lambda: self.refresh("pagination", page=0)) + self.previous_b.clicked.connect(lambda: self.refresh("pagination", page=max(self.page - 1, 0))) + self.page_n_sb.valueChanged.connect(lambda page: self.refresh("pagination", page=page - 1)) + self.next_b.clicked.connect(lambda: self.refresh("pagination", page=self.page + 1)) + self.end_b.clicked.connect(lambda: self.refresh("pagination", page=-1)) + else: + self.next_b.setHidden(True) + self.previous_b.setHidden(True) + self.start_b.setHidden(True) + self.end_b.setHidden(True) + self.page_n_sb.setHidden(True) + if self.readonly is None or self.readonly is True: + self.cancel_b.setEnabled(False) + self.cancel_b.setHidden(True) + self.commit_b.setEnabled(False) + self.commit_b.setHidden(True) + self.add_b.setEnabled(False) + self.add_b.setHidden(True) + self.delete_b.setEnabled(False) + self.delete_b.setHidden(True) + else: + self.cancel_b.clicked.connect(self.cancel) + self.commit_b.clicked.connect(self.commit) + self.add_b.clicked.connect(self.add) + self.delete_b.clicked.connect(self.delete) + + sort_cycle = [True, False, None] + sort_symbol = [u" \u25B4", u" \u25BE", u""] + + def toggleSort(self, cn=None): + if cn is not None: + fn = self.select[cn] + if fn not in self.db.table_fields: + return + self.db_tw.setHorizontalHeaderLabels(self.fields_aliases.values()) + current = self.sorting.get(fn, -1) + current = (current + 1) % len(self.sort_cycle) + self.sorting.clear() # for single column sorting + self.db.sort(None) # for single column sorting + self.sorting[fn] = current + current = self.sort_cycle[current] + self.db.sort(fn, is_ascending=current) + self.db_tw.horizontalHeaderItem(cn).setText("{field}{symbol}".format(field=self.fields_aliases[fn], symbol=self.sort_symbol[self.sorting[fn]])) + else: + self.db_tw.setHorizontalHeaderLabels(self.fields_aliases.values()) + self.sorting.clear() # for single column sorting + self.db.sort(None) # for single column sorting + self.refresh() + + def filter_edited(self, cn): + text = self.db_tw.cellWidget(0, cn).text() + if not len(text): + text = None + fn = self.select[cn] + if text != self.filters.get(fn, None): + self.filters[fn] = text + self.db.filter(self.select[cn], text) + self.refresh() + + @staticmethod + def default_row_upgrader(row, row_number, crud): + """should return the edited_row""" + return row + + @staticmethod + def default_row_filter(row, row_number, crud): + """should return a tuple: commit_this_row_bool, edited_row, fail_current_commit_bool""" + return True, row, False + + def set_modified(self, is_modified=True): + self._modified = self._modified or is_modified + self.modified.emit(self._modified) + + def refresh(self, action=None, page=0): + # IGNORE SIGNALS WHILE UPDATING + self.db_tw.blockSignals(True) + self._modified = False # force reset modified status + self.set_modified(self._modified) + if action == "init": + self.sorting = {} + self.previous_selection = [] + self.db_tw.setColumnCount(len(self.select)) + self.db_tw.setHorizontalHeaderLabels(self.fields_aliases.values()) + self.db_tw.setRowCount(1) + if self.readonly is None or self.readonly is True: + self.db_tw.verticalHeader().setHidden(True) + else: + self.db_tw.verticalHeader().setHidden(False) + elif action == "cancel": + # REVERT TABLE CHANGES + pass + elif action == "revert": + # REVERT SORTING AND FILTERS + self.sorting = {} + for cn, fn in enumerate(self.select): + self.toggleSort(None) + self.filters.clear() + self.db.revert() + elif action == "commit" and not (self.readonly is None or self.readonly is True): + # COMMIT CHANGES + fail = False + data = [] + for rn in range(1, self.db_tw.rowCount()): # start=1 because rn starts from 1 (filters line) + r = {} + for fn, cn in self.select_index.items(): + w = self.db_tw.cellWidget(rn, cn) + try: + r[fn] = w._parse(row_number=rn, crud=self) + except Exception: + self.log.exception(traceback.format_exc()) + self.set_row_color(rn, "red") + fail = True + add_row, r, filter_fail = self.row_filter(r, rn, self) + fail = fail or filter_fail + if add_row: + data.append(r) + if fail: + return False + if self.db.table_pk.name not in self.select: + for rn, r in enumerate(data): + r[self.db.table_pk.name] = self.data_index[rn] + # INDEX DATA WITH PK + try: + self.db.commit(data, self.deleted_rows) + except Exception as e: + self.log.exception(traceback.format_exc()) + QMessageBox.critical(None, "Errore Salvataggio DB", str(e)) + return False + # GET DATA + data, self.data_total_count, self.page, self.last_page = self.db.get(page) + self.data_index = [r[self.db.table_pk.name] for r in data] + self.page_n_sb.blockSignals(True) + self.page_n_sb.setRange(1, self.last_page + 1) + self.page_n_sb.setValue(self.page + 1) + self.page_n_sb.setSuffix(" / {}".format(self.last_page + 1)) + self.page_n_sb.blockSignals(False) + # RESET DELETED ROWS + self.deleted_rows = set() + # CLEAR CURRENT VALUES + self.db_tw.clearContents() + # SET TABLE ROW COUNT ACCORDINGLY + self.db_tw.setRowCount(len(data) + 1) # + 1 because rn starts from 1 (filters line) + # RESTORE FILTERS + for cn, fn in enumerate(self.select): + w = QLineEdit() + if fn in self.db.table_fields: + w.setPlaceholderText("Filtro") + w.setText(self.filters.get(fn, None)) + w.editingFinished.connect(lambda cn=cn: self.filter_edited(cn)) + else: + w.setPlaceholderText("Non filtrabile") + w.setEnabled(False) + self.db_tw.setCellWidget(0, cn, w) + # INSERT UPDATED DATA + for rn, r in enumerate(data, start=1): # start=1 because rn starts from 1 (filters line) + r = self.row_upgrader(r, rn, self) + for fn, cn in self.select_index.items(): + readonly = self.readonly is None or self.readonly is True or (self.readonly is not False and fn in self.readonly) + w = self.widget_classes.get(fn, self.default_widget_class)(readonly=readonly, autocomplete=self.autocomplete.get(fn, None), field_name=fn, field_alias=self.fields_aliases[fn], field=self.db.table_model._meta.fields.get(fn, None)) + w.modified.connect(self.set_modified) + if fn in r: + w._render(data=r[fn], row_number=rn, crud=self) + self.db_tw.setCellWidget(rn, cn, w) + # REENABLEEVENTS AFTER UPDATE + self.db_tw.blockSignals(False) + return True + + def cancel(self): + return self.refresh("cancel") + + def commit(self): + return self.refresh("commit") + + def add(self): + self.set_modified(True) + self.data_index.append(None) + rn = self.db_tw.rowCount() + self.db_tw.setRowCount(rn + 1) + for fn, cn in self.select_index.items(): + readonly = self.readonly is None or self.readonly is True or (self.readonly is not False and fn in self.readonly) + w = self.widget_classes.get(fn, self.default_widget_class)(readonly=readonly, autocomplete=self.autocomplete.get(fn, None), field_name=fn, field_alias=self.fields_aliases[fn], field=self.db.table_model._meta.fields.get(fn, None)) + w.modified.connect(self.set_modified) + self.db_tw.setCellWidget(rn, cn, w) + self.db_tw.scrollToBottom() + + def delete(self): + self.set_modified(True) + selected = self.get_selected_rows() + if len(selected) == 0: + return + # ret = QMessageBox.warning( + # None, + # u"Conferma rimozione linee", + # u"Si \u00E8 sicuri di voler eliminare le linee selezionate?", + # buttons=QMessageBox.Ok | QMessageBox.Cancel, + # defaultButton=QMessageBox.Cancel + # ) + # if ret == QMessageBox.Ok: + if True: + for rn in reversed(selected): + pk = self.data_index.pop(rn - 1) # - 1 because rn starts from 1 (filters line) + if pk is not None: + self.deleted_rows.add(pk) + self.db_tw.removeRow(rn) + + def get_selected_rows(self): + selected = self.db_tw.selectedRanges() + rows = set() + for s in selected: + rows.update(range(s.topRow(), s.bottomRow() + 1)) + rows.discard(0) # ship filters + return sorted(rows) + + def show_selection(self): + selected = self.get_selected_rows() + self.selected.emit(selected) + if self.previous_selection == selected: + return + for row_number in self.previous_selection: + self.set_row_color(row_number) + for row_number in selected: + self.set_row_color(row_number, "cyan") + self.previous_selection = selected + + def set_row_color(self, row_number, color=None): + for i in range(self.db_tw.columnCount()): + w = self.db_tw.cellWidget(row_number, i) + if w is not None: + if color is not None: + w.setStyleSheet(f"background-color: {color};") + else: + w.setStyleSheet("") + + def emit(self): + self.set_modified(self._modified) + self.show_selection() diff --git a/src/ui/crud/crud.ui b/src/ui/crud/crud.ui new file mode 100755 index 0000000..525c69f --- /dev/null +++ b/src/ui/crud/crud.ui @@ -0,0 +1,151 @@ + + + Crud + + + + 0 + 0 + 800 + 600 + + + + + 0 + 0 + + + + + 800 + 600 + + + + + + + + 0 + 0 + + + + Table + + + + + + Reset + + + + + + + + 0 + 0 + + + + + 50 + false + + + + QHeaderView::section {font-weight: bold;} + + + false + + + false + + + + + + + + + + + + + + Annulla + + + + + + + + + + + + + + ⏩️ + + + + + + + Salva + + + + + + + Rimuovi + + + + + + + Aggiungi + + + + + + + + + + + + + + + + + + + + + CopyPastableCrudQTableWidget + QTableWidget +
ui.crud
+
+
+ + db_tw + cancel_b + revert_b + commit_b + add_b + delete_b + + + +
diff --git a/src/ui/dialog/__init__.py b/src/ui/dialog/__init__.py new file mode 100644 index 0000000..2323307 --- /dev/null +++ b/src/ui/dialog/__init__.py @@ -0,0 +1 @@ +from .dialog import Dialog diff --git a/src/ui/dialog/dialog.py b/src/ui/dialog/dialog.py new file mode 100644 index 0000000..a38fa38 --- /dev/null +++ b/src/ui/dialog/dialog.py @@ -0,0 +1,36 @@ +import logging + +from PyQt5 import uic +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QDialog +from ui.helpers import replace_widget + +dialogs = {} + + +class Dialog(QDialog): + _closing = pyqtSignal() + + def __init__(self): + super().__init__() + global dialogs + dialogs[id(self)] = self + self.setAttribute(Qt.WA_DeleteOnClose) + u = "src/ui/{0}/{0}.ui".format("dialog") + self.ui = uic.loadUi(u, self) + # LOGO + self.setWindowIcon(QIcon("src/ui/imgs/neo.ico")) + self.log = logging.getLogger(f"{self.__class__.__name__} ({id(self)})") + + def setCentralWidget(self, widget): + widget.setParent(self) + replace_widget(self, "centralWidget", widget) + + def centralWidget(self): + return self.centralwidget + + def closeEvent(self, *args): + self._closing.emit() + global dialogs + dialogs.pop(id(self), None) diff --git a/src/ui/dialog/dialog.ui b/src/ui/dialog/dialog.ui new file mode 100644 index 0000000..775af0a --- /dev/null +++ b/src/ui/dialog/dialog.ui @@ -0,0 +1,34 @@ + + + Dialog + + + + 0 + 0 + 28 + 28 + + + + + 0 + 0 + + + + + + + + 0 + 0 + + + + + + + + + diff --git a/src/ui/helpers/__init__.py b/src/ui/helpers/__init__.py new file mode 100644 index 0000000..9ef44de --- /dev/null +++ b/src/ui/helpers/__init__.py @@ -0,0 +1,2 @@ +from .calc_foreground_color import calc_foreground_color +from .replace_widget import replace_widget diff --git a/src/ui/helpers/calc_foreground_color.py b/src/ui/helpers/calc_foreground_color.py new file mode 100644 index 0000000..f60ae30 --- /dev/null +++ b/src/ui/helpers/calc_foreground_color.py @@ -0,0 +1,13 @@ +def calc_foreground_color(background_color): + colors = [] + for c in background_color[:3]: + c /= 255 # 8bit to sRGB + if c <= 0.03928: + c /= 12.92 + else: + c = ((c + 0.055) / 1.055) ** 2.4 + colors.append(c) + # Luminance + L = 0.2126 * colors[0] + 0.7152 * colors[1] + 0.0722 * colors[1] + foreground_color = [(1 - round(L)) * 255] * 3 + return foreground_color diff --git a/src/ui/helpers/replace_widget.py b/src/ui/helpers/replace_widget.py new file mode 100644 index 0000000..8f989b6 --- /dev/null +++ b/src/ui/helpers/replace_widget.py @@ -0,0 +1,11 @@ +def replace_widget(parent, name, new, delete=False): + old = getattr(parent, name) + replaced = old.parentWidget().layout().replaceWidget(old, new) + if replaced is None: + raise AssertionError(f"{name} not found, cannot replace it.") + old.hide() + setattr(parent, name, new) + new.show() + if delete: + old.deleteLater() + del old diff --git a/src/ui/imgs/assembly_1.jpg b/src/ui/imgs/assembly_1.jpg new file mode 100755 index 0000000..2c8777a Binary files /dev/null and b/src/ui/imgs/assembly_1.jpg differ diff --git a/src/ui/imgs/autotest_nok.png b/src/ui/imgs/autotest_nok.png new file mode 100755 index 0000000..86bf190 Binary files /dev/null and b/src/ui/imgs/autotest_nok.png differ diff --git a/src/ui/imgs/autotest_ok.png b/src/ui/imgs/autotest_ok.png new file mode 100755 index 0000000..4681a06 Binary files /dev/null and b/src/ui/imgs/autotest_ok.png differ diff --git a/src/ui/imgs/blue.png b/src/ui/imgs/blue.png new file mode 100755 index 0000000..ecf5f58 Binary files /dev/null and b/src/ui/imgs/blue.png differ diff --git a/src/ui/imgs/fail.png b/src/ui/imgs/fail.png new file mode 100644 index 0000000..a31041b Binary files /dev/null and b/src/ui/imgs/fail.png differ diff --git a/src/ui/imgs/green.png b/src/ui/imgs/green.png new file mode 100755 index 0000000..504bd8f Binary files /dev/null and b/src/ui/imgs/green.png differ diff --git a/src/ui/imgs/logo_neo.png b/src/ui/imgs/logo_neo.png new file mode 100644 index 0000000..7714434 Binary files /dev/null and b/src/ui/imgs/logo_neo.png differ diff --git a/src/ui/imgs/neo.ico b/src/ui/imgs/neo.ico new file mode 100644 index 0000000..5f55d8a Binary files /dev/null and b/src/ui/imgs/neo.ico differ diff --git a/src/ui/imgs/red.png b/src/ui/imgs/red.png new file mode 100755 index 0000000..0847579 Binary files /dev/null and b/src/ui/imgs/red.png differ diff --git a/src/ui/imgs/reset_emergency.png b/src/ui/imgs/reset_emergency.png new file mode 100644 index 0000000..e94ba98 Binary files /dev/null and b/src/ui/imgs/reset_emergency.png differ diff --git a/src/ui/imgs/right.png b/src/ui/imgs/right.png new file mode 100644 index 0000000..7922efa Binary files /dev/null and b/src/ui/imgs/right.png differ diff --git a/src/ui/imgs/splash.png b/src/ui/imgs/splash.png new file mode 100644 index 0000000..aad97d8 Binary files /dev/null and b/src/ui/imgs/splash.png differ diff --git a/src/ui/imgs/success.png b/src/ui/imgs/success.png new file mode 100644 index 0000000..48be486 Binary files /dev/null and b/src/ui/imgs/success.png differ diff --git a/src/ui/imgs/wait.png b/src/ui/imgs/wait.png new file mode 100644 index 0000000..184187c Binary files /dev/null and b/src/ui/imgs/wait.png differ diff --git a/src/ui/login/__init__.py b/src/ui/login/__init__.py new file mode 100755 index 0000000..b7ed19b --- /dev/null +++ b/src/ui/login/__init__.py @@ -0,0 +1 @@ +from .login import * diff --git a/src/ui/login/login.py b/src/ui/login/login.py new file mode 100755 index 0000000..62d4ef9 --- /dev/null +++ b/src/ui/login/login.py @@ -0,0 +1,42 @@ +import sys + +from lib.db import Session, Users +from PyQt5.QtCore import QTimer, pyqtSignal +from PyQt5.QtGui import QKeySequence +from PyQt5.QtWidgets import QMessageBox, QShortcut +from ui.widget import Widget + + +class Login(Widget): + wrong_credentials = pyqtSignal() + successful_login = pyqtSignal() + + def __init__(self): + super().__init__() + self.welcome_l.setText("BENVENUTO, PER INIZIARE IL COLLAUDO, EFFETTUA L'ACCESSO:") + self.user_cb.addItems(Users.get_usernames()) + QShortcut(QKeySequence("Return"), self).activated.connect(self.login_b.click) + self.login_b.clicked.connect(self.try_login) + # TESTING + if "--auto-login-admin" in sys.argv: + self.user_cb.setCurrentText("ADMIN") + self.password_le.setText("123123") + if "--auto-login-user" in sys.argv or "--test" in sys.argv: + self.user_cb.setCurrentText("USER") + self.password_le.setText("user") + if "--auto-login-admin" in sys.argv or "--auto-login-user" in sys.argv or "--test" in sys.argv: + self.test_timer = QTimer() + self.test_timer.setSingleShot(True) + self.test_timer.timeout.connect(self.login_b.clicked.emit) + self.test_timer.start(500) + # /TESTING + + def try_login(self): + user = self.user_cb.currentText().upper() + password = self.password_le.text() + session = Users.login(user, password) + if type(session) is not Session: + self.wrong_credentials.emit() + QMessageBox.critical(None, "Errore login", "Credenziali errate") + else: + self.successful_login.emit() diff --git a/src/ui/login/login.ui b/src/ui/login/login.ui new file mode 100755 index 0000000..4e409b6 --- /dev/null +++ b/src/ui/login/login.ui @@ -0,0 +1,139 @@ + + + Login + + + + 0 + 0 + 1064 + 294 + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + ACCESSO OPERATORE + + + + + + NOME UTENTE + + + + + + + PASSWORD + + + + + + + QLineEdit::Password + + + + + + + ACCEDI + + + true + + + true + + + + + + + true + + + + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 24 + + + + BENVENUTO, PER INIZIARE IL COLLAUDO, EFFETTUA L' ACCESSO: + + + Qt::AlignCenter + + + + + + + password_le + login_b + + + + diff --git a/src/ui/main_window/__init__.py b/src/ui/main_window/__init__.py new file mode 100644 index 0000000..e8c0405 --- /dev/null +++ b/src/ui/main_window/__init__.py @@ -0,0 +1 @@ +from .main_window import Main_Window diff --git a/src/ui/main_window/main_window.py b/src/ui/main_window/main_window.py new file mode 100644 index 0000000..214edaf --- /dev/null +++ b/src/ui/main_window/main_window.py @@ -0,0 +1,35 @@ +import sip +from PyQt5.QtCore import pyqtSignal +from ui.window import Window + + +class Main_Window(Window): + do = pyqtSignal(dict) + + def __init__(self): + super().__init__() + self.do.connect(self._do) + # print("MAIN_WINDOW ", str(int(QThread.currentThreadId())), flush=True) + + @staticmethod + def _do(config): + return config["f"](*config.get("a", []), **config.get("k", {})) + + def open_tab(self, widget): + self.setCentralWidget(widget) + + def open_window(self, widget, show=True, maximized=False): + wt = widget.__class__.__name__ + if wt not in self.windows or self.windows[wt] is None or sip.isdeleted(self.windows[wt]): + w = Window() + self.windows[wt] = w + w.setCentralWidget(widget) + if show: + if maximized: + w.showMaximized() + else: + w.show() + return w + else: + self.windows[wt].activateWindow() + return self.windows[wt] diff --git a/src/ui/main_window/main_window.ui b/src/ui/main_window/main_window.ui new file mode 100644 index 0000000..2c3f092 --- /dev/null +++ b/src/ui/main_window/main_window.ui @@ -0,0 +1,69 @@ + + + MainWindow + + + + 0 + 0 + 94 + 40 + + + + + + + 0 + 0 + 94 + 24 + + + + + About + + + + + + Amministrazione + + + + + + Strumenti + + + + + + + + + + + Powered by + + + + + Gestione utenti + + + + + Archivio + + + + + Archivio autotest + + + + + + diff --git a/src/ui/qml_circular_gauge/__init__.py b/src/ui/qml_circular_gauge/__init__.py new file mode 100644 index 0000000..e8a15b9 --- /dev/null +++ b/src/ui/qml_circular_gauge/__init__.py @@ -0,0 +1 @@ +from .qml_circular_gauge import Qml_Circular_Gauge diff --git a/src/ui/qml_circular_gauge/components/Arch.qml b/src/ui/qml_circular_gauge/components/Arch.qml new file mode 100644 index 0000000..c2d2834 --- /dev/null +++ b/src/ui/qml_circular_gauge/components/Arch.qml @@ -0,0 +1,31 @@ +import QtQuick 2.15 +import QtQuick.Shapes 1.15 + +Shape { + id: arch + width: parent.width + height: parent.height + anchors.centerIn: parent + property real radius: Math.min(height, width) / 2 + property real startAngle: -90 + property real stopAngle: 90 + property real strokeWidth: outerRadius / 2 + property var color: "black" + // ANTIALIASING + // antialias: true does not work + // smooth: true does not work + layer.enabled: true + layer.samples: 8 + ShapePath { + fillColor: "transparent" + strokeColor: arch.color + strokeWidth: arch.strokeWidth + capStyle: ShapePath.FlatCap + PathAngleArc { + centerX: arch.width / 2; centerY: arch.height / 2 + property real radius: arch.radius - (arch.strokeWidth / 2) + radiusX: radius; radiusY: radius + startAngle: arch.startAngle - 90; sweepAngle: arch.stopAngle - arch.startAngle + } + } +} diff --git a/src/ui/qml_circular_gauge/old_qml_circular_symetric_gauge.qml b/src/ui/qml_circular_gauge/old_qml_circular_symetric_gauge.qml new file mode 100644 index 0000000..36baa1d --- /dev/null +++ b/src/ui/qml_circular_gauge/old_qml_circular_symetric_gauge.qml @@ -0,0 +1,162 @@ +import QtQuick 2.15 +import QtQuick.Controls 1.4 +import QtQuick.Controls.Styles 1.4 +import QtQuick.Extras 1.4 +import QtQuick.Extras.Private 1.0 +import QtQuick.Shapes 1.15 +import QtQuick.Controls 2.15 +import QtGraphicalEffects 1.12 +import "./components" + +// WRAPPER FOR VARIABLES +Item { + id: root + objectName: "gauge" + // EXTERNALLY CONFIGURABLE PROPERTIES + property real size: 300 + property real padding: 10 + property real min: -10.0 + property real max: 10.0 + property real step: 1.0 + property real value: 10.0 + property + var colorRanges: [] + // SHOWN HEIGHT AND WIDTH + height: wrapper.height + width: wrapper.width + // PADDING + Control { + id: wrapper + width: root.size * Math.sin((gauge.angleRange / 2) * (180 / Math.PI)) + padding * 2 + root.size * 0.2 + height: root.size / 2 + padding * 2 + root.size * 0.1 + padding: root.padding + background: Rectangle { + color: "#555" + } + // CROP TO THIS RECTANGLE + contentItem: Rectangle { + clip: true + color: "transparent" + // GAUGE + CircularGauge { + id: gauge + value: root.value + // make step, min, max manageable + stepSize: root.step.toFixed(2) // truncate precision + // calculate new range + // raw values + property real range: root.max - root.min + property real center: (root.min + root.max) / 2 + property real step_count: range / root.step + // adjusted values + property real halfValueRange: Math.ceil(step_count / 2) * stepSize + minimumValue: center - halfValueRange + maximumValue: center + halfValueRange + property real valueRange: maximumValue - minimumValue + // STYLING + // align + anchors.horizontalCenter: parent.horizontalCenter + // anchors.verticalCenter: parent.bottom + y: root.size * 0.03 + tickmarksVisible: true + property real angleRange: 120 + // labels, ticks, minor ticks density + property real labelAngleStep: 22.5 + property real tickmarkAngleStep: labelAngleStep + property real minorTickmarkAngleStep: 2 + // apply styling + style: CircularGaugeStyle { + id: style + objectName: "style" + // GAUGE ANGLE + minimumValueAngle: -(gauge.angleRange / 2) + maximumValueAngle: +(gauge.angleRange / 2) + // value to angle + function _valueToAngle(value) { + return gauge.angleRange * (value / gauge.valueRange) + } + // COLOR + function _get_color(value) { + var rs, r; + for (var i = 0; i < root.colorRanges.length; i += 1) { + rs = root.colorRanges[i]; + for (var j = 0; j < rs[0].length; j += 1) { + r = rs[0][j]; + if (value >= r[0] && value <= r[1]) { + return rs[1]; + } + } + } + return "#c8c8c8"; + } + // BACKGROUND (COLORED RANGES) + background: Rectangle { + id: background + // implicitWidth and implicitHeight set outerRadius for the style + implicitWidth: root.size - root.padding + implicitHeight: root.size - root.padding + width: implicitWidth + height: implicitHeight + color: "transparent" + Connections { + target: root + function onColorRangesChanged() { + var rs, color, r; + for (var i = 0; i < root.colorRanges.length; i += 1) { + rs = root.colorRanges[i]; + color = rs[1]; + for (var j = 0; j < rs[0].length; j += 1) { + r = rs[0][j]; + Qt.createQmlObject(` + import "./components" + Arch { + radius: outerRadius + startAngle: ${_valueToAngle(r[0])} + stopAngle: ${_valueToAngle(r[1])} + strokeWidth: outerRadius * 0.02 + color: "${color}" + } + `, background); + } + } + } + } + } + // // NEEDLE + // needle: Component + // // FOREGROUND (CENTRAL DIAL) + // foreground: Component + // LABELS + tickmarkLabel: Text { + font.pixelSize: Math.max(6, outerRadius * 0.1) + text: styleData.value % 1 == 0 ? styleData.value : styleData.value.toFixed(2) + color: _get_color(styleData.value) + antialiasing: true + } + labelStepSize: Math.ceil(gauge.valueRange / (gauge.angleRange / gauge.labelAngleStep)) // every labelAngleStep degreees or more + property real labelCount: Math.floor(gauge.valueRange / labelStepSize) + // labelInset: 0.0 + // TICKS + tickmark: Rectangle { + implicitWidth: outerRadius * 0.02 + implicitHeight: outerRadius * 0.075 + color: _get_color(styleData.value) + antialiasing: true + } + tickmarkStepSize: Math.ceil(gauge.valueRange / (gauge.angleRange / gauge.tickmarkAngleStep)) // every tickmarkAngleStep degreees or more + property real tickmarkCount: Math.floor(gauge.valueRange / tickmarkStepSize) + tickmarkInset: 0.0 + // MINOR TICKS + minorTickmark: Rectangle { + implicitWidth: outerRadius * 0.01 + implicitHeight: outerRadius * 0.05 + color: _get_color(styleData.value) + antialiasing: true + } + minorTickmarkCount: Math.floor(gauge.angleRange / tickmarkCount / gauge.minorTickmarkAngleStep) // every minorTickmarkAngleStep degreees or more + minorTickmarkInset: 0.0 + } + } + } + } +} diff --git a/src/ui/qml_circular_gauge/qml_circular_gauge.py b/src/ui/qml_circular_gauge/qml_circular_gauge.py new file mode 100644 index 0000000..cc47ff3 --- /dev/null +++ b/src/ui/qml_circular_gauge/qml_circular_gauge.py @@ -0,0 +1,37 @@ +from ui.qml_widget import Qml_Widget + + +class Qml_Circular_Gauge(Qml_Widget): + def __init__(self, min_value, max_value, step, minor_step_n=None, value=None, ranges=None, unit="", angle=None): + super().__init__() + self.set("size", 350) + self.set("padding", 5) + if angle is None: + self.set("min_angle", -60) + self.set("max_angle", +60) + elif type(angle) is int or type(angle) is float: + self.set("min_angle", -(angle / 2)) + self.set("max_angle", +(angle / 2)) + elif type(angle) is list and len(angle) == 2: + self.set("min_angle", min(angle)) + self.set("max_angle", max(angle)) + else: + raise NotImplementedError("Bad circular gauge angle field") + self.set("min", min_value) + self.set("max", max_value) + self.set("step", step) + if minor_step_n is not None: + self.set("minor_step_n", minor_step_n) + self.set("unit", unit) + if value is None: + value = min + self.set_value(value) + if ranges is None: + ranges = [] + self.set_ranges(ranges) + + def set_ranges(self, ranges=[]): + self.set("colorRanges", ranges) + + def set_value(self, value=0): + self.set("value", value) diff --git a/src/ui/qml_circular_gauge/qml_circular_gauge.qml b/src/ui/qml_circular_gauge/qml_circular_gauge.qml new file mode 100644 index 0000000..50ddeec --- /dev/null +++ b/src/ui/qml_circular_gauge/qml_circular_gauge.qml @@ -0,0 +1,187 @@ +import QtQuick 2.15 +import QtQuick.Controls 1.4 +import QtQuick.Controls.Styles 1.4 +import QtQuick.Extras 1.4 +import QtQuick.Extras.Private 1.0 +import QtQuick.Shapes 1.15 +import QtQuick.Controls 2.15 +import QtGraphicalEffects 1.12 +import "./components" + +// WRAPPER FOR VARIABLES +Item { + id: root + objectName: "gauge" + // EXTERNALLY CONFIGURABLE PROPERTIES + property real size: 300 + property real padding: 10 + property real min: -10.0 + property real max: 10.0 + property real step: 1.0 + property real minor_step_n: -4 // positive for count negative for angle + property real value: 0.0 + property string unit: "number" + property real min_angle: -60 + property real max_angle: +60 + property + var colorRanges: [] + // SHOWN HEIGHT AND WIDTH + height: wrapper.height + width: wrapper.width + // COLOR + function _get_color(value) { + var rs, r; + for (var i = 0; i < root.colorRanges.length; i += 1) { + rs = root.colorRanges[i]; + for (var j = 0; j < rs[0].length; j += 1) { + r = rs[0][j]; + if (value >= r[0] && value <= r[1]) { + return rs[1]; + } + } + } + return "#c8c8c8"; + } + // PADDING + Control { + id: wrapper + width: root.size * 1.2 * Math.sin((gauge.angleRange / 2) * (Math.PI / 180)) + padding * 2 + property real max_side_angle: Math.max(Math.abs(root.min_angle), Math.abs(root.max_angle)) + height: root.size * 1.2 / 2 * (max_side_angle <= 90 ? 1 : (1 + Math.sin((max_side_angle - 90) * (Math.PI / 180)))) + padding * 2 + padding: root.padding + background: Rectangle { + color: "#555" + } + // CROP TO THIS RECTANGLE + contentItem: Rectangle { + clip: true + color: "transparent" + // GAUGE + CircularGauge { + id: gauge + value: root.value + minimumValue: root.min + maximumValue: root.max + // STYLING + // align + anchors.horizontalCenter: parent.horizontalCenter + // anchors.verticalCenter: parent.bottom + y: root.size * 0.03 + tickmarksVisible: true + property real valueRange: maximumValue - minimumValue + property real valueCenter: (root.min + root.max) / 2 + property real angleRange: Math.abs(root.min_angle) + Math.abs(root.max_angle) + property real angleCenter: (root.min_angle + root.max_angle) / 2 + // labels, ticks, minor ticks density + property real minorTickmarkAngleStep: Math.abs(root.minor_step_n) + // apply styling + style: CircularGaugeStyle { + id: style + objectName: "style" + // GAUGE ANGLE + minimumValueAngle: -(gauge.angleRange / 2) + maximumValueAngle: +(gauge.angleRange / 2) + // value to angle + function _valueToAngle(value) { + return ((value - gauge.valueCenter) / gauge.valueRange) * gauge.angleRange + gauge.angleCenter + } + // BACKGROUND (COLORED RANGES) + background: Rectangle { + id: background + // implicitWidth and implicitHeight set outerRadius for the style + implicitWidth: root.size - root.padding + implicitHeight: root.size - root.padding + width: implicitWidth + height: implicitHeight + color: "transparent" + Connections { + target: root + function onColorRangesChanged() { + var rs, color, r; + for (var i = 0; i < root.colorRanges.length; i += 1) { + rs = root.colorRanges[i]; + color = rs[1]; + for (var j = 0; j < rs[0].length; j += 1) { + r = rs[0][j]; + Qt.createQmlObject(` + import "./components" + Arch { + radius: outerRadius + startAngle: ${_valueToAngle(r[0])} + stopAngle: ${_valueToAngle(r[1])} + strokeWidth: outerRadius * 0.02 + color: "${color}" + } + `, background); + } + } + } + } + } + // // NEEDLE + // needle: Component + // // FOREGROUND (CENTRAL DIAL) + // foreground: Component + // LABELS + tickmarkLabel: Text { + font.pixelSize: Math.max(6, outerRadius * 0.1) + text: styleData.value % 1 == 0 ? styleData.value : styleData.value.toFixed(2) + color: "#c8c8c8" // root._get_color(styleData.value) + antialiasing: true + } + labelStepSize: root.step + property real labelCount: Math.floor(gauge.valueRange / labelStepSize) + // labelInset: 0.0 + // TICKS + tickmark: Rectangle { + implicitWidth: outerRadius * 0.02 + implicitHeight: outerRadius * 0.075 + color: root._get_color(styleData.value) + antialiasing: true + } + tickmarkStepSize: root.step + tickmarkInset: 0.0 + // MINOR TICKS + minorTickmark: Rectangle { + implicitWidth: outerRadius * 0.01 + implicitHeight: outerRadius * 0.05 + color: root._get_color(styleData.value) + antialiasing: true + } + minorTickmarkCount: root.minor_step_n > 0 ? Math.floor(root.minor_step_n) : Math.floor(gauge.angleRange / tickmarkCount / gauge.minorTickmarkAngleStep) // every minorTickmarkAngleStep degreees or more + minorTickmarkInset: 0.0 + } + } + + Rectangle { + anchors.centerIn: parent + width: root.size * 0.4 + height: root.size * 0.25 + color: "transparent" + + Text { + id: value_text + anchors.centerIn: parent + property real font_size: root.size * 0.1 + font.pixelSize: font_size + fontSizeMode: Text.Fit + font.bold: true + text: root.value % 1 == 0 ? root.value : root.value.toFixed(2) + color: "#c8c8c8" // root._get_color(root.value) + antialiasing: true + } + + Text { + anchors.top: value_text.bottom + anchors.horizontalCenter: parent.horizontalCenter + font.pixelSize: value_text.font_size * 0.5 + fontSizeMode: Text.Fit + font.bold: true + text: root.unit + color: "#c8c8c8" // root._get_color(root.value) + antialiasing: true + } + } + } + } +} diff --git a/src/ui/qml_led/__init__.py b/src/ui/qml_led/__init__.py new file mode 100644 index 0000000..dbcf966 --- /dev/null +++ b/src/ui/qml_led/__init__.py @@ -0,0 +1 @@ +from .qml_led import Qml_Led diff --git a/src/ui/qml_led/qml_led.py b/src/ui/qml_led/qml_led.py new file mode 100644 index 0000000..f664d39 --- /dev/null +++ b/src/ui/qml_led/qml_led.py @@ -0,0 +1,17 @@ +from ui.qml_widget import Qml_Widget + + +class Qml_Led(Qml_Widget): + def __init__(self, size, color=None): + super().__init__() + self.set("size", size) + self.set_color(color) + + def set_color(self, color): + self.color = color + if self.color is None: + self.set("led_on", False) + self.set("led_color", color) + else: + self.set("led_on", True) + self.set("led_color", color) diff --git a/src/ui/qml_led/qml_led.qml b/src/ui/qml_led/qml_led.qml new file mode 100644 index 0000000..c6510b6 --- /dev/null +++ b/src/ui/qml_led/qml_led.qml @@ -0,0 +1,19 @@ +import QtQuick 2.2 +import QtQuick.Extras 1.4 + +Rectangle { + property real size: 16 + property bool led_on: false + property color led_color: "gray" + + width: size + height: size + color: "transparent" + + StatusIndicator { + anchors.fill: parent + anchors.centerIn: parent + active: led_on + color: led_color + } +} diff --git a/src/ui/qml_switch/__init__.py b/src/ui/qml_switch/__init__.py new file mode 100644 index 0000000..639d6c8 --- /dev/null +++ b/src/ui/qml_switch/__init__.py @@ -0,0 +1 @@ +from .qml_switch import Qml_Switch diff --git a/src/ui/qml_switch/qml_switch.py b/src/ui/qml_switch/qml_switch.py new file mode 100644 index 0000000..c100eab --- /dev/null +++ b/src/ui/qml_switch/qml_switch.py @@ -0,0 +1,15 @@ +from ui.qml_widget import Qml_Widget + + +class Qml_Switch(Qml_Widget): + def __init__(self, checked=False): # , size, color=None): + super().__init__() + self.set_checked(False) + self.toggled = self.qml_item.toggled + + def set_checked(self, checked): + self.set("checked", checked) + + @property + def checked(self): + return self.get("checked") diff --git a/src/ui/qml_switch/qml_switch.qml b/src/ui/qml_switch/qml_switch.qml new file mode 100644 index 0000000..cd81722 --- /dev/null +++ b/src/ui/qml_switch/qml_switch.qml @@ -0,0 +1,26 @@ +import QtQuick 2.15 +import QtQuick.Controls 2.15 + +Switch { + id: control + checked: true + + indicator: Rectangle { + implicitWidth: 64 + implicitHeight: 32 + x: control.width - width - control.rightPadding + y: parent.height / 2 - height / 2 + radius: 16 + color: control.checked ? "#0f0" : "#f00" + border.color: "black" + + Rectangle { + x: control.checked ? parent.width - width : 0 + width: 32 + height: 32 + radius: 16 + color: control.checked ? "#bfb" : "#fbb" + border.color: "black" + } + } + } diff --git a/src/ui/qml_widget/__init__.py b/src/ui/qml_widget/__init__.py new file mode 100644 index 0000000..8a28388 --- /dev/null +++ b/src/ui/qml_widget/__init__.py @@ -0,0 +1 @@ +from .qml_widget import Qml_Widget diff --git a/src/ui/qml_widget/qml_widget.py b/src/ui/qml_widget/qml_widget.py new file mode 100644 index 0000000..cf602bb --- /dev/null +++ b/src/ui/qml_widget/qml_widget.py @@ -0,0 +1,72 @@ +from lib.helpers import get_resource +from PyQt5.QtCore import QObject, Qt, QUrl +from PyQt5.QtQuickWidgets import QQuickWidget + +# TODO: https://www.pythonguis.com/tutorials/qml-qtquick-python-application/ + + +class Qml_Widget(QQuickWidget): + def __init__(self, background=None): + super().__init__() + self.setAttribute(Qt.WA_DeleteOnClose) + # self.setAttribute(Qt.WA_TranslucentBackground) + if background is None: + self.setClearColor(Qt.transparent) + self.setAttribute(Qt.WA_AlwaysStackOnTop) + else: + self.setClearColor(background) + me = self.__class__.__name__ + u = get_resource("ui/{0}/{0}.qml".format(me.lower())) + self.setSource(QUrl(u)) + # self.qml_item = self.rootObject().findChild(QObject, me) + self.qml_item = self.rootObject() + # self.setResizeMode(self.SizeViewToRootObject) + # self.setResizeMode(self.SizeRootObjectToView) + # print(self.dump(self.get_child(self.qml_item, "gauge")), flush=True) + + # def dump(self, object=None): + # if object is None: + # object = self.qml_item + # mo = object.metaObject() + # d = {} + # for i in range(mo.propertyCount()): + # mp = mo.property(i) + # try: + # d[mp.name()] = mp.read(object) + # except TypeError: + # d[mp.name()] = None + # return d + + def get_child(self, item=None, address=0): + if item is None: + item = self.qml_item + + def _get_child(item, address=0): + if address is None: + return item + elif type(address) is str: + return item.findChild(QObject, address) + elif type(address) is int: + children = item.children() + if address < len(children): + return children[address] + else: + raise AttributeError("{!r}".format(address)) + + try: + if type(address) is list: + for a in address: + item = _get_child(item, a) + else: + item = _get_child(item, address) + return item + except AttributeError as e: + raise AttributeError("qml child item not found: {} ({!r})".format(str(e), address)) + + def set(self, property, value, child=None): + item = self.get_child(self.qml_item, child) + item.setProperty(property, value) + + def get(self, property, child=None): + item = self.get_child(self.qml_item, child) + return item.property(property) diff --git a/src/ui/qml_widget/qml_widget.qml b/src/ui/qml_widget/qml_widget.qml new file mode 100644 index 0000000..2b994ef --- /dev/null +++ b/src/ui/qml_widget/qml_widget.qml @@ -0,0 +1,9 @@ +import QtQuick 2.15 + +Rectangle { + id: root + objectName: "root" + width: 100 + height: 100 + color: "gray" +} diff --git a/src/ui/recipe_editor/__init__.py b/src/ui/recipe_editor/__init__.py new file mode 100644 index 0000000..0f78de5 --- /dev/null +++ b/src/ui/recipe_editor/__init__.py @@ -0,0 +1 @@ +from .recipe_editor import Recipe_Editor diff --git a/src/ui/recipe_editor/recipe_editor.py b/src/ui/recipe_editor/recipe_editor.py new file mode 100644 index 0000000..e84dd22 --- /dev/null +++ b/src/ui/recipe_editor/recipe_editor.py @@ -0,0 +1,120 @@ +from PyQt5.QtWidgets import (QCheckBox, QComboBox, QDoubleSpinBox, QLineEdit, + QRadioButton, QSpinBox, QPlainTextEdit) +from ui.widget import Widget + + +class Recipe_Editor(Widget): + def __init__(self): + super().__init__() + self.spec = { + # recipe + "client": self.client_le, + "part_number": self.part_number_le, + "station": self.station_le, + # pressure + "pressure_min": self.pressure_min_sb, + "pressure_test": self.pressure_test_sb, + "pressure_max": self.pressure_max_sb, + "pressure_ramp": self.pressure_ramp_sb, + # test + "cleaning_time": self.cleaning_time_sb, + "tolerance": self.tolerance_sb, + "test_duration": self.test_duration_sb, + "flush_duration": self.flush_duration_sb, + # stabilizarion + "stabilization_time": self.stabilization_time_sb, + "stabilization_level_min": self.stabilization_level_min_sb, + "stabilization_level_max": self.stabilization_level_max_sb, + "stabilization_settling_time": self.stabilization_settling_time_sb, + "stabilization_cycles": self.stabilization_cycles_sb, + # description + "description": self.description_pte, + } + + def set_readonly(self, readonly): + for w in self.spec.values(): + w.setReadOnly(readonly) + + def do_autocomplete(self, autocomplete): + if autocomplete is None: + return + for k, v in autocomplete.items(): + w = self.spec[k] + if isinstance(w, QCheckBox): + w.setChecked(bool(v)) + elif isinstance(w, QComboBox): + w.addItems(list(map(str, v))) + elif isinstance(w, QDoubleSpinBox): + w.setValue(float(v)) + elif isinstance(w, QLineEdit): + w.setText(str(v)) + elif isinstance(w, QRadioButton): + w.setChecked(bool(v)) + elif isinstance(w, QSpinBox): + w.setValue(int(v)) + elif isinstance(w, QPlainTextEdit): + w.setPlainText(str(v)) + else: + raise NotImplementedError(f"widget of type {type(w)!r} not implemented") + + def connect_modified(self, check_modified): + for k, w in self.spec.items(): + if isinstance(w, QCheckBox): + w.toggled.connect(check_modified) + elif isinstance(w, QComboBox): + w.currentTextChanged.connect(check_modified) + elif isinstance(w, QDoubleSpinBox): + w.valueChanged.connect(check_modified) + elif isinstance(w, QLineEdit): + w.textChanged.connect(check_modified) + elif isinstance(w, QRadioButton): + w.toggled.connect(check_modified) + elif isinstance(w, QSpinBox): + w.valueChanged.connect(check_modified) + elif isinstance(w, QPlainTextEdit): + w.textChanged.connect(check_modified) + else: + raise NotImplementedError(f"widget of type {type(w)!r} not implemented") + + def render(self, data, field_name=None, row_number=None, crud=None): + for k, v in data.items(): + if k not in self.spec: + continue + w = self.spec[k] + if isinstance(w, QCheckBox): + w.setChecked(bool(v)) + elif isinstance(w, QComboBox): + w.setCurrentText(str(v)) + elif isinstance(w, QDoubleSpinBox): + w.setValue(float(v)) + elif isinstance(w, QLineEdit): + w.setText(str(v)) + elif isinstance(w, QRadioButton): + w.setChecked(bool(v)) + elif isinstance(w, QSpinBox): + w.setValue(int(v)) + elif isinstance(w, QPlainTextEdit): + w.setPlainText(str(v)) + else: + raise NotImplementedError(f"widget of type {type(w)!r} not implemented") + + def parse(self, row_number=None, crud=None): + ret = {} + for k, w in self.spec.items(): + if isinstance(w, QCheckBox): + ret[k] = w.isChecked() + elif isinstance(w, QComboBox): + ret[k] = w.currentText() + elif isinstance(w, QDoubleSpinBox): + ret[k] = w.value() + elif isinstance(w, QLineEdit): + ret[k] = w.text() + elif isinstance(w, QRadioButton): + ret[k] = w.isChecked() + elif isinstance(w, QSpinBox): + ret[k] = w.value() + elif isinstance(w, QPlainTextEdit): + ret[k] = w.toPlainText() + else: + raise NotImplementedError(f"widget of type {type(w)!r} not implemented") + return ret diff --git a/src/ui/recipe_editor/recipe_editor.ui b/src/ui/recipe_editor/recipe_editor.ui new file mode 100644 index 0000000..8dcd520 --- /dev/null +++ b/src/ui/recipe_editor/recipe_editor.ui @@ -0,0 +1,310 @@ + + + Recipe_Editor + + + + 0 + 0 + 494 + 654 + + + + + + + Pressione + + + + + + + + + Min + + + + + + + Max + + + + + + + + + + bar + + + + + + + + + + + + + Test + + + + + + + bar + + + + + + + Rampa di salita + + + + + + + bar + + + + + + + bar/min + + + + + + + + + + Descrizione + + + + + + + + + + + + Test + + + + + + Tempo di prova + + + + + + + Tempo di scarico + + + + + + + Tempo di pulizia + + + + + + + + + + Tolleranza + + + + + + + + + + + + + + + + s + + + + + + + bar + + + + + + + s + + + + + + + s + + + + + + + + + + Ricetta + + + + + + Cliente + + + + + + + Stazione + + + + + + + + + + + + + N° disegno + + + + + + + + + + + + + Stabilizzazione + + + + + + Soglia max + + + + + + + Soglia min + + + + + + + + + + + + + + + + + + + + + + Tempo assestamento + + + + + + + Numero di cicli + + + + + + + Tempo + + + + + + + s + + + + + + + % + + + + + + + % + + + + + + + s + + + + + + + + + + + diff --git a/src/ui/recipe_selection/__init__.py b/src/ui/recipe_selection/__init__.py new file mode 100755 index 0000000..01a3317 --- /dev/null +++ b/src/ui/recipe_selection/__init__.py @@ -0,0 +1 @@ +from .recipe_selection import * diff --git a/src/ui/recipe_selection/recipe_selection.py b/src/ui/recipe_selection/recipe_selection.py new file mode 100755 index 0000000..779991d --- /dev/null +++ b/src/ui/recipe_selection/recipe_selection.py @@ -0,0 +1,151 @@ +import sys + +from lib.db import Recipes, Users +from PyQt5.QtCore import Qt, QTimer, pyqtSignal +from PyQt5.QtGui import QKeySequence +from PyQt5.QtWidgets import QPushButton, QShortcut +from ui.crud import Cell, Crud +from ui.dialog import Dialog +from ui.helpers import replace_widget +from ui.recipe_editor import Recipe_Editor +from ui.widget import Widget + + +class Json_Spec_External_Dialog_Cell_Widget(QPushButton, Cell): + def __init__(self, readonly=True, autocomplete=None, field_name=None, field_alias=None, field=None): + self.dialog = Dialog() + self.dialog.setAttribute(Qt.WA_DeleteOnClose, on=False) + self.editor = Recipe_Editor() + self.dialog.setCentralWidget(self.editor) + super().__init__(u"\u238B apri") + Cell.__init__(self, readonly=readonly, autocomplete=autocomplete, field_name=field_name, field_alias=field_alias, field=field) + self.dialog.setWindowTitle(self.field_alias) + self.clicked.connect(self.dialog.show) + + def set_readonly(self, readonly): + self.editor.set_readonly(readonly) + + def do_autocomplete(self, autocomplete): + self.editor.do_autocomplete(autocomplete) + + def connect_modified(self): + self.editor.connect_modified(self.check_modified) + + def render(self, data, field_name=None, row_number=None, crud=None): + self.editor.render(data, field_name=field_name, row_number=row_number, crud=crud) + + def parse(self, row_number=None, crud=None): + return self.editor.parse(row_number=row_number, crud=crud) + + +def recipes_row_upgrader(row, row_number, crud): + if len(row.keys() & row["spec"].keys()): + raise AssertionError("field keys in Recipes model MUST NOT be present inside Recipes.spec") + row.update(row["spec"]) + return row + + +def recipes_row_filter(row, row_number, crud): + for k in list(row): + if k in row["spec"]: + row["spec"][k] = row.pop(k) + return True, row, False + + +class Recipe_Selection(Widget): + ok = pyqtSignal(Recipes) + + def __init__(self): + super().__init__() + session = Users.get_session() + if session.is_admin: + readonly = False + crud_aliases = { + "name": "Ricetta", + "spec": "Specifica", + # "client": "Cliente", + # "part_number": "N° disegno", + # "station": "Stazione", + # "cleaning_time": "Tempo di pulizia", + # "pressure_ramp": "Rampa di salita", + # "tolerance": "Tolleranza", + # "test_duration": "Tempo di prova", + # "flush_duration": "Tempo di scarico", + # "pressure_min": "Pressione min", + # "pressure_test": "Pressione test", + # "pressure_max": "Pressione max", + # "stabilization_time": "Tempo di stabilizzazione", + # "stabilization_level_min": "Soglia di stabilizzazione min", + # "stabilization_level_max": "Soglia di stabilizzazione max", + # "stabilization_settling_time": "Tempo di assestamento", + # "stabilization_cycles": "Numero di cicli", + # "description": "Desccrizione", + "archived": "Archiviata", + } + filters = None + else: + readonly = True + crud_aliases = { + "name": "Ricetta", + "client": "Cliente", + "part_number": "N° disegno", + "description": "Desccrizione", + "spec": "Specifica", + } + filters = {"archived": False} + self.crud = Crud( + "recipes", + display_name="SELEZIONE RICETTA", + readonly=readonly, + select=list(crud_aliases.keys()), + filters=filters, + fields_aliases=crud_aliases, + autocomplete={"archived": False}, + row_upgrader=recipes_row_upgrader, + widget_classes={"spec": Json_Spec_External_Dialog_Cell_Widget, }, + row_filter=recipes_row_filter, + ) + replace_widget(self, "crud_w", self.crud) + self.crud_modified = None + self.selected = None + self.select_b.setEnabled(False) + self.select_b.clicked.connect(self.select) + QShortcut(QKeySequence("Return"), self).activated.connect(self.select_b.click) + self.crud.modified.connect(self.check_modified) + self.crud.selected.connect(self.check_selected) + self.crud.emit() + # TESTING + if "--auto-select" in sys.argv or "--test" in sys.argv: + drawing = "TEST" + cn = self.crud.select_index["name"] + for rn in range(1, self.crud.db_tw.rowCount()): + if self.crud.db_tw.cellWidget(rn, cn).text() == drawing: + selection = self.crud.db_tw.model().index(rn, cn) + self.crud.db_tw.setCurrentIndex(selection) + break + self.test_timer = QTimer() + self.test_timer.setSingleShot(True) + self.test_timer.timeout.connect(self.select_b.clicked.emit) + self.test_timer.start(500) + # /TESTING + + def check_modified(self, modified): + self.crud_modified = modified + self.check(self.crud_modified, self.selected) + + def check_selected(self, selected): + if len(selected) == 1: + selected = selected[0] - 1 # - 1 because rn starts from 1 (filters line) + if selected >= 0 and selected < len(self.crud.data_index): + selected = self.crud.data_index[selected] + self.selected = self.crud.db.table_model.get_by_id(selected) + else: + self.selected = None + self.check(self.crud_modified, self.selected) + + def check(self, modified, selected): + self.select_b.setEnabled(modified is False and selected is not None) + + def select(self): + if self.selected is not None: + self.ok.emit(self.selected) diff --git a/src/ui/recipe_selection/recipe_selection.ui b/src/ui/recipe_selection/recipe_selection.ui new file mode 100644 index 0000000..0d798b8 --- /dev/null +++ b/src/ui/recipe_selection/recipe_selection.ui @@ -0,0 +1,44 @@ + + + Recipe selection + + + + 0 + 0 + 800 + 600 + + + + + + + + 0 + 0 + + + + + 16 + 75 + true + + + + COLLAUDA CODICE SELEZIONATO + + + + + + + + + + select_b + + + + diff --git a/src/ui/test/__init__.py b/src/ui/test/__init__.py new file mode 100755 index 0000000..9707fac --- /dev/null +++ b/src/ui/test/__init__.py @@ -0,0 +1 @@ +from .test import * diff --git a/src/ui/test/test.py b/src/ui/test/test.py new file mode 100755 index 0000000..631e7ee --- /dev/null +++ b/src/ui/test/test.py @@ -0,0 +1,288 @@ +import logging +import os +import sys +import time +from datetime import datetime + +from lib.db import Archive, Recipes, Users +from PyQt5.QtCore import Qt, QTimer +from ui.helpers import replace_widget +from ui.recipe_selection import Recipe_Selection +from ui.test_assembly import Test_Assembly +from ui.test_autotest import Test_Autotest +from ui.widget import Widget + + +class Test(Widget): + def __init__(self, system_name=None): + super().__init__() + self.system_name = system_name + # GET LOGGER + self.log = logging.getLogger("Test") + # SHOW USERNAME + session = Users.get_session() + self.user_l.setText(session.username) + if session.is_admin: + self.user_l.setStyleSheet("QLabel { color: red; }") + else: + self.user_l.setStyleSheet("") + # SHOW AND UPDATE TIME CLOCK + self.refresh_time(init=True) + # INIT RECIPE + self.recipe = None + # INIT CYCLE STATES + self.cycle_state = None + self.cycle_states = { + "select_recipe": Test_Assembly(None, u"SELEZIONARE IL CODICE DA COLLAUDARE", Recipe_Selection()), + "autotest": Test_Assembly(None, u"ESEGUIRE PROCEDURA DI AUTOTEST", Test_Autotest()), + "assembly_1": Test_Assembly(self.select_step_img("assembly_1"), u"INSERIRE SENSORE"), + "done": Test_Assembly(self.select_step_img("success"), u"COLLAUDO COMPLETATO - RIMUOVERE IL SENSORE"), + "wait": Test_Assembly(self.select_step_img("wait"), u"ATTENDERE - PAUSA INTER CICLO"), + "fail": Test_Assembly(self.select_step_img("fail"), u"CICLO INTERROTTO - RIMUOVERE IL SENSORE"), + "emergency": Test_Assembly(self.select_step_img("reset_emergency"), u"EMERGENZA INTERVENUTA - RIPRISTINARE PULSANTE E SELEZIONARE \"RESET EMERGENZA\" DAL MEN\u00d9 \"STRUMENTI\""), + } + self.cycle_loop = ["assembly_1", "done", "wait"] + self.cycle_changing_state = False + # SETUP AUTOTEST + self.autotest_request = False + if "--no-autotest" not in sys.argv: + self.autotest_period = 12 * 60 * 60 * 1000 + self.request_autotest("init") + else: + self.autotest_period = None + # INIT PIECES COUNTER ([pieces_ok, pieces_failed]) + self.pieces = [0, 0] + # CONNECT CYCLE CONTROLS + self.cancel_b.clicked.connect(self.fail_cycle) + self.change_recipe_b.clicked.connect(self.change_recipe) + for w in self.cycle_states.values(): + if hasattr(w, "ko"): + w.ko.connect(self.fail_cycle) + if hasattr(w, "ok"): + # custom ok handlers should call next again + if type(w.widget) is Recipe_Selection: + w.ok.connect(self.set_recipe) + # elif type(w) is Test_Camera: + # w.ok.connect(self.set_data) + else: + w.ok.connect(self.next) + # TESTING + if "--test" in sys.argv: + self.testing = True + else: + self.testing = False + # /TESTING + # START CYCLE + self.next() + + def refresh_time(self, init=False): + if init: + self.time_timer = QTimer() + self.time_timer.setSingleShot(True) + self.time_timer.timeout.connect(self.refresh_time) + t = datetime.now() + self.time_l.setText("{d}/{mo}/{y}\n{h}:{m}".format(y=t.year, mo=t.month, d=t.day, h=t.hour, m=t.minute)) + self.time_timer.start(60 - t.second) + + def select_step_img(self, step, suffix=None): + img_path = "./src/ui/imgs" + names = [] + if suffix is not None: + names.append(f"{step}_{suffix}_{self.system_name}") + names.append(f"{step}_{suffix}") + names.append(f"{step}_{self.system_name}") + names.append(f"{step}") + for name in names: + for ext in ["png", "jpg"]: + path = f"{img_path}/{name}.{ext}" + if os.path.isfile(path): + return path + raise FileNotFoundError(f"No image was found for step {step}") + + def change_recipe(self): + self.next(action="change_recipe") + + def fail_cycle(self): + self.next(action="fail") + + def setCentralWidget(self, widget): + replace_widget(self, "centralWidget", widget) + + # def check_next(self, interpreted): + # self.disconnect(self.watched) + # if "digital_io" in interpreted: + # if interpreted["digital_io"]["emergency"] is True and self.cycle_state != "emergency": + # self.cycle_state = "emergency" + # self.next() + # elif interpreted["digital_io"]["emergency"] is False and self.cycle_state == "emergency" and self.is_first_cycle_time: + # self.is_first_cycle_time = False + # # reset cycle + # if self.recipe is None: + # self.cycle_state = -1 # go to recipe selection + # else: + # self.cycle_state = 0 # skip recipe selection + # self.next(fail=True) + # if type(self.centralWidget()) is not Test_Autotest: # IGNORE DURING AUTOTEST + # if self.cycle_state == 1: # WAIT FOR PIECE PRESENCE + # if self.testing is True and self.is_first_cycle_time: + # self.bench.inputs["io"].set_bit(*self.bench.parsers["digital_io"].presence1, True) + # self.bench.inputs["io"].set_bit(*self.bench.parsers["digital_io"].presence2, True) + # # /TESTING + # if self.is_first_cycle_time: + # self.is_first_cycle_time = False + # log_msg("cycle state:", self.cycle_state, "done", msg_type="test") + # if "digital_io" in interpreted and interpreted["digital_io"]["presence1"] and interpreted["digital_io"]["presence2"]: + # self.bench.parsers["digital_io"].handler({"lock": True}) + # self.next() + # elif self.cycle_state == 2: # PIECE INSERTED, LOCK PIECE AFTER DELAY + # if self.is_first_cycle_time: + # self.is_first_cycle_time = False + # log_msg("cycle state:", self.cycle_state, "done", msg_type="test") + # self.timer.start(2000) + # elif self.cycle_state == 4: # CAMERA TEST & BARCODE READ DONE, START ASSEMBLY PHASE + # if self.is_first_cycle_time: + # self.is_first_cycle_time = False + # log_msg("cycle state:", self.cycle_state, "done", msg_type="test") + # self.timer.start(2000) + # elif self.cycle_state == 5: # WAIT FOR SCREWDRIVER COUNTS + # if self.is_first_cycle_time and self.bench.parsers["pick_to_light"].request is None: + # self.bench.parsers["pick_to_light"].handler({ + # "vtfe10": self.recipe.vtfe10, + # "vtfe11": self.recipe.vtfe11, + # "vtfe13": self.recipe.vtfe13, + # }) + # log_msg("SCREWDRIVER ENABLE", msg_type="test") + # elif "pick_to_light" in interpreted and interpreted["pick_to_light"] is not None: + # screws = [interpreted["pick_to_light"][k] for k in ["vtfe10", "vtfe11", "vtfe13"]] + # needed = [self.bench.parsers["pick_to_light"].request[k] for k in ["vtfe10", "vtfe11", "vtfe13"]] + # done = all([screwed >= requested for screwed, requested in zip(screws, needed)]) + # self.screw_text(screws) + # if self.is_first_cycle_time and done: + # self.is_first_cycle_time = False + # log_msg("cycle state:", self.cycle_state, "done", msg_type="test") + # self.bench.parsers["pick_to_light"].handler(None) + # self.screw_text([0, 0, 0]) + # self.bench.parsers["digital_io"].handler({"lock": False}) + # self.next() + # elif self.cycle_state == 6: + # # TESTING + # if self.testing is True: + # self.bench.inputs["io"].set_bit(*self.bench.parsers["digital_io"].presence1, False) + # self.bench.inputs["io"].set_bit(*self.bench.parsers["digital_io"].presence2, False) + # # /TESTING + # if not self.is_done: + # self.is_done = True + # self.done(True) + # if self.is_done and "digital_io" in interpreted and not interpreted["digital_io"]["presence1"] and not interpreted["digital_io"]["presence2"] and self.is_first_cycle_time: + # self.is_first_cycle_time = False + # log_msg("cycle state:", self.cycle_state, "done", msg_type="test") + # if self.skip: + # # reset cycle + # self.cycle_state = 0 # skip recipe selection + # self.skip = False + # self.next() + # else: + # self.timer.start(2000) + # elif self.cycle_state == 7: # WAITING BEFORE NEW CYCLE + # if self.is_first_cycle_time: + # self.is_first_cycle_time = False + # log_msg("cycle state:", self.cycle_state, "wait", msg_type="test") + # # reset cycle + # self.cycle_state = 0 # skip recipe selection + # self.timer.start(6000) + # self.watched = self.bench.interpreter.update.connect(self.check_next) + + def request_autotest(self, reason): # you can cancel the request calling request_autotest(False) + self.log.info(f"cycle request autotest: reason: {reason!r} autotest_request: {self.autotest_request!r}") + if reason == "init": + self.autotest_timer = QTimer() + self.autotest_timer.setSingleShot(False) + self.autotest_timer.timeout.connect(self.request_periodic_autotest) + self.time_timer.start(self.autotest_period) + reason = "boot" + self.autotest_request = reason + self.cycle_states["autotest"].widget.set_reason(reason) + + def request_periodic_autotest(self): + self.request_autotest("periodic") + + def next(self, action=None): + self.log.debug(f"cycle next: cycle_state: {self.cycle_state!r} action: {action!r}") + if action == "change_recipe": + self.log.info(f"cycle next: action: {action!r}") + self.set_recipe(recipe=None) + self.cycle_changing_state = True + self.cycle_state = "select_recipe" + elif action == "fail": + self.log.info(f"cycle next: action: {action!r}") + if self.cycle_state in self.cycle_loop: + pass + # COUNT FAIL + self.pieces[1] += 1 + # FAIL AND RESTART TEST + self.cycle_changing_state = True + self.cycle_state = "fail" + elif action is not None: + raise NotImplementedError(f"cycle next: action {action!r} is not a valid action") + # if action did not set the next cycle_state + # set next cycle_state normally + if not self.cycle_changing_state: + self.cycle_changing_state = True + if self.recipe is None: + # if recipe not set: select_recipe + self.cycle_state = "select_recipe" + else: + try: + # get current cycle_state index in cycle_loop + cycle_index = self.cycle_loop.index(self.cycle_state) + except ValueError: + # if current cycle_state not in cycle_loop + # start the cycle_loop + cycle_index = -1 + if cycle_index == -1 and self.autotest_request is not False: + # if cycle_loop is not started or has ended + # and autotest was requested + self.autotest_request = False + self.cycle_state = "autotest" + if self.autotest_period is not None: # reset periodic autotest timer + self.time_timer.start(self.autotest_period) + else: + # goto next step in cycle_loop + self.cycle_state = self.cycle_loop[(cycle_index + 1) % len(self.cycle_loop)] + # enable/disable cycle controls + self.change_recipe_b.setEnabled(self.recipe is not None) + self.cancel_b.setEnabled(self.cycle_state not in { + "emergency", + "fail", + "select_recipe", + "wait", + }) + self.log.info(f"cycle next: next cycle_state: {self.cycle_state!r}") + if self.cycle_state == "done": + self.done() + w = self.cycle_states[self.cycle_state] + # UPDATE PIECES DISPLAY + self.pieces_count_l.setText(f"{self.pieces[0]} OK / {self.pieces[1]} NOK / {sum(self.pieces)} TOT") + self.setCentralWidget(w) + self.cycle_changing_state = False + + def set_recipe(self, recipe=None): + self.recipe = recipe + # UPDATE RECIPE DISPLAY + if self.recipe is not None: + self.recipe_l.setText(self.recipe.name) + self.recipe_l.setStyleSheet("") + self.next() + else: + self.recipe_l.setText("NON SELEZIONATA") + self.recipe_l.setStyleSheet("QLabel { color: red; }") + + def done(self, ok=False): + self.log.info("cycle done") + archived = Archive.archive(self.recipe, self.data, overridden=self.data["overridden"]) + self.log.info(f"cycle archived locally: {archived!r}") + # LABEL PRINT + # self.printer.print_label("1", archived) + # self.log.info(f"cycle printed: {archived!r}") + # COUNT OK + self.pieces[0] += 1 diff --git a/src/ui/test/test.ui b/src/ui/test/test.ui new file mode 100755 index 0000000..b6f355d --- /dev/null +++ b/src/ui/test/test.ui @@ -0,0 +1,233 @@ + + + Test + + + + 0 + 0 + 763 + 85 + + + + + + + + + + + + + + + 0 + 0 + + + + + + + + 3 + + + 3 + + + 3 + + + 3 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + 0 + 0 + + + + N. DISEGNO: + + + + + + + + 0 + 0 + + + + + 120 + 0 + + + + + 20 + 16777215 + + + + - + + + + + + + + 0 + 0 + + + + + 0 + 0 + + + + + 200 + 40 + + + + CAMBIA DISEGNO + + + + + + + - + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + 200 + 40 + + + + ANNULLA TEST + + + + + + + PEZZI FATTI + + + Qt::AlignCenter + + + + + + + OPERATORE: + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + - + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Qt::Horizontal + + + QSizePolicy::Fixed + + + + 20 + 20 + + + + + + + + - + + + Qt::AlignCenter + + + + + + + + + + + diff --git a/src/ui/test_assembly/__init__.py b/src/ui/test_assembly/__init__.py new file mode 100755 index 0000000..4ffa1f1 --- /dev/null +++ b/src/ui/test_assembly/__init__.py @@ -0,0 +1 @@ +from .test_assembly import * diff --git a/src/ui/test_assembly/test_assembly.py b/src/ui/test_assembly/test_assembly.py new file mode 100755 index 0000000..488c1cc --- /dev/null +++ b/src/ui/test_assembly/test_assembly.py @@ -0,0 +1,48 @@ +from PyQt5.QtCore import Qt +from PyQt5.QtGui import QPixmap +from ui.helpers import replace_widget +from ui.widget import Widget + + +class Test_Assembly(Widget): + def __init__(self, img_path=None, text=None, widget=None): + super().__init__() + self.set_img(img_path=img_path) + self.set_text(text=text) + self.set_widget(widget=widget) + self.resizeEvent() + + def set_img(self, img_path=None): + if img_path is not None: + self.img = QPixmap(str(img_path)) + self.img_l.setVisible(True) + else: + self.img = None + self.img_l.setVisible(False) + + def set_text(self, text=None): + if text is not None: + self.text = text + self.text_l.setText(str(self.text)) + self.text_l.setVisible(True) + else: + self.text = None + self.text_l.setVisible(False) + + def set_widget(self, widget=None): + if widget is not None: + replace_widget(self, "widget", widget) + # widget attributes passtrough passtrough + for attr in ["ok", "ko"]: + if hasattr(self.widget, attr): + setattr(self, attr, getattr(self.widget, attr)) + else: + if hasattr(self, attr): + delattr(self, attr) + self.widget.setVisible(True) + else: + self.widget.setVisible(False) + + def resizeEvent(self, event=None): + if self.img is not None: + self.img_l.setPixmap(self.img.scaled(self.img_l.width(), self.img_l.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) diff --git a/src/ui/test_assembly/test_assembly.ui b/src/ui/test_assembly/test_assembly.ui new file mode 100755 index 0000000..9db7fa4 --- /dev/null +++ b/src/ui/test_assembly/test_assembly.ui @@ -0,0 +1,77 @@ + + + Assembly + + + + 0 + 0 + 94 + 108 + + + + + + + + 0 + 0 + + + + + + + false + + + Qt::AlignCenter + + + + + + + + 0 + 0 + + + + + 24 + + + + border: 1px solid grey; background-color: lime; + + + - + + + Qt::AlignCenter + + + true + + + + + + + + 0 + 0 + + + + + + + + + + + + diff --git a/src/ui/test_autotest/__init__.py b/src/ui/test_autotest/__init__.py new file mode 100755 index 0000000..2d39ac6 --- /dev/null +++ b/src/ui/test_autotest/__init__.py @@ -0,0 +1 @@ +from .test_autotest import * diff --git a/src/ui/test_autotest/test_autotest.py b/src/ui/test_autotest/test_autotest.py new file mode 100755 index 0000000..2a70e25 --- /dev/null +++ b/src/ui/test_autotest/test_autotest.py @@ -0,0 +1,117 @@ +import sys +from datetime import datetime + +# from lib.db import Autotests +from PyQt5.QtCore import Qt, QTimer, pyqtSignal, pyqtSlot +# from PyQt5.QtGui import QPixmap +from ui.widget import Widget + + +class Test_Autotest(Widget): + ok = pyqtSignal() + + def set_reason(self, reason): + print(reason) + # def __init__(self, bench, drawing, reason): + # self.reason = reason + # self.autotest_cycle = [ + # ["autotest_nok", "Inserire campione autotest \"NOK\"", "autotest_nok"], + # ["autotest_ok", "Inserire campione autotest \"OK\"", "autotest_ok"], + # ] + # self.autotest_cycle_state = 0 + # test = self.autotest_cycle[self.autotest_cycle_state] + # recipe = bench.recipes[test[0]] + # for h in ["wires", "terminals"]: + # bench.parsers[h].set_recipe(recipe) + # self.instructions_img = QPixmap("src/ui/imgs/{}.png".format(test[2])) + # log_msg("autotest {}".format(test[0])) + # super().__init__(bench, drawing) + # if "--sim-autotest" not in sys.argv: + # self.vision_ok_counter_limit = 3 + # else: + # self.vision_ok_counter_limit = 1 + # self.autotest_frames = {} + # self.cycle_state_timer = QTimer() + # self.cycle_state_timer.setSingleShot(True) + # self.cycle_state_timer.setInterval(2000) + # self.cycle_state_timer.timeout.connect(self.cycle_state) + # self.sources = ["frame", "wires", "terminals"] + # self.bench.inputs["renderer"].checklist = ["wires", "terminals"] + # self.bench.inputs["datamatrix"].update.disconnect() + # self.bench.inputs["datamatrix"].out.disconnect() + # self.instruction_l.setText(test[1]) + # self.autotest_cycle_state_leds = [ + # self.nok_state_l, + # self.ok_state_l, + # ] + # self.update_leds() + # + # @pyqtSlot() + # def resizeEvent(self, event=None): + # super().resizeEvent(event=event) + # self.instructions_img_l.setPixmap(self.instructions_img.scaled(self.instructions_img_l.width(), self.instructions_img_l.height(), Qt.KeepAspectRatio, Qt.SmoothTransformation)) + # + # @pyqtSlot() + # @pyqtSlot(str) + # def check_barcodes(self, barcode=None): + # pass + # + # def check_ok(self): + # # CHECK IF COMPLETED AUTOTEST + # if ( + # all([t in self.vision and self.vision[t]["ok"] for t in ["wires", "terminals"]]) + # and self.vision_ok_counter >= self.vision_ok_counter_limit + # ): + # self.autotest_frames[self.autotest_cycle[self.autotest_cycle_state][0]] = self.frame + # self.instructions_img = QPixmap("src/ui/imgs/success.png") + # self.resizeEvent() + # self.autotest_cycle_state += 1 + # self.cycle_state_timer.start() + # + # @pyqtSlot() + # def cycle_state(self, override=False): + # if self.autotest_cycle_state < len(self.autotest_cycle) and not override: + # test = self.autotest_cycle[self.autotest_cycle_state] + # recipe = self.bench.recipes[test[0]] + # for h in ["wires", "terminals"]: + # self.bench.parsers[h].set_recipe(recipe) + # self.instruction_l.setText(test[1]) + # self.instructions_img = QPixmap("src/ui/imgs/{}.png".format(test[2])) + # self.resizeEvent() + # log_msg("autotest {}".format(test[0])) + # self.request_vision.emit() + # else: + # if override: + # self.bench.outputs["autotest_recorder"].save(self.frame[0], self.frame[1]) + # self.save(overridden=True) + # else: + # self.save() + # # RESET RECIPES TO ACTUAL ONES + # if self.drawing is not None: + # recipe = self.bench.recipes[self.drawing.vision_recipe] + # for h in ["wires", "terminals"]: + # self.bench.parsers[h].set_recipe(recipe) + # self.ok.emit() + # self.update_leds() + # + # @pyqtSlot() + # def update_leds(self): + # for n, led in enumerate(self.autotest_cycle_state_leds): + # if n < self.autotest_cycle_state: + # led.setPixmap(self.sled.cmp(True)) + # elif n == self.autotest_cycle_state: + # led.setPixmap(self.sled.cmp("right")) + # elif n > self.autotest_cycle_state: + # led.setPixmap(self.sled.cmp(False)) + # + # @pyqtSlot() + # def override_vision(self): + # if self.challenge_admin("Si sta tentando di bypassare il l'autotest"): + # self.cycle_state(override=True) + # + # def save(self, overridden=False): + # frames = [] + # for k, f in self.autotest_frames.items(): + # frames.append(self.bench.outputs["autotest_recorder"].save(f[0], f[1])) + # Autotests.archive(result=True, vision_tests=[t[0] for t in self.autotest_cycle], reason=self.reason, overridden=overridden, frames=frames) + # self.bench.last_autotest = datetime.now() diff --git a/src/ui/test_autotest/test_autotest.ui b/src/ui/test_autotest/test_autotest.ui new file mode 100755 index 0000000..9ef8444 --- /dev/null +++ b/src/ui/test_autotest/test_autotest.ui @@ -0,0 +1,9 @@ + + + Autotest + + + + + + diff --git a/src/ui/test_home/__init__.py b/src/ui/test_home/__init__.py new file mode 100644 index 0000000..bc0776c --- /dev/null +++ b/src/ui/test_home/__init__.py @@ -0,0 +1 @@ +from .test_home import Test_Home diff --git a/src/ui/test_home/test_home.py b/src/ui/test_home/test_home.py new file mode 100644 index 0000000..a448a12 --- /dev/null +++ b/src/ui/test_home/test_home.py @@ -0,0 +1,14 @@ +import json + +from lib.helpers import JSONEncoder +from ui.widget import Widget + + +class Test_Home(Widget): + def __init__(self, components): + super().__init__() + for component in components: + component.out.connect(self.update) + + def update(self, data): + self.readout_t.setPlainText(json.dumps(data, indent=4, sort_keys=True, cls=JSONEncoder)) diff --git a/src/ui/test_home/test_home.ui b/src/ui/test_home/test_home.ui new file mode 100644 index 0000000..2a7c6bb --- /dev/null +++ b/src/ui/test_home/test_home.ui @@ -0,0 +1,31 @@ + + + Widget + + + + 0 + 0 + 500 + 500 + + + + + + + QFrame::StyledPanel + + + true + + + false + + + + + + + + \ No newline at end of file diff --git a/src/ui/users_management/__init__.py b/src/ui/users_management/__init__.py new file mode 100755 index 0000000..d35d16f --- /dev/null +++ b/src/ui/users_management/__init__.py @@ -0,0 +1 @@ +from .users_management import Users_Management diff --git a/src/ui/users_management/users_management.py b/src/ui/users_management/users_management.py new file mode 100644 index 0000000..d4ca05d --- /dev/null +++ b/src/ui/users_management/users_management.py @@ -0,0 +1,76 @@ +import traceback + +from lib.db import Users +from PyQt5.QtWidgets import QMessageBox +from ui.crud import Crud, Line_Edit_Cell_Widget +from ui.widget import Widget + + +class Users_Management(Widget): + def __init__(self): + super().__init__() + + class Username_Line_Edit_Cell_Widget(Line_Edit_Cell_Widget): + def parse(self, row_number=None, crud=None): + data = super().parse(row_number=row_number, crud=crud) + if data is not None and len(data): + return data.upper() + return None + + class Password_Line_Edit_Cell_Widget(Line_Edit_Cell_Widget): + def render(self, data, row_number=None, crud=None): + if data is not None and len(data): + data = u"\u2022" * 8 + else: + data = None + self.setText(data) + + def parse(self, row_number=None, crud=None): + data = self.text() + if data is not None and len(data): + return data + return None + + class Roles_Line_Edit_Cell_Widget(Line_Edit_Cell_Widget): + def render(self, data, row_number=None, crud=None): + super().render(", ".join(data) if data is not None else None, row_number=row_number, crud=crud) + + def parse(self, row_number=None, crud=None): + return Users.parse_roles(self.text()) + + crud_aliases = { + "id": "Id", + "username": "Nome utente", + "password": "Password", + "roles": "Ruoli", + } + self.crud = Crud( + "users", + display_name="GESTIONE UTENTI", + readonly=["id"], + select=list(crud_aliases.keys()), + fields_aliases=crud_aliases, + autocomplete={"archived": False}, + widget_classes={ + "username": Username_Line_Edit_Cell_Widget, + "password": Password_Line_Edit_Cell_Widget, + "roles": Roles_Line_Edit_Cell_Widget, + }, + row_filter=self.row_filter + ) + self.layout().addWidget(self.crud, 0, 0, -1, -1) + + def row_filter(self, row, row_number, crud): + if row["password"] is not None and all(map(lambda x: x == u"\u2022", row["password"])): + row.pop("password", None) + row.pop("salt", None) + else: + try: + user = Users.generate(username=row["username"], password=row["password"], roles=row["roles"]) + except AssertionError as e: + self.log.exception(traceback.format_exc()) + crud.set_row_color(row_number, "red") + QMessageBox.critical(None, "Errore Salvataggio DB", f"Errore alla riga {row_number}:\n{str(e)}") + return False, None, True + row.update(user) + return True, row, False diff --git a/src/ui/users_management/users_management.ui b/src/ui/users_management/users_management.ui new file mode 100644 index 0000000..34e6e8b --- /dev/null +++ b/src/ui/users_management/users_management.ui @@ -0,0 +1,17 @@ + + + Users management + + + + 0 + 0 + 94 + 18 + + + + + + + diff --git a/src/ui/widget/__init__.py b/src/ui/widget/__init__.py new file mode 100644 index 0000000..8839690 --- /dev/null +++ b/src/ui/widget/__init__.py @@ -0,0 +1 @@ +from .widget import Widget diff --git a/src/ui/widget/widget.py b/src/ui/widget/widget.py new file mode 100644 index 0000000..25104f2 --- /dev/null +++ b/src/ui/widget/widget.py @@ -0,0 +1,35 @@ +import logging + +from lib.helpers import get_resource +from PyQt5 import uic +from PyQt5.QtCore import Qt +from PyQt5.QtWidgets import QWidget +from ui.dialog import Dialog + + +class Widget(QWidget): + def __init__(self): + super().__init__() + self.setAttribute(Qt.WA_DeleteOnClose) + me = self.__class__.__name__ + u = get_resource("ui/{0}/{0}.ui".format(me.lower())) + self.ui = uic.loadUi(u, self) + self.setWindowTitle(me) + self.log = logging.getLogger(f"{self.__class__.__name__} ({id(self)})") + + def setParent(self, parent): + parent._closing.connect(self._parent_closing) + super().setParent(parent) + + def open_dialog(self, widget, show=True, maximized=False): + d = Dialog() + d.setCentralWidget(widget) + if show: + if maximized: + d.showMaximized() + else: + d.show() + return d + + def _parent_closing(self): + pass diff --git a/src/ui/window/__init__.py b/src/ui/window/__init__.py new file mode 100644 index 0000000..8576cfb --- /dev/null +++ b/src/ui/window/__init__.py @@ -0,0 +1 @@ +from .window import Window diff --git a/src/ui/window/window.py b/src/ui/window/window.py new file mode 100644 index 0000000..6999475 --- /dev/null +++ b/src/ui/window/window.py @@ -0,0 +1,38 @@ +import logging + +from lib.helpers import get_resource +from PyQt5 import uic +from PyQt5.QtCore import Qt, pyqtSignal +from PyQt5.QtGui import QIcon +from PyQt5.QtWidgets import QMainWindow +from ui.dialog import Dialog + + +class Window(QMainWindow): + _closing = pyqtSignal() + + def __init__(self): + super().__init__() + self.setAttribute(Qt.WA_DeleteOnClose) + self.setWindowFlags(Qt.Window) + me = self.__class__.__name__ + u = get_resource("ui/{0}/{0}.ui".format(me.lower())) + self.ui = uic.loadUi(u, self) + # LOGO + self.setWindowIcon(QIcon(get_resource("ui/imgs/neo.ico"))) + self.log = logging.getLogger(f"{self.__class__.__name__} ({id(self)})") + + def setCentralWidget(self, widget): + widget.setParent(self) + super().setCentralWidget(widget) + + def open_dialog(self, widget, show=True): + d = Dialog() + widget.setParent(self) + d.setCentralWidget(widget) + if show: + d.show() + return d + + def closeEvent(self, *args): + self._closing.emit() diff --git a/src/ui/window/window.ui b/src/ui/window/window.ui new file mode 100644 index 0000000..2866b32 --- /dev/null +++ b/src/ui/window/window.ui @@ -0,0 +1,27 @@ + + + MainWindow + + + + 0 + 0 + 481 + 393 + + + + + + + 0 + 0 + 481 + 24 + + + + + + + diff --git a/test.sh b/test.sh new file mode 100755 index 0000000..669fc4e --- /dev/null +++ b/test.sh @@ -0,0 +1,3 @@ +#!/bin/bash -e +cd "$(dirname "$0")" +./simulate.sh --test $*