init
17
.gitignore
vendored
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/bash -e
|
||||
./built_simulate.sh --test --auto $*
|
||||
14
config/label_templates/5803001456.prn
Normal 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
|
||||
24
config/machine_settings/defaults.ini
Normal 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
|
||||
2
config/machine_settings/hostnames.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[hostnames]
|
||||
this: this
|
||||
2
config/machine_settings/this.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[test]
|
||||
parameter: this
|
||||
2
diagnostic.sh
Executable file
|
|
@ -0,0 +1,2 @@
|
|||
#!/bin/bash -e
|
||||
./simulate.sh --diagnostic $*
|
||||
15
init.sh
Executable 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
6
src/components/__init__.py
Normal 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
|
||||
109
src/components/archive_synchronizer.py
Normal 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
|
|
@ -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}")
|
||||
49
src/components/os_label_printer.py
Normal 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
|
||||
155
src/components/remote_api.py
Normal 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
|
||||
45
src/components/serial_label_printer.py
Normal 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)
|
||||
)
|
||||
120
src/components/tecna_marposs_provaset_t3.py
Normal 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()
|
||||
169
src/components/tecna_marposs_provaset_t3_registers.py
Normal 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
|
||||
}
|
||||
27
src/components/test_component.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
||||
6
src/lib/db/models/__init__.py
Normal 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
|
||||
34
src/lib/db/models/archive.py
Normal 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
|
|
@ -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"
|
||||
29
src/lib/db/models/base_model.py
Normal 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
|
|
@ -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"
|
||||
20
src/lib/db/models/recipes.py
Normal 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
|
|
@ -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
|
||||
5
src/lib/dummies/serial/__init__.py
Executable file
|
|
@ -0,0 +1,5 @@
|
|||
import serial
|
||||
|
||||
from .serial import Serial
|
||||
|
||||
serial.Serial = Serial
|
||||
18
src/lib/dummies/serial/serial.py
Executable 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")
|
||||
20
src/lib/helpers/__init__.py
Normal 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
|
||||
113
src/lib/helpers/config_reader.py
Normal 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)
|
||||
8
src/lib/helpers/custom_json_encoder.py
Normal 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)
|
||||
27
src/lib/helpers/dict_merger.py
Normal 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
|
||||
16
src/lib/helpers/get_nested.py
Normal 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
|
||||
40
src/lib/helpers/performancer.py
Normal 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
|
||||
143
src/lib/helpers/qthread_catcher.py
Normal 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_())
|
||||
156
src/lib/helpers/qthread_synchronizer.py
Normal 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_())
|
||||
13
src/lib/helpers/resources.py
Normal 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"]
|
||||
9
src/lib/helpers/timing.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
from .about import About
|
||||
21
src/ui/about/about.py
Normal 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
|
|
@ -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 "Arial Black";</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
|
|
@ -0,0 +1 @@
|
|||
from .archive import Archive
|
||||
70
src/ui/archive/archive.py
Executable 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
|
|
@ -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>
|
||||
1
src/ui/autotests_archive/__init__.py
Executable file
|
|
@ -0,0 +1 @@
|
|||
from .autotests_archive import Autotests_Archive
|
||||
70
src/ui/autotests_archive/autotests_archive.py
Executable 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)
|
||||
37
src/ui/autotests_archive/autotests_archive.ui
Executable 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>
|
||||
74
src/ui/crud/CopyPastableCrudQTableWidget.py
Normal 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
|
|
@ -0,0 +1,2 @@
|
|||
from .CopyPastableCrudQTableWidget import CopyPastableCrudQTableWidget
|
||||
from .crud import *
|
||||
448
src/ui/crud/crud.py
Executable 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
|
|
@ -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>
|
||||
1
src/ui/dialog/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .dialog import Dialog
|
||||
36
src/ui/dialog/dialog.py
Normal 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
|
|
@ -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>
|
||||
2
src/ui/helpers/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from .calc_foreground_color import calc_foreground_color
|
||||
from .replace_widget import replace_widget
|
||||
13
src/ui/helpers/calc_foreground_color.py
Normal 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
|
||||
11
src/ui/helpers/replace_widget.py
Normal 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
|
After Width: | Height: | Size: 2.2 MiB |
BIN
src/ui/imgs/autotest_nok.png
Executable file
|
After Width: | Height: | Size: 100 KiB |
BIN
src/ui/imgs/autotest_ok.png
Executable file
|
After Width: | Height: | Size: 99 KiB |
BIN
src/ui/imgs/blue.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/ui/imgs/fail.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/ui/imgs/green.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/ui/imgs/logo_neo.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
src/ui/imgs/neo.ico
Normal file
|
After Width: | Height: | Size: 150 KiB |
BIN
src/ui/imgs/red.png
Executable file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
src/ui/imgs/reset_emergency.png
Normal file
|
After Width: | Height: | Size: 169 KiB |
BIN
src/ui/imgs/right.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
src/ui/imgs/splash.png
Normal file
|
After Width: | Height: | Size: 56 KiB |
BIN
src/ui/imgs/success.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
src/ui/imgs/wait.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
1
src/ui/login/__init__.py
Executable file
|
|
@ -0,0 +1 @@
|
|||
from .login import *
|
||||
42
src/ui/login/login.py
Executable 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
|
|
@ -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>
|
||||
1
src/ui/main_window/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .main_window import Main_Window
|
||||
35
src/ui/main_window/main_window.py
Normal 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]
|
||||
69
src/ui/main_window/main_window.ui
Normal 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>
|
||||
1
src/ui/qml_circular_gauge/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .qml_circular_gauge import Qml_Circular_Gauge
|
||||
31
src/ui/qml_circular_gauge/components/Arch.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
162
src/ui/qml_circular_gauge/old_qml_circular_symetric_gauge.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
37
src/ui/qml_circular_gauge/qml_circular_gauge.py
Normal 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)
|
||||
187
src/ui/qml_circular_gauge/qml_circular_gauge.qml
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
src/ui/qml_led/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from .qml_led import Qml_Led
|
||||
17
src/ui/qml_led/qml_led.py
Normal 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)
|
||||
19
src/ui/qml_led/qml_led.qml
Normal 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
|
||||
}
|
||||
}
|
||||