505 lines
20 KiB
Python
505 lines
20 KiB
Python
import logging
|
|
import traceback
|
|
import types
|
|
|
|
from lib.helpers import timing
|
|
from PyQt5.QtCore import (QMutex, QObject, QSemaphore, Qt, QThread, QTimer,
|
|
pyqtSignal)
|
|
|
|
|
|
class Component(QObject):
|
|
"""emitted with data from the _get method"""
|
|
out = pyqtSignal(object)
|
|
_reconfigure = pyqtSignal()
|
|
_pause = pyqtSignal()
|
|
_resume = pyqtSignal()
|
|
_set_sources = pyqtSignal(object)
|
|
_set_requestors = pyqtSignal(object)
|
|
_set_period = pyqtSignal(object)
|
|
|
|
reconfigurators = set()
|
|
reconfigurators_lock = QMutex()
|
|
|
|
@staticmethod
|
|
def reconfig_on_error(f):
|
|
def wrapper(*arg, **kwargs):
|
|
"""arg[0] is expected to be self fot the wrapped f method and wrappedmethods are expected to run in the component thread"""
|
|
self = arg[0]
|
|
Component.reconfigurators_lock.lock()
|
|
if not self._started or self in Component.reconfigurators:
|
|
# do not retry on first configuration
|
|
# avoid reconfiguration recursion loops when calling decorated methods during reconfiguration
|
|
# self.log.exception(f"self: {self}, reconfigurators: {Component.reconfigurators}, reconfiguration loop")
|
|
Component.reconfigurators_lock.unlock()
|
|
try:
|
|
return f(*arg, **kwargs)
|
|
except Exception:
|
|
self.log.exception(traceback.format_exc())
|
|
return None
|
|
Component.reconfigurators.add(self)
|
|
Component.reconfigurators_lock.unlock()
|
|
t_limit = 3
|
|
t = 0
|
|
ok = False
|
|
while t < t_limit and not ok:
|
|
if not self._ready:
|
|
try:
|
|
self._do_reconfigure()
|
|
except Exception:
|
|
self.log.exception(traceback.format_exc())
|
|
try:
|
|
ret = f(*arg, **kwargs)
|
|
ok = True
|
|
except Exception:
|
|
self.log.exception(traceback.format_exc())
|
|
# mark component asnot ready on fail
|
|
self._ready = False
|
|
if not ok:
|
|
t += 1
|
|
QThread.msleep(1000)
|
|
if ok:
|
|
Component.reconfigurators_lock.lock()
|
|
Component.reconfigurators.remove(self)
|
|
Component.reconfigurators_lock.unlock()
|
|
return ret
|
|
raise RuntimeError(f"retried to run {f} and reconfigure {self} with no success for {t_limit} times. giving up.")
|
|
return wrapper
|
|
|
|
def __init__(
|
|
self,
|
|
config=None,
|
|
name=None,
|
|
period=None,
|
|
lazy=True,
|
|
paused=False,
|
|
threaded=True,
|
|
):
|
|
"""
|
|
parameters:
|
|
config: value for self.config, should be instance of lib.helpers.config_reader.ConfigReader
|
|
name: value for self.name, should be used to retrive component configuration section (self.config[self.name])
|
|
period: period in seconds for periodic calls to _get, set to None to disable
|
|
lazy: whether or not skip periodic _get calls if falling behind
|
|
paused: whether or not periodic calls toget are paused
|
|
threaded: set this to tell the component if it should be thread synchronized or not(shoul be true if calls to methods are done from threads different from the component's one)
|
|
"""
|
|
super().__init__()
|
|
self.config = config
|
|
self.log=logging.getLogger(f"{self.__class__.__name__}")
|
|
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 = None
|
|
self.requestors = None
|
|
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()
|
|
|
|
@property
|
|
def ready(self):
|
|
"""returns True if the component is ready"""
|
|
if self._threaded:
|
|
self._lock.acquire(max(self._lock.available(), 1))
|
|
ready = self._ready
|
|
if self._threaded:
|
|
self._lock.release()
|
|
return ready
|
|
|
|
def config_changed(self):
|
|
"""
|
|
this method should be overridden when inheriting from the Component class
|
|
and should contain all the initialization code that needs to access self.config
|
|
so that the component will reinitialize if configuration changes
|
|
this method will be called on start and when self.config (ConfigReader) emits the updated signal
|
|
"""
|
|
pass
|
|
|
|
def start(self):
|
|
"""
|
|
this method is automatically called if threaded is set to False at object creation
|
|
otherwise if the component is in a thread this method should be used like this:
|
|
component = ComponentSubclass(threaded=True)
|
|
thread = QThread()
|
|
thread.setTerminationEnabled(True)
|
|
component.moveToThread(thread)
|
|
thread.started.connect(component.start)
|
|
thread.start()
|
|
component.wait_completion() # this is optional and will wait untill the component has finished started
|
|
"""
|
|
self._reconfigure.connect(self._do_reconfigure)
|
|
self._pause.connect(self._do_pause)
|
|
self._resume.connect(self._do_resume)
|
|
self._set_sources.connect(self._do_set_sources)
|
|
self._set_requestors.connect(self._do_set_requestors)
|
|
self._set_period.connect(self._do_set_period)
|
|
if self.config is not None:
|
|
if self.config.updated is not None:
|
|
self.config.updated.connect(self.reconfigure)
|
|
self._do_reconfigure()
|
|
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):
|
|
"""returns True if the component has been started"""
|
|
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):
|
|
"""returns True if the periodic calls to _get are not paused"""
|
|
if self._threaded:
|
|
self._lock.acquire(max(self._lock.available(), 1))
|
|
running = self._running
|
|
if self._threaded:
|
|
self._lock.release()
|
|
return running
|
|
|
|
def wait_completion(self, timeout=5):
|
|
"""
|
|
waits untill the requested action has been completed by the component
|
|
this will return immediately if threaded=False was passed at component initialization
|
|
"""
|
|
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 reconfigure(self):
|
|
"""will pause periodic calls to _get and sources triggers"""
|
|
if self._threaded:
|
|
self._lock.acquire(max(self._lock.available(), 1))
|
|
if self._threaded:
|
|
self._reconfigure.emit()
|
|
self.wait_completion()
|
|
else:
|
|
self._do_reconfigure()
|
|
|
|
def pause(self):
|
|
"""will pause periodic calls to _get and sources or requestors triggers"""
|
|
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_completion()
|
|
else:
|
|
self._do_pause()
|
|
|
|
def resume(self):
|
|
"""will resume periodic calls to _get and sources or requestors triggers"""
|
|
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_completion()
|
|
else:
|
|
self._do_resume()
|
|
|
|
def set_sources(self, sources=None): #
|
|
"""
|
|
connect the given sources to trigger a call to _get,
|
|
the sources parameter should be:
|
|
a dict of signals ({"<source_name>": <signal_to_connect>, ...})
|
|
signals might contain one optional argument that will be passed as data to _get
|
|
or None to disconnect all sources
|
|
"""
|
|
if sources is None:
|
|
sources = {}
|
|
if self._threaded:
|
|
self._lock.acquire(max(self._lock.available(), 1))
|
|
self._set_sources.emit(sources)
|
|
self.wait_completion()
|
|
else:
|
|
self._do_set_sources(sources)
|
|
|
|
def add_sources(self, sources=None, overwrite_conflicting_sources=False): # sources should be {"source_name": signal_to_connect, ...}
|
|
"""
|
|
add the given sources to the current ones,
|
|
the sources parameter should be:
|
|
a dict of signals ({"<source_name>": <signal_to_connect>, ...})
|
|
signals might contain one optional argument that will be passed as data to _get
|
|
or None if no sources are to be added
|
|
this method calls set_sources, this is semplest but not the most efficient approach
|
|
"""
|
|
if self.sources is None:
|
|
self_sources = {}
|
|
else:
|
|
self_sources = self.sources
|
|
if sources is None:
|
|
sources = {}
|
|
if not overwrite_conflicting_sources:
|
|
conflicting_sources = {
|
|
n: [self_sources[n], sources[n]]
|
|
for n in self_sources.keys() & sources.keys()
|
|
if self_sources[n] is not sources[n]
|
|
}
|
|
if len(conflicting_sources):
|
|
raise AssertionError("\n\t" + "\n\t".join([f"source named {n!r}: {s[0]!r} will not be replaced with {s[1]!r}" for n, s in conflicting_sources.items()]))
|
|
self.set_sources({**self_sources, **sources})
|
|
|
|
def remove_sources(self, sources=None):
|
|
"""
|
|
remove the given sources to the current ones by name,
|
|
the sources parameter should be:
|
|
an iterable of source names (["<source_name>", ...])
|
|
or None if no sources are to be removed
|
|
this method calls set_sources, this is semplest but not the most efficient approach
|
|
"""
|
|
if sources is None or self.sources is None:
|
|
return
|
|
sources = set(sources)
|
|
self.set_sources({n: s for n, s in self.sources.items() if n not in sources})
|
|
|
|
def set_requestors(self, requestors=None): #
|
|
"""
|
|
connect the given requestors to trigger a call to _set,
|
|
the requestors parameter should be:
|
|
a dict of signals ({"<requestor_name>": <signal_to_connect>, ...})
|
|
signals might contain one optional argument that will be passed as data to set
|
|
or None to disconnect all requestors
|
|
"""
|
|
if requestors is None:
|
|
requestors = {}
|
|
if self._threaded:
|
|
self._lock.acquire(max(self._lock.available(), 1))
|
|
self._set_requestors.emit(requestors)
|
|
self.wait_completion()
|
|
else:
|
|
self._do_set_requestors(requestors)
|
|
|
|
def add_requestors(self, requestors=None, overwrite_conflicting_requestors=False): # requestors should be {"source_name": signal_to_connect, ...}
|
|
"""
|
|
add the given requestors to the current ones,
|
|
the requestors parameter should be:
|
|
a dict of signals ({"<source_name>": <signal_to_connect>, ...})
|
|
signals might contain one optional argument that will be passed as data to set
|
|
or None if no requestors are to be added
|
|
this method calls set_requestors, this is semplest but not the most efficient approach
|
|
"""
|
|
if self.requestors is None:
|
|
self_requestors = {}
|
|
else:
|
|
self_requestors = self.requestors
|
|
if requestors is None:
|
|
requestors = {}
|
|
if not overwrite_conflicting_requestors:
|
|
conflicting_requestors = {
|
|
n: [self_requestors[n], requestors[n]]
|
|
for n in self_requestors.keys() & requestors.keys()
|
|
if self_requestors[n] is not requestors[n]
|
|
}
|
|
if len(conflicting_requestors):
|
|
raise AssertionError("\n\t" + "\n\t".join([f"requestor named {n!r}: {s[0]!r} will not be replaced with {s[1]!r}" for n, s in conflicting_requestors.items()]))
|
|
self.set_requestors({**self_requestors, **requestors})
|
|
|
|
def remove_requestors(self, requestors=None):
|
|
"""
|
|
remove the given requestors to the current ones by name,
|
|
the requestors parameter should be:
|
|
an iterable of requestor names (["<requestor_name>", ...])
|
|
or None if no requestors are to be removed
|
|
this method calls set_requestors, this is semplest but not the most efficient approach
|
|
"""
|
|
if requestors is None or self.requestors is None:
|
|
return
|
|
requestors = set(requestors)
|
|
self.set_requestors({n: s for n, s in self.requestors.items() if n not in requestors})
|
|
|
|
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):
|
|
"""will set the period for periodic calls to _get and whether or not those are lazy (see init parameters)"""
|
|
if self._threaded:
|
|
self._lock.acquire(max(self._lock.available(), 1))
|
|
self._set_period.emit({"period": period, "lazy": lazy})
|
|
self.wait_completion()
|
|
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("started periodic")
|
|
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("stopped periodic")
|
|
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 _connect_requestors(self):
|
|
if self.requestors is not None:
|
|
for requestor in self.requestors.values():
|
|
requestor.connect(self._set)
|
|
self.log.debug(f"connected requestors: {list(self.requestors)}")
|
|
else:
|
|
self.log.debug("no connected requestors")
|
|
|
|
def _disconnect_requestors(self):
|
|
if self.requestors is not None:
|
|
for requestor in self.requestors.values():
|
|
try:
|
|
requestor.disconnect()
|
|
except TypeError:
|
|
pass
|
|
self.log.debug(f"disconnected requestors: {list(self.requestors)}")
|
|
else:
|
|
self.log.debug("no disconnected requestors")
|
|
|
|
def _do_reconfigure(self):
|
|
"""this method must run in the component thread or must be invoked trough reconfigure"""
|
|
self.log.info("reconfigure")
|
|
self._ready = False
|
|
try:
|
|
self.config_changed()
|
|
self._ready = True
|
|
except Exception:
|
|
self.log.exception(traceback.format_exc())
|
|
self._ready = False
|
|
self.log.debug(f"config: {self.config}, ready: {self._ready}")
|
|
if self._threaded:
|
|
self._lock.release()
|
|
|
|
def _do_resume(self):
|
|
self._start_periodic()
|
|
self._connect_sources()
|
|
self._connect_requestors()
|
|
self._running = True
|
|
self.log.info("resumed")
|
|
if self._threaded:
|
|
self._lock.release()
|
|
|
|
def _do_pause(self):
|
|
self._stop_periodic()
|
|
self._disconnect_sources()
|
|
self._disconnect_requestors()
|
|
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()
|
|
if sources is not None and not len(sources):
|
|
sources = None
|
|
self.sources = sources
|
|
if self._running:
|
|
self._connect_sources()
|
|
self.log.info("set sources")
|
|
if self._threaded:
|
|
self._lock.release()
|
|
|
|
def _do_set_requestors(self, requestors):
|
|
if self._running:
|
|
self._disconnect_requestors()
|
|
if requestors is not None and not len(requestors):
|
|
requestors = None
|
|
self.requestors = requestors
|
|
if self._running:
|
|
self._connect_requestors()
|
|
self.log.info("set requestors")
|
|
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, emit=True):
|
|
"""
|
|
this method should be overridden when inheriting from the Component class
|
|
the overriding method should retrieve all the data and then call super()._get(data)
|
|
this will emit the data in the proper format if the emit parameter is not set to False
|
|
"""
|
|
if emit:
|
|
if data is None:
|
|
data = [None]
|
|
t = timing()
|
|
got = [{
|
|
"time": t if type(d) is not dict or "time" not in d else d["time"],
|
|
self.name: d,
|
|
} for d in data]
|
|
self.out.emit(got)
|
|
self.log.debug(f"_get: {got}")
|
|
else:
|
|
self.log.debug("_get")
|
|
if self._timer is not None and self._single_shot:
|
|
self._timer.start()
|
|
|
|
def _set(self, val):
|
|
"""
|
|
this method should be overridden when inheriting from the Component class
|
|
the overriding method should set the requested val and then call super()._set(set_value)
|
|
this will log the value that has been set
|
|
"""
|
|
self.log.debug(f"_set: {val}")
|