This commit is contained in:
matteo porta 2022-06-01 18:37:19 +02:00
commit 0beb139753
133 changed files with 6122 additions and 0 deletions

17
.gitignore vendored Normal file
View File

@ -0,0 +1,17 @@
__pycache__/
.idea/
.ropeproject/
*.bak
*.prof
*.pyc
/*.asc
/*.exe
/*.log
/*.pdf
/*.spec
/build/
/data/*
/dist/
/src/lib/db/*imports*/
/tmp/
/venv*/

12
TODO.txt Normal file
View File

@ -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

43
build.py Normal file
View File

@ -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)

39
build.sh Executable file
View File

@ -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. ----------"

4
built_runme.sh Executable file
View File

@ -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"

27
built_simulate.sh Executable file
View File

@ -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

2
built_test.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash -e
./built_simulate.sh --test --auto $*

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,2 @@
[hostnames]
this: this

View File

@ -0,0 +1,2 @@
[test]
parameter: this

2
diagnostic.sh Executable file
View File

@ -0,0 +1,2 @@
#!/bin/bash -e
./simulate.sh --diagnostic $*

15
init.sh Executable file
View File

@ -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"

6
profilate.sh Executable file
View File

@ -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

4
run_chart_example.sh Executable file
View File

@ -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"

4
run_lifecycle.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash -e
cd "$(dirname "$0")"
source "./venv/Scripts/activate" || source "./venv/bin/activate"
python -O "./src/lifecycle.py"

4
runme.sh Executable file
View File

@ -0,0 +1,4 @@
#!/bin/bash -e
cd "$(dirname "$0")"
source "./venv/bin/activate" || source "./venv/Scripts/activate" || :
python -O "./src/main.py"

29
simulate.sh Executable file
View File

@ -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

0
src/__init__.py Normal file
View File

View File

@ -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

View File

@ -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

226
src/components/component.py Normal file
View File

@ -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}")

View File

@ -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

View File

@ -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

View File

@ -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)
)

View File

@ -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()

View File

@ -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
}

View File

@ -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)])

70
src/components/vision_saver.py Executable file
View File

@ -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]

89
src/lib/db/__init__.py Normal file
View File

@ -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

88
src/lib/db/crud_db.py Executable file
View File

@ -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

View File

@ -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

View File

@ -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"

39
src/lib/db/models/autotests.py Executable file
View File

@ -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"

View File

@ -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

24
src/lib/db/models/log.py Executable file
View File

@ -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"

View File

@ -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"

151
src/lib/db/models/users.py Executable file
View File

@ -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

View File

@ -0,0 +1,5 @@
import serial
from .serial import Serial
serial.Serial = Serial

View File

@ -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")

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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_())

View File

@ -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_())

View File

@ -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"]

View File

@ -0,0 +1,9 @@
from time import perf_counter, time
ref = time() - perf_counter()
def timing():
global ref
t = ref + perf_counter()
return t

165
src/main.py Normal file
View File

@ -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)

12
src/requirements.txt Normal file
View File

@ -0,0 +1,12 @@
argon2-cffi
bottle
google-cloud-storage
numpy
opencv-python-headless
peewee
pillow
pymodbus
pyqt5
pyserial
requests
zebra

18
src/ui/__init__.py Normal file
View File

@ -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

1
src/ui/about/__init__.py Normal file
View File

@ -0,0 +1 @@
from .about import About

21
src/ui/about/about.py Normal file
View File

@ -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
)
)

130
src/ui/about/about.ui Normal file
View File

@ -0,0 +1,130 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>About</class>
<widget class="QWidget" name="About">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>316</width>
<height>262</height>
</rect>
</property>
<layout class="QVBoxLayout" name="verticalLayout">
<item>
<widget class="QLabel" name="label_6">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Maximum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="styleSheet">
<string notr="true">font: 87 7pt &quot;Arial Black&quot;;</string>
</property>
<property name="text">
<string>POWERED BY</string>
</property>
<property name="textFormat">
<enum>Qt::AutoText</enum>
</property>
<property name="scaledContents">
<bool>false</bool>
</property>
</widget>
</item>
<item>
<widget class="QLabel" name="logo_l">
<property name="text">
<string>-</string>
</property>
</widget>
</item>
<item>
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>Contatti</string>
</property>
<layout class="QFormLayout" name="formLayout">
<item row="0" column="0">
<widget class="QLabel" name="label_8">
<property name="text">
<string>Website:</string>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QLabel" name="label_13">
<property name="text">
<string>www.neosystems.it</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_10">
<property name="text">
<string>E-mail:</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLabel" name="label_14">
<property name="text">
<string>info@neosystems.it</string>
</property>
</widget>
</item>
<item row="2" column="0">
<widget class="QLabel" name="label_11">
<property name="text">
<string>Telefono:</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QLabel" name="label_15">
<property name="text">
<string>+39 0118000876</string>
</property>
</widget>
</item>
<item row="3" column="0">
<widget class="QLabel" name="label_12">
<property name="text">
<string>P.IVA:</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QLabel" name="label_16">
<property name="text">
<string>11836090016</string>
</property>
</widget>
</item>
<item row="4" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>Indirizzo:</string>
</property>
</widget>
</item>
<item row="4" column="1">
<widget class="QLabel" name="label_3">
<property name="text">
<string>Via Vittime delle Foibe, 10 10036 - Settimo Torinese (TO)
</string>
</property>
<property name="wordWrap">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

1
src/ui/archive/__init__.py Executable file
View File

@ -0,0 +1 @@
from .archive import Archive

70
src/ui/archive/archive.py Executable file
View File

@ -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)

37
src/ui/archive/archive.ui Executable file
View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Test archive</class>
<widget class="QWidget" name="Test archive">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>98</width>
<height>61</height>
</rect>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QPushButton" name="print_b">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Stampa</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QWidget" name="crud_w" native="true"/>
</item>
</layout>
</widget>
<tabstops>
<tabstop>print_b</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1 @@
from .autotests_archive import Autotests_Archive

View File

@ -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)

View File

@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Autotests archive</class>
<widget class="QWidget" name="Autotests archive">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>98</width>
<height>61</height>
</rect>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="1" column="0">
<widget class="QPushButton" name="print_b">
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Fixed">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="text">
<string>Stampa</string>
</property>
</widget>
</item>
<item row="0" column="0">
<widget class="QWidget" name="crud_w" native="true"/>
</item>
</layout>
</widget>
<tabstops>
<tabstop>print_b</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

@ -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())

2
src/ui/crud/__init__.py Executable file
View File

@ -0,0 +1,2 @@
from .CopyPastableCrudQTableWidget import CopyPastableCrudQTableWidget
from .crud import *

448
src/ui/crud/crud.py Executable file
View File

@ -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()

151
src/ui/crud/crud.ui Executable file
View File

@ -0,0 +1,151 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Crud</class>
<widget class="QWidget" name="Crud">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>800</width>
<height>600</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="minimumSize">
<size>
<width>800</width>
<height>600</height>
</size>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QGroupBox" name="db_gb">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="title">
<string>Table</string>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="2" column="2">
<widget class="QPushButton" name="revert_b">
<property name="text">
<string>Reset</string>
</property>
</widget>
</item>
<item row="0" column="1" colspan="5">
<widget class="CopyPastableCrudQTableWidget" name="db_tw">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<property name="font">
<font>
<weight>50</weight>
<bold>false</bold>
</font>
</property>
<property name="styleSheet">
<string notr="true">QHeaderView::section {font-weight: bold;}</string>
</property>
<attribute name="horizontalHeaderHighlightSections">
<bool>false</bool>
</attribute>
<attribute name="verticalHeaderHighlightSections">
<bool>false</bool>
</attribute>
</widget>
</item>
<item row="3" column="5">
<widget class="QPushButton" name="end_b">
<property name="text">
<string>⏭</string>
</property>
</widget>
</item>
<item row="2" column="1">
<widget class="QPushButton" name="cancel_b">
<property name="text">
<string>Annulla</string>
</property>
</widget>
</item>
<item row="3" column="1">
<widget class="QPushButton" name="start_b">
<property name="text">
<string>⏮</string>
</property>
</widget>
</item>
<item row="3" column="4">
<widget class="QPushButton" name="next_b">
<property name="text">
<string>⏩️</string>
</property>
</widget>
</item>
<item row="2" column="3">
<widget class="QPushButton" name="commit_b">
<property name="text">
<string>Salva</string>
</property>
</widget>
</item>
<item row="2" column="5">
<widget class="QPushButton" name="delete_b">
<property name="text">
<string>Rimuovi</string>
</property>
</widget>
</item>
<item row="2" column="4">
<widget class="QPushButton" name="add_b">
<property name="text">
<string>Aggiungi</string>
</property>
</widget>
</item>
<item row="3" column="2">
<widget class="QPushButton" name="previous_b">
<property name="text">
<string>⏪</string>
</property>
</widget>
</item>
<item row="3" column="3">
<widget class="QSpinBox" name="page_n_sb"/>
</item>
</layout>
</widget>
</item>
</layout>
</widget>
<customwidgets>
<customwidget>
<class>CopyPastableCrudQTableWidget</class>
<extends>QTableWidget</extends>
<header>ui.crud</header>
</customwidget>
</customwidgets>
<tabstops>
<tabstop>db_tw</tabstop>
<tabstop>cancel_b</tabstop>
<tabstop>revert_b</tabstop>
<tabstop>commit_b</tabstop>
<tabstop>add_b</tabstop>
<tabstop>delete_b</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1 @@
from .dialog import Dialog

36
src/ui/dialog/dialog.py Normal file
View File

@ -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)

34
src/ui/dialog/dialog.ui Normal file
View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Dialog</class>
<widget class="QDialog" name="Dialog">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>28</width>
<height>28</height>
</rect>
</property>
<property name="sizePolicy">
<sizepolicy hsizetype="Preferred" vsizetype="Preferred">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="0" column="0">
<widget class="QWidget" name="centralWidget" native="true">
<property name="sizePolicy">
<sizepolicy hsizetype="Minimum" vsizetype="Minimum">
<horstretch>0</horstretch>
<verstretch>0</verstretch>
</sizepolicy>
</property>
</widget>
</item>
</layout>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1,2 @@
from .calc_foreground_color import calc_foreground_color
from .replace_widget import replace_widget

View File

@ -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

View File

@ -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

BIN
src/ui/imgs/assembly_1.jpg Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

BIN
src/ui/imgs/autotest_nok.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

BIN
src/ui/imgs/autotest_ok.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

BIN
src/ui/imgs/blue.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/ui/imgs/fail.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
src/ui/imgs/green.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
src/ui/imgs/logo_neo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
src/ui/imgs/neo.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

BIN
src/ui/imgs/red.png Executable file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

BIN
src/ui/imgs/right.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
src/ui/imgs/splash.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 56 KiB

BIN
src/ui/imgs/success.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

BIN
src/ui/imgs/wait.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

1
src/ui/login/__init__.py Executable file
View File

@ -0,0 +1 @@
from .login import *

42
src/ui/login/login.py Executable file
View File

@ -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()

139
src/ui/login/login.ui Executable file
View File

@ -0,0 +1,139 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>Login</class>
<widget class="QWidget" name="Login">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>1064</width>
<height>294</height>
</rect>
</property>
<layout class="QGridLayout" name="gridLayout">
<item row="2" column="0">
<spacer name="horizontalSpacer">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="2">
<spacer name="horizontalSpacer_2">
<property name="orientation">
<enum>Qt::Horizontal</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>40</width>
<height>20</height>
</size>
</property>
</spacer>
</item>
<item row="1" column="1">
<spacer name="verticalSpacer_2">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="2" column="1">
<widget class="QGroupBox" name="groupBox">
<property name="title">
<string>ACCESSO OPERATORE</string>
</property>
<layout class="QGridLayout" name="gridLayout_2">
<item row="0" column="0">
<widget class="QLabel" name="label">
<property name="text">
<string>NOME UTENTE</string>
</property>
</widget>
</item>
<item row="1" column="0">
<widget class="QLabel" name="label_2">
<property name="text">
<string>PASSWORD</string>
</property>
</widget>
</item>
<item row="1" column="1">
<widget class="QLineEdit" name="password_le">
<property name="echoMode">
<enum>QLineEdit::Password</enum>
</property>
</widget>
</item>
<item row="2" column="0" colspan="2">
<widget class="QPushButton" name="login_b">
<property name="text">
<string>ACCEDI</string>
</property>
<property name="autoDefault">
<bool>true</bool>
</property>
<property name="default">
<bool>true</bool>
</property>
</widget>
</item>
<item row="0" column="1">
<widget class="QComboBox" name="user_cb">
<property name="editable">
<bool>true</bool>
</property>
</widget>
</item>
</layout>
</widget>
</item>
<item row="3" column="1">
<spacer name="verticalSpacer">
<property name="orientation">
<enum>Qt::Vertical</enum>
</property>
<property name="sizeHint" stdset="0">
<size>
<width>20</width>
<height>40</height>
</size>
</property>
</spacer>
</item>
<item row="0" column="1">
<widget class="QLabel" name="welcome_l">
<property name="font">
<font>
<pointsize>24</pointsize>
</font>
</property>
<property name="text">
<string>BENVENUTO, PER INIZIARE IL COLLAUDO, EFFETTUA L' ACCESSO:</string>
</property>
<property name="alignment">
<set>Qt::AlignCenter</set>
</property>
</widget>
</item>
</layout>
</widget>
<tabstops>
<tabstop>password_le</tabstop>
<tabstop>login_b</tabstop>
</tabstops>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1 @@
from .main_window import Main_Window

View File

@ -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]

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<ui version="4.0">
<class>MainWindow</class>
<widget class="QMainWindow" name="MainWindow">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>94</width>
<height>40</height>
</rect>
</property>
<widget class="QWidget" name="centralwidget"/>
<widget class="QMenuBar" name="menubar">
<property name="geometry">
<rect>
<x>0</x>
<y>0</y>
<width>94</width>
<height>24</height>
</rect>
</property>
<widget class="QMenu" name="menuAbout">
<property name="title">
<string>About</string>
</property>
<addaction name="about_a"/>
</widget>
<widget class="QMenu" name="admin_m">
<property name="title">
<string>Amministrazione</string>
</property>
<addaction name="users_management_a"/>
</widget>
<widget class="QMenu" name="menuStrumenti">
<property name="title">
<string>Strumenti</string>
</property>
<addaction name="archive_a"/>
<addaction name="autotests_archive_a"/>
</widget>
<addaction name="menuStrumenti"/>
<addaction name="admin_m"/>
<addaction name="menuAbout"/>
</widget>
<action name="about_a">
<property name="text">
<string>Powered by</string>
</property>
</action>
<action name="users_management_a">
<property name="text">
<string>Gestione utenti</string>
</property>
</action>
<action name="archive_a">
<property name="text">
<string>Archivio</string>
</property>
</action>
<action name="autotests_archive_a">
<property name="text">
<string>Archivio autotest</string>
</property>
</action>
</widget>
<resources/>
<connections/>
</ui>

View File

@ -0,0 +1 @@
from .qml_circular_gauge import Qml_Circular_Gauge

View File

@ -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
}
}
}

View File

@ -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
}
}
}
}
}

View File

@ -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)

View File

@ -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
}
}
}
}
}

View File

@ -0,0 +1 @@
from .qml_led import Qml_Led

17
src/ui/qml_led/qml_led.py Normal file
View File

@ -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)

View File

@ -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
}
}

Some files were not shown because too many files have changed in this diff Show More