diff --git a/aquaPi/machineroom/__init__.py b/aquaPi/machineroom/__init__.py index 3e040a9..6297658 100644 --- a/aquaPi/machineroom/__init__.py +++ b/aquaPi/machineroom/__init__.py @@ -6,9 +6,9 @@ import atexit from .msg_bus import MsgBus, BusRole -from .ctrl_nodes import MinimumCtrl, MaximumCtrl, SunCtrl, FadeCtrl +from .ctrl_nodes import MinimumCtrl, MaximumCtrl, PidCtrl, SunCtrl, FadeCtrl from .in_nodes import AnalogInput, ScheduleInput -from .out_nodes import SwitchDevice, AnalogDevice +from .out_nodes import SwitchDevice, SlowPwmDevice, AnalogDevice from .aux_nodes import ScaleAux, MinAux, MaxAux, AvgAux from .hist_nodes import History from .alert_nodes import Alert, AlertAbove, AlertBelow @@ -142,10 +142,14 @@ def create_default_nodes(self): # single water temp sensor, switched relay wasser_i = AnalogInput('Wasser', 'DS1820 xA2E9C', 25.0, '°C', - avg=3, interval=30) - wasser = MinimumCtrl('Temperatur', wasser_i.id, 25.0) - wasser_o = SwitchDevice('Heizstab', wasser.id, - 'GPIO 12 out', inverted=1) + avg=1, interval=72) + #wasser = MinimumCtrl('Temperatur', wasser_i.id, 25.0) + #wasser_o = SwitchDevice('Heizstab', wasser.id, + # 'GPIO 12 out', inverted=1) + wasser = PidCtrl('PID Temperatur', wasser_i.id, 25.0, + p_fact=1.5, i_fact=0.1, d_fact=0.) + wasser_o = SlowPwmDevice('Heizstab', wasser.id, + 'GPIO 12 out', inverted=1, cycle=70) wasser_i.plugin(self.bus) wasser.plugin(self.bus) wasser_o.plugin(self.bus) @@ -167,7 +171,7 @@ def create_default_nodes(self): # ... and history for a diagram t_history = History('Temperaturen', [wasser_i.id, wasser_i2.id, - wasser.id, #wasser_o.id, + wasser.id, wasser_o.id, coolspeed.id]) #, cool.id]) t_history.plugin(self.bus) diff --git a/aquaPi/machineroom/aux_nodes.py b/aquaPi/machineroom/aux_nodes.py index 35d7feb..f01571d 100644 --- a/aquaPi/machineroom/aux_nodes.py +++ b/aquaPi/machineroom/aux_nodes.py @@ -171,7 +171,7 @@ def listen(self, msg): for k in self.values: val += self.values[k] / len(self.values) - if (self.data != val): + if (self.data != val) or True: self.data = val log.info('AvgAux %s: output %f', self.id, self.data) self.post(MsgData(self.id, round(self.data, 4))) @@ -205,7 +205,7 @@ def listen(self, msg): for k in self.values: val = min(val, self.values[k]) val = round(val, 4) - if self.data != val: + if self.data != val or True: self.data = val ## log.info('MinAux %s: output %f', self.id, self.data) self.post(MsgData(self.id, self.data)) @@ -233,7 +233,7 @@ def listen(self, msg): for k in self.values: val = max(val, self.values[k]) val = round(val, 4) - if self.data != val: + if self.data != val or True: self.data = val log.info('MaxAux %s: output %f', self.id, self.data) self.post(MsgData(self.id, self.data)) diff --git a/aquaPi/machineroom/ctrl_nodes.py b/aquaPi/machineroom/ctrl_nodes.py index 43ed1da..25263b2 100644 --- a/aquaPi/machineroom/ctrl_nodes.py +++ b/aquaPi/machineroom/ctrl_nodes.py @@ -111,7 +111,7 @@ def listen(self, msg): elif float(msg.data) >= (self.threshold + self.hysteresis / 2): new_val = 0.0 - if (self.data != new_val) or True: # WAR a startup problem + if (self.data != new_val) or True: #FIXME WAR a startup problem log.debug('MinimumCtrl: %d -> %d', self.data, new_val) self.data = new_val @@ -182,7 +182,7 @@ def listen(self, msg): elif float(msg.data) <= (self.threshold - self.hysteresis / 2): new_val = 0.0 - if (self.data != new_val) or True: # WAR a startup problem + if (self.data != new_val) or True: #FIXME WAR a startup problem log.debug('MaximumCtrl: %d -> %d', self.data, new_val) self.data = new_val @@ -209,6 +209,86 @@ def get_settings(self): return settings +class PidCtrl(ControllerNode): + """ An experimental PID controller producing a slow PWM + + Options: + name - unique name of this controller node in UI + receives - id of a single (!) input to receive measurements from + setpoint - the target value + p_fact/i_fact,d_fact - the PID factors + + Output: + posts a series of PWM pulses + """ + data_range = DataRange.PERCENT + + def __init__(self, name: str, receives: str, setpoint: float, + p_fact: float = 1.0, i_fact: float = 0.05, d_fact: float = 0., + _cont: bool = False): + super().__init__(name, receives, _cont=_cont) + self.setpoint: float = setpoint + self.p_fact: float = p_fact + self.i_fact: float = i_fact + self.d_fact: float = d_fact + self._err_sum: float = 0 + self._err_old: float = 0 + self._tm_old: float = 0 + self.data: float = 0. + + def __getstate__(self): + state = super().__getstate__() + state.update(setpoint=self.setpoint) + state.update(p_fact=self.p_fact) + state.update(i_fact=self.i_fact) + state.update(d_fact=self.d_fact) + return state + + def __setstate__(self, state): + log.debug('__SETstate__ %r', state) + self.data = state['data'] + PidCtrl.__init__(self, state['name'], state['inputs'], state['setpoint'], + p_fact=state['p_fact'], i_fact=state['i_fact'], d_fact=state['d_fact'], + _cont=True) + + def listen(self, msg): + if isinstance(msg, MsgData): + log.debug('PID got %s', msg) + now = time.time() + ta = now - self._tm_old + err = float(msg.data) - self.setpoint + if self._tm_old >= 1.: + self._err_sum = self._err_sum / 1 + err + p_dev = self.p_fact * err + i_dev = self.i_fact * ta * self._err_sum / 100 #?? + d_dev = self.d_fact / ta * (err - self._err_old) + val = p_dev + i_dev + d_dev + + log.warning('PID err %f, e-sum %f | P %+.1f%% / I %+.1f%% / D %+.1f %%, ', + err, self._err_sum, + 100 * p_dev, 100 * i_dev, 100 * d_dev) + self.data = min(max(0., 50. - val*100.), 100.) + log.brief('PID -> %f (%+.1f)', self.data, -val * 100) + self.post(MsgData(self.id, round(self.data, 4))) + + if self.data <= 0. or self.data >= 100.: + self._err_sum /= 2 + self._err_old = err + self._ta_old = ta + self._tm_old = now + + return super().listen(msg) + + def get_settings(self): + settings = super().get_settings() + settings.append(('setpoint', 'Sollwert [%s]' % self.unit, + self.setpoint, 'type="number" step="0.1"')) + settings.append(('p_fact', 'P Faktor', self.p_fact, 'type="number" min="-10" max="10" step="0.1"')) + settings.append(('i_fact', 'I Faktor', self.i_fact, 'type="number" min="-10" max="10" step="0.01"')) + settings.append(('d_fact', 'D Faktor', self.d_fact, 'type="number" min="-10" max="10" step="0.1"')) + return settings + + class FadeCtrl(ControllerNode): """ Single channel linear fading controller, usable for light (dusk/dawn). A change of input value will start a ramp from current to new @@ -278,6 +358,7 @@ def listen(self, msg): log.debug('_fader %f -> %f', self.data, self.target) self._fader_thread = Thread(name=self.id, target=self._fader, daemon=True) self._fader_thread.start() + return super().listen(msg) def _fader(self): """ This fader uses constant steps of 0.1% unless this would be >10 steps/sec diff --git a/aquaPi/machineroom/in_nodes.py b/aquaPi/machineroom/in_nodes.py index 1f17f10..534eb78 100644 --- a/aquaPi/machineroom/in_nodes.py +++ b/aquaPi/machineroom/in_nodes.py @@ -25,7 +25,7 @@ class InputNode(BusNode): """ ROLE = BusRole.IN_ENDP - def __init__(self, name, port, interval=0.5, _cont=False): + def __init__(self, name, port, interval, _cont=False): super().__init__(name, _cont=_cont) self._driver = None self._driver_opts = None @@ -78,7 +78,7 @@ def _reader(self): try: val = self.read() self.alert = None - if self.data != val: + if self.data != val or True: self.data = val log.brief('%s: read %f', self.id, self.data) self.post(MsgData(self.id, self.data)) @@ -95,7 +95,7 @@ def get_settings(self): settings.append(('port', 'Input', self.port, 'type="text"')) settings.append(('interval', 'Leseintervall [s]', - self.interval, 'type="number" min="0.1" max="60" step="0.1"')) + self.interval, 'type="number" min="1" max="600" step="1"')) return settings diff --git a/aquaPi/machineroom/out_nodes.py b/aquaPi/machineroom/out_nodes.py index cfc7312..bbf9139 100644 --- a/aquaPi/machineroom/out_nodes.py +++ b/aquaPi/machineroom/out_nodes.py @@ -1,6 +1,8 @@ #!/usr/bin/env python3 import logging +from threading import Thread +import time from .msg_bus import (BusListener, BusRole, DataRange, MsgData) from ..driver import (io_registry) @@ -16,8 +18,8 @@ class DeviceNode(BusListener): """ Base class for OUT_ENDP such as relay, PWM, GPIO pins. Receives float input from listened sender. - The interpretation is device specific, recommendation is - to follow pythonic truth testing to avoid surprises. + Binary devices should use a threashold of 50 or pythonic + truth testing, whatever is more intuitive for each dev. """ ROLE = BusRole.OUT_ENDP @@ -50,13 +52,13 @@ def __init__(self, name, inputs, port, inverted=0, _cont=False): self._inverted = int(inverted) ##self.unit = '%' if self.data_range != DataRange.BINARY else '⏻' self.port = port - self.switch(self.data if _cont else 0) - log.info('%s init to %r|%f|%f', self.name, _cont, self.data, inverted) + self.switch(self.data if _cont else False) + log.info('%s init to %f|%r', self.name, self.data, inverted) def __getstate__(self): state = super().__getstate__() state.update(port=self.port) - state.update(inverted=self.inverted) + state.update(inverted=self._inverted) return state def __setstate__(self, state): @@ -86,15 +88,17 @@ def inverted(self, inverted): def listen(self, msg): if isinstance(msg, MsgData): - if self.data != bool(msg.data): - self.switch(msg.data) + #if self.data != bool(msg.data): + data = (msg.data > 50.) + if self.data != data: + self.switch(data) return super().listen(msg) - def switch(self, on): - self.data = 100 if bool(on) else 0 + def switch(self, state: bool) -> None: + self.data: bool = state log.info('SwitchDevice %s: turns %s', self.id, 'ON' if self.data else 'OFF') - if not self.inverted: + if not self._inverted: self._driver.write(self.data) else: self._driver.write(not self.data) @@ -107,6 +111,121 @@ def get_settings(self): return settings +class SlowPwmDevice(DeviceNode): + """ An analog output to a binary GPIO pin or relay using slow PWM. + + Options: + name - unique name of this output node in UI + inputs - id of a single (!) input to receive data from + port - name of a IoRegistry port driver to drive output + inverted - swap the boolean interpretation for active low outputs + cycle - optional cycle time in sec for generated PWM + + Output: + drive output with PWM(input/100 * cycle), possibly inverted + """ + data_range = DataRange.BINARY + + def __init__(self, name, inputs, port, inverted=0, cycle=60., _cont=False): + super().__init__(name, inputs, _cont=_cont) + self.data = 50.0 + ##self.unit = '%' if self.data_range != DataRange.BINARY else '⏻' + self.cycle = float(cycle) + self._driver = None + self._port = None + self._inverted = int(inverted) + self._thread = None + self._thread_stop = False + self.port = port + self.set(self.data) + log.info('%s init to %f|%r|%r s', self.name, self.data, inverted, cycle) + + def __getstate__(self): + state = super().__getstate__() + state.update(cycle=self.cycle) + state.update(port=self.port) + state.update(inverted=self._inverted) + return state + + def __setstate__(self, state): + self.data = state['data'] + self.__init__(state['name'], state['inputs'], state['port'], + inverted=state['inverted'], cycle=state['cycle'], + _cont=True) + + @property + def port(self): + return self._port + + @port.setter + def port(self, port): + if self._driver: + io_registry.driver_destruct(self._port, self._driver) + if port: + self._driver = io_registry.driver_factory(port) + self._port = port + + @property + def inverted(self): + return self._inverted + + @inverted.setter + def inverted(self, inverted): + self._inverted = inverted + self.set(self.data) + + def listen(self, msg): + if isinstance(msg, MsgData): + self.set(float(msg.data)) + return super().listen(msg) + + def _pulse(self, hi_sec: float): + def toggle_and_wait(state: bool, end: float) -> bool: + start = time.time() + self._driver.write(state if not self._inverted else not state) + self.post(MsgData(self.id, 100 if state else 0)) + # avoid error accumulation by exact final sleep() + while time.time() < end - .1: + if self._thread_stop: + self._thread_stop = False + return False + time.sleep(.1) + time.sleep(end - time.time()) + log.debug(' _pulse needed %f instead of %f', + time.time() - start, end - start) + return True + + while True: + lead_edge = time.time() + if hi_sec > 0.1: + if not toggle_and_wait(True, lead_edge + hi_sec): + return + if hi_sec < self.cycle: + if not toggle_and_wait(False, lead_edge + self.cycle): + return + return + + def set(self, perc: float) -> None: + self.data: float = perc + + log.info('SlowPwmDevice %s: sets %.1f %% (%.3f of %f s)', + self.id, self.data, self.cycle * perc/100, self.cycle) + if self._thread: + self._thread_stop = True + self._thread.join() + self._thread = Thread(name='PIDpulse', target=self._pulse, + args=[self.data / 100 * self.cycle], daemon=True) + self._thread.start() + + def get_settings(self): + settings = super().get_settings() + settings.append(('cycle', 'PWM cycle time', self.cycle, + 'type="number" min="10" max="300" step="1"', + 'inverted', 'Inverted', self.inverted, + 'type="number" min="0" max="1"')) + return settings + + class AnalogDevice(DeviceNode): """ An analog output using PWM (or DAC), 0..100% input range is mapped to the pysical minimum...maximum range of this node. diff --git a/aquaPi/static/spa/components/dashboard/comps.js b/aquaPi/static/spa/components/dashboard/comps.js index 4dc8474..7de3e6d 100644 --- a/aquaPi/static/spa/components/dashboard/comps.js +++ b/aquaPi/static/spa/components/dashboard/comps.js @@ -215,6 +215,7 @@ const MaximumCtrl = { }, } Vue.component('MaximumCtrl', MaximumCtrl) +Vue.component('PidCtrl', MaximumCtrl) const SunCtrl = { @@ -306,6 +307,7 @@ const AnalogDevice = { extends: BusNode, } Vue.component('AnalogDevice', AnalogDevice) +Vue.component('SlowPwmDevice', AnalogDevice) const AuxNode = { extends: BusNode, diff --git a/aquaPi/static/spa/i18n/locales/de.js b/aquaPi/static/spa/i18n/locales/de.js index 991a82c..be72396 100644 --- a/aquaPi/static/spa/i18n/locales/de.js +++ b/aquaPi/static/spa/i18n/locales/de.js @@ -99,6 +99,7 @@ export default history: 'Diagramm', in_endp: 'Eingang', out_endp: 'Ausgang', + alerts: 'Störung', }, dataRange: { default: { diff --git a/aquaPi/static/spa/i18n/locales/en.js b/aquaPi/static/spa/i18n/locales/en.js index 59a8352..54df655 100644 --- a/aquaPi/static/spa/i18n/locales/en.js +++ b/aquaPi/static/spa/i18n/locales/en.js @@ -90,7 +90,6 @@ export default } } } - }, misc: { @@ -100,6 +99,7 @@ export default history: 'Diagram', in_endp: 'Input', out_endp: 'Output', + alerts: 'Alert', }, dataRange: { default: { diff --git a/aquaPi/static/spa/pages/Config.vue.js b/aquaPi/static/spa/pages/Config.vue.js index 1e78e57..77f936b 100644 --- a/aquaPi/static/spa/pages/Config.vue.js +++ b/aquaPi/static/spa/pages/Config.vue.js @@ -49,23 +49,7 @@ const Config = { diff --git a/run b/run index 6e9ef67..32d90fb 100755 --- a/run +++ b/run @@ -24,4 +24,4 @@ if [[ ${reset_cfg} ]]; then rm "instance/${AQUAPI_CFG}"; fi export FLASK_APP=aquaPi export FLASK_ENV=development -nohup flask run --host "$(hostname -i|cut -d ' ' -f 1)" | tee run.log +nohup flask run --host "$(hostname -i|cut -d ' ' -f 1)"