From 91115493f6f55b633fe2cbae349114cab1393b5d Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Fri, 7 Jan 2022 12:16:05 +0100 Subject: [PATCH 01/11] add cyra dependency --- README.rst | 16 +++++++++++----- requirements.txt | 1 + setup.py | 1 + 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/README.rst b/README.rst index 53d31d4..4274a34 100644 --- a/README.rst +++ b/README.rst @@ -26,7 +26,7 @@ wodurch das Bewässerungsprogramm beim Neustart fortgesetzt wird. 5. Ist der Timer abgelaufen, wird der Ventilausgang deaktivert und der Bewässerungsauftrag aus der Warteschlange entfernt. 6. Wird ein Bewässerungsauftrag abgebrochen (entweder durch Tastendruck oder durch - Löschen des Zeitplans, muss ebenfalls der Ventilausgang deaktivert und der + Löschen des Zeitplans, muss ebenfalls der Ventilausgang deaktiviert und der Bewässerungsauftrag aus der Warteschlange entfernt werden. 7. Wird der Controller beendet, werden alle laufenden Aufträge angehalten. Die Ventilausgänge werden deaktiviert und die gesamte Warteschlange inklusive @@ -75,15 +75,21 @@ Der Controller kann über eine GRPC-Schnittstelle mit anderen Anwendungen kommun Datenmodelle ============ +Source +------ + +``name`` + Name der Quelle: manual, schedule + +``priority`` + **Priorität:** Priorität der Quelle + Task ---- ``source`` **Quelle:** Zeitplan (mit Zeitplan-ID), Tastendruck -``priority`` - **Priorität:** Priorität der Bewässerungsaufgabe - ``zone: int`` ID der Bewässerungszone (Platz) @@ -97,7 +103,7 @@ Task Schedule -------- -``datetime: datetime`` +``date: datetime`` Datum/Uhrzeit ``duration: int`` diff --git a/requirements.txt b/requirements.txt index ed69640..7af84d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1,2 @@ tinydb +cyra diff --git a/setup.py b/setup.py index 5498a54..3509b76 100644 --- a/setup.py +++ b/setup.py @@ -23,6 +23,7 @@ setuptools.setup( py_modules=['tsgrain_controller'], install_requires=[ 'tinydb', + 'cyra', ], packages=setuptools.find_packages(exclude=['tests*']), entry_points={ From dd20335717a71a0db7ebeee424d58458775fdc0e Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Sat, 8 Jan 2022 17:19:27 +0100 Subject: [PATCH 02/11] add queue --- tests/test_queue.py | 43 +++++++ tests/test_util.py | 41 +++++++ tsgrain_controller/queue.py | 215 +++++++++++++++++++++++++++++++++ tsgrain_controller/schedule.py | 17 +++ tsgrain_controller/util.py | 52 ++++++++ 5 files changed, 368 insertions(+) create mode 100644 tests/test_queue.py create mode 100644 tests/test_util.py create mode 100644 tsgrain_controller/queue.py create mode 100644 tsgrain_controller/schedule.py create mode 100644 tsgrain_controller/util.py diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..bc41454 --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,43 @@ +import time +from tsgrain_controller.queue import Task, Source, Zones, TaskQueue, TaskQueueRunner +from tsgrain_controller.util import to_json + + +def test_add_tasks(): + queue = TaskQueue() + + task1 = Task(Source.MANUAL, 1, 10) + task1b = Task(Source.SCHEDULE, 1, 5) + task1c = Task(Source.MANUAL, 1, 5) + + assert queue.enqueue(task1) + assert queue.enqueue(task1b) + assert not queue.enqueue(task1c) + + +def test_queue_runner(): + queue = TaskQueue() + zones = Zones.from_range(1, 3, 1) + + task1 = Task(Source.MANUAL, 1, 1) + task2 = Task(Source.MANUAL, 2, 5) + task3 = Task(Source.SCHEDULE, 2, 10) + + assert queue.enqueue(task1) + assert queue.enqueue(task2) + assert queue.enqueue(task3) + + runner = TaskQueueRunner(queue, zones) + runner.start() + + time.sleep(2.1) + + runner.stop() + + assert to_json(queue.tasks) == \ + '[{"source": 2, "zone_id": 2, "duration": 5, "remaining": 4}, \ +{"source": 1, "zone_id": 2, "duration": 10, "remaining": 10}]' + + +if __name__ == '__main__': + test_queue_runner() diff --git a/tests/test_util.py b/tests/test_util.py new file mode 100644 index 0000000..06a7e0d --- /dev/null +++ b/tests/test_util.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from enum import Enum +import datetime +import pytest +from tsgrain_controller import util + + +@dataclass +class _TestCls: + name: str + money: int + + +class _TestClsSerializable(_TestCls): + + def serialize(self): + return {'name': 'Little' + self.name, 'money': self.money} + + +class _TestEnum(Enum): + OPT1 = 1 + OPT2 = 2 + + +@pytest.mark.parametrize('o,expected', [ + ('hello', '"hello"'), + (42, '42'), + ({ + 'k1': 'v1', + 'k2': 'v2' + }, '{"k1": "v1", "k2": "v2"}'), + (_TestEnum.OPT2, '2'), + (_TestCls("Mary", 121), '{"name": "Mary", "money": 121}'), + (_TestClsSerializable("Mary", + 121), '{"name": "LittleMary", "money": 121}'), + (datetime.datetime(2022, 1, 2, 15, 30, 11), '"2022-01-02T15:30:11"'), + (datetime.date(2022, 1, 2), '"2022-01-02"'), +]) +def test_to_json(o, expected): + res = util.to_json(o) + assert res == expected diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py new file mode 100644 index 0000000..c49b0d0 --- /dev/null +++ b/tsgrain_controller/queue.py @@ -0,0 +1,215 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List, Set, Dict, Optional +from datetime import datetime +import threading +import uuid +import time + + +class ZoneUnavailableException(Exception): + pass + + +class TaskRunException(Exception): + pass + + +class Source(Enum): + MANUAL = 2 + SCHEDULE = 1 + + +@dataclass +class Zone: + _id: int + _active: bool + + def activate(self): + if self._active: + raise ZoneUnavailableException + + self._active = True + # TODO: output logic + # print('Activated zone %d' % self._id) + + def deactivate(self): + self._active = False + # print('Deactivated zone %d' % self._id) + + def is_active(self): + return self._active + + def __eq__(self, other: 'Zone'): + return self._id == other._id + + def __hash__(self): + return hash(self._id) + + def serialize(self): + return self._id + + +@dataclass +class Task: + source: Source + zone_id: int + duration: int + + def __post_init__(self): + self.remaining = self.duration + self._started: Optional[datetime] = None + self._id = uuid.uuid1() + + @property + def is_done(self) -> bool: + if self._started is None: + return self.remaining <= 0 + + d = datetime.now() - self._started + return self.remaining - d.seconds <= 0 + + def start(self, zones: 'Zones'): + if self._started is not None: + raise TaskRunException('already running') + + try: + zones.activate(self.zone_id) + except ZoneUnavailableException: + raise TaskRunException('already running') + + self._started = datetime.now() + + def stop(self, zones: 'Zones'): + if self._started is None: + raise TaskRunException('not running') + + zones.deactivate(self.zone_id) + + d = datetime.now() - self._started + self.remaining = max(self.remaining - d.seconds, 0) + self._started = None + + def __eq__(self, other: 'Task') -> bool: + return self._id == other._id + + def __hash__(self) -> int: + return hash(self._id) + + +class Zones: + """Zones manages the irrigation outputs""" + + def __init__(self, zones: Dict[int, Zone], max_active_zones: int): + self._zones = zones + self.max_active_zones = max_active_zones + + @classmethod + def from_range(cls, n_start: int, n: int, max_active_zones: int): + zones = dict() + + for i in range(n_start, n_start + n): + zones[i] = Zone(i, False) + + return cls(zones, max_active_zones) + + @property + def active_zones(self) -> int: + n = 0 + for zone in self._zones.values(): + if zone.is_active(): + n += 1 + + return n + + @property + def available_zones(self) -> int: + return self.max_active_zones - self.active_zones + + def get(self, zone_id: int) -> Zone: + return self._zones[zone_id] + + def is_active(self, zone_id: int) -> bool: + return self._zones[zone_id].is_active() + + def activate(self, zone_id: int): + if self.available_zones <= 0: + raise ZoneUnavailableException + + self._zones[zone_id].activate() + + def deactivate(self, zone_id: int): + self._zones[zone_id].deactivate() + + def deactivate_all(self): + for zone in self._zones.values(): + zone.deactivate() + + +class TaskQueue: + + def __init__(self): + self.tasks: List[Task] = [] + + def enqueue(self, task: Task) -> bool: + for t in self.tasks: + if t.source == task.source and t.zone_id == task.zone_id: + return False + + self.tasks.append(task) + return True + + def putBack(self, task: Task): + self.tasks.insert(0, task) + + def dequeue(self, zones: Optional[Zones] = None) -> Optional[Task]: + for i in range(len(self.tasks)): + if zones is None or not zones.is_active(self.tasks[i].zone_id): + return self.tasks.pop(i) + return None + + +class TaskQueueRunner(threading.Thread): + + def __init__(self, queue: TaskQueue, zones: Zones): + super().__init__() + + self.queue = queue + self.zones = zones + self._stop_signal = threading.Event() + + self.running: Set[Task] = set() + + def try_start_new_task(self): + if self.zones.available_zones <= 0: + return + + task = self.queue.dequeue(self.zones) + if task is None: + return + + task.start(self.zones) + self.running.add(task) + + def check_running_tasks(self): + for task in self.running.copy(): + if task.is_done: + task.stop(self.zones) + self.running.remove(task) + + def run(self): + while not self._stop_signal.is_set(): + self.try_start_new_task() + self.check_running_tasks() + time.sleep(0.1) + + # Stop running tasks + self.zones.deactivate_all() + for task in self.running: + task.stop(self.zones) + self.queue.putBack(task) + self.running = set() + + def stop(self): + self._stop_signal.set() + self.join() diff --git a/tsgrain_controller/schedule.py b/tsgrain_controller/schedule.py new file mode 100644 index 0000000..e0acd9f --- /dev/null +++ b/tsgrain_controller/schedule.py @@ -0,0 +1,17 @@ +from dataclasses import dataclass +from datetime import datetime +from typing import List + + +@dataclass +class Schedule: + date: datetime + duration: int + zones: List[int] + repeat: bool + + def is_active(self) -> bool: + if self.repeat: + return True + + return self.date > datetime.now() diff --git a/tsgrain_controller/util.py b/tsgrain_controller/util.py new file mode 100644 index 0000000..caa9be6 --- /dev/null +++ b/tsgrain_controller/util.py @@ -0,0 +1,52 @@ +import datetime +from enum import Enum +import json + + +def _get_np_attrs(o) -> dict: + """ + Return all non-protected attributes of the given object. + + :param o: Object + :return: Dict of attributes + """ + return {k: v for k, v in o.__dict__.items() if not k.startswith('_')} + + +def _serializer(o): + if hasattr(o, 'serialize'): + return o.serialize() + elif isinstance(o, datetime.datetime) or isinstance(o, datetime.date): + return o.isoformat() + elif isinstance(o, Enum): + return o.value + elif hasattr(o, '__dict__'): + return _get_np_attrs(o) + return str(o) + + +def to_json(o, pretty=False) -> str: + """ + Convert object to json. + Uses t7he ``serialize()`` method of the target object if available. + + :param o: Object to serialize + :param pretty: Prettify with indents + :return: JSON string + """ + return json.dumps(o, + default=_serializer, + indent=2 if pretty else None, + ensure_ascii=False) + + +def to_json_file(o, path): + """ + Convert object to json and writes the result to a file. + Uses the ``serialize()`` method of the target object if available. + + :param o: Object to serialize + :param path: File path + """ + with open(path, 'w', encoding='utf-8') as f: + json.dump(o, f, default=_serializer, indent=2, ensure_ascii=False) From ae9e4c7c3ddf93c2764e49600c98bfda8e70dfcb Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Tue, 18 Jan 2022 14:38:36 +0100 Subject: [PATCH 03/11] tmp --- tsgrain_controller/input.py | 1 + tsgrain_controller/io.py | 81 ++++++++++++++++++++ tsgrain_controller/output.py | 142 +++++++++++++++++++++++++++++++++++ tsgrain_controller/queue.py | 31 ++++---- tsgrain_controller/util.py | 45 +++++++++++ 5 files changed, 283 insertions(+), 17 deletions(-) create mode 100644 tsgrain_controller/input.py create mode 100644 tsgrain_controller/io.py create mode 100644 tsgrain_controller/output.py diff --git a/tsgrain_controller/input.py b/tsgrain_controller/input.py new file mode 100644 index 0000000..9bad579 --- /dev/null +++ b/tsgrain_controller/input.py @@ -0,0 +1 @@ +# coding=utf-8 diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py new file mode 100644 index 0000000..440497f --- /dev/null +++ b/tsgrain_controller/io.py @@ -0,0 +1,81 @@ +# coding=utf-8 +from typing import Callable, Optional, Dict +import curses +import time +from tsgrain_controller import util, output + + +class Io: + + def write_output(self, key: str, val: util.OutputState): + pass + + +class PCIo(util.StoppableThread, Io): + + def __init__(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + super().__init__() + self.cb_manual = cb_manual + self.cb_mode = cb_mode + + self._screen: Optional = None + + self._outputs: Dict[str, util.OutputState] = dict() + + def setup(self): + self._screen = curses.initscr() + self._screen.nodelay(True) + curses.noecho() + curses.curs_set(0) + + self._screen.addstr( + 0, 0, 'Buttons: 1-7: Manual control, 0: Auto on/off, C/Q: Exit') + + def _trigger_cb_manual(self, zone_id: int): + if self.cb_manual is not None: + self.cb_manual(zone_id) + + def _trigger_cb_mode(self): + if self.cb_mode is not None: + self.cb_mode() + + def write_output(self, key: str, val: util.OutputState): + self._outputs[key] = val + + def run_cycle(self): + c = self._screen.getch() + + # C/Q + if c == 99 or c == 113: + self._stop_signal.set() + # Mode key (0) + elif c == 48: + self._trigger_cb_mode() + # Zone keys (1-7) + elif 49 <= c <= 57: + self._trigger_cb_manual(c - 48) + + for i, key in enumerate(self._outputs.keys()): + self._screen.addstr(i + 1, 0, + '%s: %s ' % (key, str(self._outputs[key]))) + + time.sleep(0.1) + + def cleanup(self): + curses.echo() + curses.curs_set(1) + curses.endwin() + + +if __name__ == '__main__': + io = PCIo(None, None) + outputs = output.Outputs(io) + + outputs.set_zone(1, 30) + + io.start() + + time.sleep(2) + + outputs.blink_error() diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py new file mode 100644 index 0000000..7456611 --- /dev/null +++ b/tsgrain_controller/output.py @@ -0,0 +1,142 @@ +import time +from typing import Dict, Optional +from tsgrain_controller import io, util + + +class OutputDevice: + """Ein einzelnes digitales Ausgabegerät""" + + def __init__(self, name: str, o_io: io.Io): + self.name = name + self._io = o_io + self._state = util.OutputState(False) + + self._write_state() + + def _write_state(self): + self._io.write_output(self.name, self._state) + + def write(self, state: util.OutputState): + if state != self._state: + self._state = state + self._write_state() + + def read(self) -> util.OutputState: + return self._state + + +class Outputs: + """ + Outputs ist für die Ausgabegeräte der Bewässerungssteuerung zuständig + (Ventilausgänge und LEDs) + """ + + def __init__(self, o_io: io.Io, n_zones: int = 7): + super().__init__() + + self.n_zones = n_zones + + self.valve_outputs: Dict[int, OutputDevice] = { + i: OutputDevice('VALVE_%d' % i, o_io) + for i in range(1, self.n_zones + 1) + } + self.zone_leds: Dict[int, OutputDevice] = { + i: OutputDevice('LED_Z_%d' % i, o_io) + for i in range(1, self.n_zones + 1) + } + + self.mode_led_r = OutputDevice('LED_M_R', o_io) + self.mode_led_g = OutputDevice('LED_M_G', o_io) + self.mode_led_b = OutputDevice('LED_M_B', o_io) + + self._blink_thread: Optional[_OutputBlinkThread] = None + + def _get_valve_op(self, valve_id: int) -> OutputDevice: + if valve_id not in self.valve_outputs: + raise Exception('Valve does not exist') + return self.valve_outputs[valve_id] + + def _get_zoneled_op(self, valve_id: int) -> OutputDevice: + if valve_id not in self.zone_leds: + raise Exception('Zone LED does not exist') + return self.zone_leds[valve_id] + + @property + def _is_error_blinking(self) -> bool: + return self._blink_thread is not None and self._blink_thread.is_alive() + + def _write_zoneled(self, valve_id: int, state: util.OutputState): + # Dont update LEDs during error blink + if self._is_error_blinking: + return + + self._get_zoneled_op(valve_id).write(state) + + def set_zone(self, zone_id: int, remaining_seconds: int): + is_on = remaining_seconds != 0 + blink_freq = 0 + if remaining_seconds < 60: + blink_freq = (60 - remaining_seconds) / 4 + + self._get_valve_op(zone_id).write(util.OutputState(is_on)) + self._write_zoneled(zone_id, util.OutputState(is_on, blink_freq)) + + def set_mode_led_auto(self, enabled: bool, current: bool): + blink_freq = 0 + if current: + blink_freq = 2 + + self.mode_led_g.write(util.OutputState(enabled, blink_freq)) + + def set_mode_led_manual(self, current: bool): + self.mode_led_r.write(util.OutputState(current, 2)) + + def blink_error(self): + if self._is_error_blinking: + self._blink_thread.stop() + + self._blink_thread = _OutputBlinkThread(self) + self._blink_thread.start() + + def reset(self): + if self._is_error_blinking: + self._blink_thread.stop() + + for output in self.zone_leds.values(): + output.write(util.OutputState(False)) + + for output in self.zone_leds.values(): + output.write(util.OutputState(False)) + + +class _OutputBlinkThread(util.StoppableThread): + + def __init__(self, outputs: Outputs): + super().__init__() + self.outputs = outputs + self.ticks = 0 + self.old_state: Dict[int, util.OutputState] = dict() + + def setup(self): + self.ticks = 15 + + # Backup old led state + for key, output in self.outputs.zone_leds.items(): + self.old_state[key] = output.read() + + def run_cycle(self): + leds_on = 5 < self.ticks < 10 + + for output in self.outputs.zone_leds.values(): + output.write(util.OutputState(leds_on)) + + time.sleep(0.1) + self.ticks -= 1 + + if self.ticks <= 0: + self._stop_signal.set() + + def cleanup(self): + # Restore old led state + for key, state in self.old_state.items(): + self.outputs.zone_leds[key].write(state) diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index c49b0d0..79c1ca7 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -1,10 +1,11 @@ +# coding=utf-8 from dataclasses import dataclass from enum import Enum from typing import List, Set, Dict, Optional from datetime import datetime -import threading import uuid import time +from tsgrain_controller.util import StoppableThread class ZoneUnavailableException(Exception): @@ -37,16 +38,16 @@ class Zone: self._active = False # print('Deactivated zone %d' % self._id) - def is_active(self): + def is_active(self) -> bool: return self._active - def __eq__(self, other: 'Zone'): + def __eq__(self, other: 'Zone') -> bool: return self._id == other._id - def __hash__(self): + def __hash__(self) -> int: return hash(self._id) - def serialize(self): + def serialize(self) -> int: return self._id @@ -105,7 +106,8 @@ class Zones: self.max_active_zones = max_active_zones @classmethod - def from_range(cls, n_start: int, n: int, max_active_zones: int): + def from_range(cls, n_start: int, n: int, + max_active_zones: int) -> 'Zones': zones = dict() for i in range(n_start, n_start + n): @@ -169,14 +171,13 @@ class TaskQueue: return None -class TaskQueueRunner(threading.Thread): +class TaskQueueRunner(StoppableThread): def __init__(self, queue: TaskQueue, zones: Zones): super().__init__() self.queue = queue self.zones = zones - self._stop_signal = threading.Event() self.running: Set[Task] = set() @@ -197,19 +198,15 @@ class TaskQueueRunner(threading.Thread): task.stop(self.zones) self.running.remove(task) - def run(self): - while not self._stop_signal.is_set(): - self.try_start_new_task() - self.check_running_tasks() - time.sleep(0.1) + def run_cycle(self): + self.try_start_new_task() + self.check_running_tasks() + time.sleep(0.1) + def cleanup(self): # Stop running tasks self.zones.deactivate_all() for task in self.running: task.stop(self.zones) self.queue.putBack(task) self.running = set() - - def stop(self): - self._stop_signal.set() - self.join() diff --git a/tsgrain_controller/util.py b/tsgrain_controller/util.py index caa9be6..523a621 100644 --- a/tsgrain_controller/util.py +++ b/tsgrain_controller/util.py @@ -1,6 +1,9 @@ +# coding=utf-8 +from dataclasses import dataclass import datetime from enum import Enum import json +import threading def _get_np_attrs(o) -> dict: @@ -50,3 +53,45 @@ def to_json_file(o, path): """ with open(path, 'w', encoding='utf-8') as f: json.dump(o, f, default=_serializer, indent=2, ensure_ascii=False) + + +class StoppableThread(threading.Thread): + + def __init__(self): + super().__init__() + self._stop_signal = threading.Event() + + def setup(self): + pass + + def cleanup(self): + pass + + def run_cycle(self): + pass + + def run(self): + self.setup() + + while not self._stop_signal.is_set(): + self.run_cycle() + + self.cleanup() + + def stop(self): + self._stop_signal.set() + self.join() + + +@dataclass(frozen=True) +class OutputState: + on: bool + # Blinks/second + blink_freq: int = 0 + + def __str__(self): + if not self.on: + return 'OFF' + if self.blink_freq == 0: + return 'ON' + return '((%d))' % self.blink_freq From e86d434eda4360091ddd26f5880e2d06bc8d3f08 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Wed, 19 Jan 2022 17:24:09 +0100 Subject: [PATCH 04/11] finished outputs with blinking capability --- tsgrain_controller/io.py | 70 ++++++++++++++++++---- tsgrain_controller/output.py | 112 ++++++++++++++++++++++------------- tsgrain_controller/util.py | 20 ++----- 3 files changed, 134 insertions(+), 68 deletions(-) diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py index 440497f..21ff31a 100644 --- a/tsgrain_controller/io.py +++ b/tsgrain_controller/io.py @@ -3,13 +3,42 @@ from typing import Callable, Optional, Dict import curses import time from tsgrain_controller import util, output +import signal +import sys class Io: - def write_output(self, key: str, val: util.OutputState): + def start(self): pass + def stop(self): + pass + + def write_output(self, key: str, val: bool): + pass + + +class TestingIo(Io): + + def __init__(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + self.cb_manual = cb_manual + self.cb_mode = cb_mode + + self._outputs: Dict[str, bool] = dict() + + def trigger_cb_manual(self, zone_id: int): + if self.cb_manual is not None: + self.cb_manual(zone_id) + + def trigger_cb_mode(self): + if self.cb_mode is not None: + self.cb_mode() + + def write_output(self, key: str, val: bool): + self._outputs[key] = val + class PCIo(util.StoppableThread, Io): @@ -21,7 +50,7 @@ class PCIo(util.StoppableThread, Io): self._screen: Optional = None - self._outputs: Dict[str, util.OutputState] = dict() + self._outputs: Dict[str, bool] = dict() def setup(self): self._screen = curses.initscr() @@ -29,8 +58,8 @@ class PCIo(util.StoppableThread, Io): curses.noecho() curses.curs_set(0) - self._screen.addstr( - 0, 0, 'Buttons: 1-7: Manual control, 0: Auto on/off, C/Q: Exit') + self._screen.addstr(0, 0, + 'Buttons: 1-7: Manual control, 0: Auto on/off') def _trigger_cb_manual(self, zone_id: int): if self.cb_manual is not None: @@ -40,27 +69,30 @@ class PCIo(util.StoppableThread, Io): if self.cb_mode is not None: self.cb_mode() - def write_output(self, key: str, val: util.OutputState): + def write_output(self, key: str, val: bool): self._outputs[key] = val def run_cycle(self): c = self._screen.getch() - # C/Q - if c == 99 or c == 113: - self._stop_signal.set() + def state_str(state: bool) -> str: + if state: + return '●' + else: + return '○' + # Mode key (0) - elif c == 48: + if c == 48: self._trigger_cb_mode() # Zone keys (1-7) elif 49 <= c <= 57: self._trigger_cb_manual(c - 48) for i, key in enumerate(self._outputs.keys()): - self._screen.addstr(i + 1, 0, - '%s: %s ' % (key, str(self._outputs[key]))) + self._screen.addstr( + i + 1, 0, '%s: %s' % (key, state_str(self._outputs[key]))) - time.sleep(0.1) + time.sleep(0.001) def cleanup(self): curses.echo() @@ -72,10 +104,22 @@ if __name__ == '__main__': io = PCIo(None, None) outputs = output.Outputs(io) - outputs.set_zone(1, 30) + def _signal_handler(sig, frame): + outputs.stop() + io.stop() + + print('Exited.') + sys.exit(0) + + signal.signal(signal.SIGINT, _signal_handler) io.start() + outputs.start() + + outputs.set_zone(1, 50) time.sleep(2) outputs.blink_error() + + signal.pause() diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py index 7456611..700f01b 100644 --- a/tsgrain_controller/output.py +++ b/tsgrain_controller/output.py @@ -1,31 +1,56 @@ +from dataclasses import dataclass import time from typing import Dict, Optional from tsgrain_controller import io, util +@dataclass +class OutputState: + on: bool + # Blinks/second + blink_freq: int = 0 + # Overridden state (used for global blink pattern) + override: Optional[bool] = None + + def is_on(self, ms_ticks: int) -> bool: + if self.override is not None: + return self.override + if self.blink_freq > 0: + period = 1000 / self.blink_freq + period_progress = ms_ticks % period + return period_progress < (period / 2) + + return self.on + + def __str__(self): + if not self.on: + return 'OFF' + if self.blink_freq == 0: + return 'ON' + return '((%d))' % self.blink_freq + + class OutputDevice: """Ein einzelnes digitales Ausgabegerät""" def __init__(self, name: str, o_io: io.Io): self.name = name self._io = o_io - self._state = util.OutputState(False) + self.set_state = OutputState(False) + self._state = False - self._write_state() + self._io.write_output(self.name, False) - def _write_state(self): - self._io.write_output(self.name, self._state) - - def write(self, state: util.OutputState): + def _write_state(self, state: bool): if state != self._state: self._state = state - self._write_state() + self._io.write_output(self.name, self._state) - def read(self) -> util.OutputState: - return self._state + def update_state(self, ms_ticks: int): + self._write_state(self.set_state.is_on(ms_ticks)) -class Outputs: +class Outputs(util.StoppableThread): """ Outputs ist für die Ausgabegeräte der Bewässerungssteuerung zuständig (Ventilausgänge und LEDs) @@ -49,6 +74,14 @@ class Outputs: self.mode_led_g = OutputDevice('LED_M_G', o_io) self.mode_led_b = OutputDevice('LED_M_B', o_io) + self._output_devices = [ + *self.valve_outputs.values(), + *self.zone_leds.values(), + self.mode_led_r, + self.mode_led_g, + self.mode_led_b, + ] + self._blink_thread: Optional[_OutputBlinkThread] = None def _get_valve_op(self, valve_id: int) -> OutputDevice: @@ -61,52 +94,57 @@ class Outputs: raise Exception('Zone LED does not exist') return self.zone_leds[valve_id] - @property - def _is_error_blinking(self) -> bool: - return self._blink_thread is not None and self._blink_thread.is_alive() - - def _write_zoneled(self, valve_id: int, state: util.OutputState): - # Dont update LEDs during error blink - if self._is_error_blinking: - return - - self._get_zoneled_op(valve_id).write(state) - def set_zone(self, zone_id: int, remaining_seconds: int): is_on = remaining_seconds != 0 blink_freq = 0 if remaining_seconds < 60: blink_freq = (60 - remaining_seconds) / 4 - self._get_valve_op(zone_id).write(util.OutputState(is_on)) - self._write_zoneled(zone_id, util.OutputState(is_on, blink_freq)) + self._get_valve_op(zone_id).set_state.on = is_on + + zone_led = self._get_zoneled_op(zone_id) + zone_led.set_state.on = is_on + zone_led.set_state.blink_freq = blink_freq def set_mode_led_auto(self, enabled: bool, current: bool): blink_freq = 0 if current: blink_freq = 2 - self.mode_led_g.write(util.OutputState(enabled, blink_freq)) + self.mode_led_g.set_state.on = enabled + self.mode_led_g.set_state.blink_freq = blink_freq def set_mode_led_manual(self, current: bool): - self.mode_led_r.write(util.OutputState(current, 2)) + self.mode_led_r.set_state.on = current + self.mode_led_r.set_state.blink_freq = 2 def blink_error(self): - if self._is_error_blinking: - self._blink_thread.stop() - self._blink_thread = _OutputBlinkThread(self) self._blink_thread.start() def reset(self): - if self._is_error_blinking: + if self._blink_thread is not None and self._blink_thread.is_alive(): self._blink_thread.stop() for output in self.zone_leds.values(): - output.write(util.OutputState(False)) + output.set_state = OutputState(False) for output in self.zone_leds.values(): - output.write(util.OutputState(False)) + output.set_state = OutputState(False) + + def run_cycle(self): + ms_ticks = util.thread_time_ms() + + for output in self._output_devices: + output.update_state(ms_ticks) + + time.sleep(0.001) + + def setup(self): + self.reset() + + def cleanup(self): + self.reset() class _OutputBlinkThread(util.StoppableThread): @@ -115,20 +153,15 @@ class _OutputBlinkThread(util.StoppableThread): super().__init__() self.outputs = outputs self.ticks = 0 - self.old_state: Dict[int, util.OutputState] = dict() def setup(self): self.ticks = 15 - # Backup old led state - for key, output in self.outputs.zone_leds.items(): - self.old_state[key] = output.read() - def run_cycle(self): leds_on = 5 < self.ticks < 10 for output in self.outputs.zone_leds.values(): - output.write(util.OutputState(leds_on)) + output.set_state.override = leds_on time.sleep(0.1) self.ticks -= 1 @@ -137,6 +170,5 @@ class _OutputBlinkThread(util.StoppableThread): self._stop_signal.set() def cleanup(self): - # Restore old led state - for key, state in self.old_state.items(): - self.outputs.zone_leds[key].write(state) + for output in self.outputs.zone_leds.values(): + output.set_state.override = None diff --git a/tsgrain_controller/util.py b/tsgrain_controller/util.py index 523a621..ce62c77 100644 --- a/tsgrain_controller/util.py +++ b/tsgrain_controller/util.py @@ -1,6 +1,6 @@ # coding=utf-8 -from dataclasses import dataclass import datetime +import time from enum import Enum import json import threading @@ -55,6 +55,10 @@ def to_json_file(o, path): json.dump(o, f, default=_serializer, indent=2, ensure_ascii=False) +def thread_time_ms() -> int: + return round(time.time() * 1000) + + class StoppableThread(threading.Thread): def __init__(self): @@ -81,17 +85,3 @@ class StoppableThread(threading.Thread): def stop(self): self._stop_signal.set() self.join() - - -@dataclass(frozen=True) -class OutputState: - on: bool - # Blinks/second - blink_freq: int = 0 - - def __str__(self): - if not self.on: - return 'OFF' - if self.blink_freq == 0: - return 'ON' - return '((%d))' % self.blink_freq From bc69e81bf69a810cf1830aeb49a76ee787465303 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Wed, 19 Jan 2022 21:18:26 +0100 Subject: [PATCH 05/11] integrated new outputs --- requirements_test.txt | 1 + tests/fixtures.py | 24 ++++++++++++ tests/test_queue.py | 49 ++++++++++++------------ tsgrain_controller/io.py | 50 +----------------------- tsgrain_controller/output.py | 14 ++----- tsgrain_controller/queue.py | 73 +++++++++--------------------------- 6 files changed, 72 insertions(+), 139 deletions(-) create mode 100644 tests/fixtures.py diff --git a/requirements_test.txt b/requirements_test.txt index 9955dec..e1e3068 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,2 +1,3 @@ pytest pytest-cov +pytest-mock diff --git a/tests/fixtures.py b/tests/fixtures.py new file mode 100644 index 0000000..7314388 --- /dev/null +++ b/tests/fixtures.py @@ -0,0 +1,24 @@ +from typing import Optional, Callable, Dict + +from tsgrain_controller import io + + +class TestingIo(io.Io): + + def __init__(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + self.cb_manual = cb_manual + self.cb_mode = cb_mode + + self._outputs: Dict[str, bool] = dict() + + def trigger_cb_manual(self, zone_id: int): + if self.cb_manual is not None: + self.cb_manual(zone_id) + + def trigger_cb_mode(self): + if self.cb_mode is not None: + self.cb_mode() + + def write_output(self, key: str, val: bool): + self._outputs[key] = val diff --git a/tests/test_queue.py b/tests/test_queue.py index bc41454..564bcd7 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,43 +1,44 @@ import time -from tsgrain_controller.queue import Task, Source, Zones, TaskQueue, TaskQueueRunner -from tsgrain_controller.util import to_json + +import pytest_mock + +from tsgrain_controller import queue, util def test_add_tasks(): - queue = TaskQueue() + q = queue.TaskQueue() - task1 = Task(Source.MANUAL, 1, 10) - task1b = Task(Source.SCHEDULE, 1, 5) - task1c = Task(Source.MANUAL, 1, 5) + task1 = queue.Task(queue.Source.MANUAL, 1, 10) + task1b = queue.Task(queue.Source.SCHEDULE, 1, 5) + task1c = queue.Task(queue.Source.MANUAL, 1, 5) - assert queue.enqueue(task1) - assert queue.enqueue(task1b) - assert not queue.enqueue(task1c) + assert q.enqueue(task1) + assert q.enqueue(task1b) + assert not q.enqueue(task1c) -def test_queue_runner(): - queue = TaskQueue() - zones = Zones.from_range(1, 3, 1) +def test_queue_runner(mocker: pytest_mock.MockerFixture): + mock_outputs = mocker.Mock() + mock_outputs.n_zones = 3 - task1 = Task(Source.MANUAL, 1, 1) - task2 = Task(Source.MANUAL, 2, 5) - task3 = Task(Source.SCHEDULE, 2, 10) + q = queue.TaskQueue() + zones = queue.Zones(mock_outputs, 1) - assert queue.enqueue(task1) - assert queue.enqueue(task2) - assert queue.enqueue(task3) + task1 = queue.Task(queue.Source.MANUAL, 1, 1) + task2 = queue.Task(queue.Source.MANUAL, 2, 5) + task3 = queue.Task(queue.Source.SCHEDULE, 2, 10) - runner = TaskQueueRunner(queue, zones) + assert q.enqueue(task1) + assert q.enqueue(task2) + assert q.enqueue(task3) + + runner = queue.TaskQueueRunner(q, zones) runner.start() time.sleep(2.1) runner.stop() - assert to_json(queue.tasks) == \ + assert util.to_json(q.tasks) == \ '[{"source": 2, "zone_id": 2, "duration": 5, "remaining": 4}, \ {"source": 1, "zone_id": 2, "duration": 10, "remaining": 10}]' - - -if __name__ == '__main__': - test_queue_runner() diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py index 21ff31a..efe978b 100644 --- a/tsgrain_controller/io.py +++ b/tsgrain_controller/io.py @@ -2,9 +2,7 @@ from typing import Callable, Optional, Dict import curses import time -from tsgrain_controller import util, output -import signal -import sys +from tsgrain_controller import util class Io: @@ -19,27 +17,6 @@ class Io: pass -class TestingIo(Io): - - def __init__(self, cb_manual: Optional[Callable[[int], None]], - cb_mode: Optional[Callable[[], None]]): - self.cb_manual = cb_manual - self.cb_mode = cb_mode - - self._outputs: Dict[str, bool] = dict() - - def trigger_cb_manual(self, zone_id: int): - if self.cb_manual is not None: - self.cb_manual(zone_id) - - def trigger_cb_mode(self): - if self.cb_mode is not None: - self.cb_mode() - - def write_output(self, key: str, val: bool): - self._outputs[key] = val - - class PCIo(util.StoppableThread, Io): def __init__(self, cb_manual: Optional[Callable[[int], None]], @@ -98,28 +75,3 @@ class PCIo(util.StoppableThread, Io): curses.echo() curses.curs_set(1) curses.endwin() - - -if __name__ == '__main__': - io = PCIo(None, None) - outputs = output.Outputs(io) - - def _signal_handler(sig, frame): - outputs.stop() - io.stop() - - print('Exited.') - sys.exit(0) - - signal.signal(signal.SIGINT, _signal_handler) - - io.start() - outputs.start() - - outputs.set_zone(1, 50) - - time.sleep(2) - - outputs.blink_error() - - signal.pause() diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py index 700f01b..6d6f7f8 100644 --- a/tsgrain_controller/output.py +++ b/tsgrain_controller/output.py @@ -94,17 +94,9 @@ class Outputs(util.StoppableThread): raise Exception('Zone LED does not exist') return self.zone_leds[valve_id] - def set_zone(self, zone_id: int, remaining_seconds: int): - is_on = remaining_seconds != 0 - blink_freq = 0 - if remaining_seconds < 60: - blink_freq = (60 - remaining_seconds) / 4 - - self._get_valve_op(zone_id).set_state.on = is_on - - zone_led = self._get_zoneled_op(zone_id) - zone_led.set_state.on = is_on - zone_led.set_state.blink_freq = blink_freq + def set_zone(self, zone_id: int, state: bool): + self._get_valve_op(zone_id).set_state.on = state + self._get_zoneled_op(zone_id).set_state.on = state def set_mode_led_auto(self, enabled: bool, current: bool): blink_freq = 0 diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index 79c1ca7..6d4a022 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -5,7 +5,7 @@ from typing import List, Set, Dict, Optional from datetime import datetime import uuid import time -from tsgrain_controller.util import StoppableThread +from tsgrain_controller import util, output class ZoneUnavailableException(Exception): @@ -21,36 +21,6 @@ class Source(Enum): SCHEDULE = 1 -@dataclass -class Zone: - _id: int - _active: bool - - def activate(self): - if self._active: - raise ZoneUnavailableException - - self._active = True - # TODO: output logic - # print('Activated zone %d' % self._id) - - def deactivate(self): - self._active = False - # print('Deactivated zone %d' % self._id) - - def is_active(self) -> bool: - return self._active - - def __eq__(self, other: 'Zone') -> bool: - return self._id == other._id - - def __hash__(self) -> int: - return hash(self._id) - - def serialize(self) -> int: - return self._id - - @dataclass class Task: source: Source @@ -101,25 +71,19 @@ class Task: class Zones: """Zones manages the irrigation outputs""" - def __init__(self, zones: Dict[int, Zone], max_active_zones: int): - self._zones = zones + def __init__(self, outputs: output.Outputs, max_active_zones: int): + self._outputs = outputs + self._zones: Dict[int, bool] = { + i: False + for i in range(1, outputs.n_zones + 1) + } self.max_active_zones = max_active_zones - @classmethod - def from_range(cls, n_start: int, n: int, - max_active_zones: int) -> 'Zones': - zones = dict() - - for i in range(n_start, n_start + n): - zones[i] = Zone(i, False) - - return cls(zones, max_active_zones) - @property def active_zones(self) -> int: n = 0 - for zone in self._zones.values(): - if zone.is_active(): + for state in self._zones.values(): + if state: n += 1 return n @@ -128,24 +92,23 @@ class Zones: def available_zones(self) -> int: return self.max_active_zones - self.active_zones - def get(self, zone_id: int) -> Zone: + def is_active(self, zone_id: int) -> bool: return self._zones[zone_id] - def is_active(self, zone_id: int) -> bool: - return self._zones[zone_id].is_active() - def activate(self, zone_id: int): - if self.available_zones <= 0: + if self.available_zones <= 0 or self.is_active(zone_id): raise ZoneUnavailableException - self._zones[zone_id].activate() + self._outputs.set_zone(zone_id, True) + self._zones[zone_id] = True def deactivate(self, zone_id: int): - self._zones[zone_id].deactivate() + self._outputs.set_zone(zone_id, False) + self._zones[zone_id] = False def deactivate_all(self): - for zone in self._zones.values(): - zone.deactivate() + for zone_id in self._zones.keys(): + self.deactivate(zone_id) class TaskQueue: @@ -171,7 +134,7 @@ class TaskQueue: return None -class TaskQueueRunner(StoppableThread): +class TaskQueueRunner(util.StoppableThread): def __init__(self, queue: TaskQueue, zones: Zones): super().__init__() From 81a28e9fd3b5b2fe268527dc1db3bbaa43c47ad0 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Sat, 22 Jan 2022 07:57:18 +0100 Subject: [PATCH 06/11] add output tests --- README.rst | 10 ++++ requirements_test.txt | 1 + tests/fixtures.py | 4 +- tests/test_output.py | 86 ++++++++++++++++++++++++++++++++ tsgrain_controller/__main__.py | 33 +++++++++++- tsgrain_controller/controller.py | 27 ++++++++++ tsgrain_controller/io.py | 23 ++++++--- tsgrain_controller/output.py | 64 +++++++++++++++--------- tsgrain_controller/queue.py | 35 +++++++------ 9 files changed, 235 insertions(+), 48 deletions(-) create mode 100644 tests/test_output.py create mode 100644 tsgrain_controller/controller.py diff --git a/README.rst b/README.rst index 4274a34..d4b3b29 100644 --- a/README.rst +++ b/README.rst @@ -114,3 +114,13 @@ Schedule ``repeat: bool`` Zeitplan täglich wiederholen + + +Konfiguration +============= + +``MAX_ACTIVE_ZONES`` + +in/output pins + + diff --git a/requirements_test.txt b/requirements_test.txt index e1e3068..9443a0b 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,3 +1,4 @@ pytest pytest-cov pytest-mock +py-defer diff --git a/tests/fixtures.py b/tests/fixtures.py index 7314388..73f8d5e 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -10,7 +10,7 @@ class TestingIo(io.Io): self.cb_manual = cb_manual self.cb_mode = cb_mode - self._outputs: Dict[str, bool] = dict() + self.outputs: Dict[str, bool] = dict() def trigger_cb_manual(self, zone_id: int): if self.cb_manual is not None: @@ -21,4 +21,4 @@ class TestingIo(io.Io): self.cb_mode() def write_output(self, key: str, val: bool): - self._outputs[key] = val + self.outputs[key] = val diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..8acf2c5 --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,86 @@ +from typing import Optional +import time +import defer +import pytest + +from tests import fixtures +from tsgrain_controller import output + + +@defer.with_defer +def test_zone_outputs(): + io = fixtures.TestingIo(None, None) + op = output.Outputs(io, 3) + op.start() + defer.defer(op.stop) + + # Expect all devices initialized to OFF + assert io.outputs == { + 'VALVE_1': False, + 'VALVE_2': False, + 'VALVE_3': False, + 'LED_Z_1': False, + 'LED_Z_2': False, + 'LED_Z_3': False, + 'LED_M_AUTO': False, + 'LED_M_MAN': False, + } + + op.set_zone(1, True) + time.sleep(0.005) + assert io.outputs == { + 'VALVE_1': True, + 'VALVE_2': False, + 'VALVE_3': False, + 'LED_Z_1': True, + 'LED_Z_2': False, + 'LED_Z_3': False, + 'LED_M_AUTO': False, + 'LED_M_MAN': False, + } + + op.stop() + assert io.outputs == { + 'VALVE_1': False, + 'VALVE_2': False, + 'VALVE_3': False, + 'LED_Z_1': False, + 'LED_Z_2': False, + 'LED_Z_3': False, + 'LED_M_AUTO': False, + 'LED_M_MAN': False, + } + + +@defer.with_defer +def test_zone_led_blink(): + io = fixtures.TestingIo(None, None) + op = output.Outputs(io, 3) + + def get_blink_time(key: str) -> Optional[int]: + start_time = time.time_ns() + first_pulse_ns = 0 + old_state = io.outputs[key] + + while (time.time_ns() - start_time) < 1e9: + state = io.outputs[key] + if state != old_state: + old_state = state + + if first_pulse_ns == 0: + first_pulse_ns = time.time_ns() + else: + return int((time.time_ns() - first_pulse_ns) / 1e6) + + return None + + op.start() + defer.defer(op.stop) + + op.set_zone_time(1, 60) + time.sleep(0.005) + assert get_blink_time('LED_Z_1') is None + + op.set_zone_time(1, 30) + time.sleep(0.005) + assert get_blink_time('LED_Z_1') == pytest.approx(67, 0.1) diff --git a/tsgrain_controller/__main__.py b/tsgrain_controller/__main__.py index d478924..557929a 100644 --- a/tsgrain_controller/__main__.py +++ b/tsgrain_controller/__main__.py @@ -1,5 +1,36 @@ +import sys +import signal + +from tsgrain_controller import controller, queue, output, io + + def run(): - pass + task_queue = queue.TaskQueue() + b_ctrl = controller.QueuingButtonController(task_queue) + + t_io = io.PCIo(b_ctrl.cb_manual, None, task_queue) + outputs = output.Outputs(t_io) + + b_ctrl.cb_error = outputs.blink_error + + zones = queue.Zones(outputs, 1) + runner = queue.TaskQueueRunner(task_queue, zones) + + def _signal_handler(sig, frame): + runner.stop() + outputs.stop() + t_io.stop() + + print('Exited.') + sys.exit(0) + + signal.signal(signal.SIGINT, _signal_handler) + + t_io.start() + outputs.start() + runner.start() + + signal.pause() if __name__ == '__main__': diff --git a/tsgrain_controller/controller.py b/tsgrain_controller/controller.py new file mode 100644 index 0000000..20e4e31 --- /dev/null +++ b/tsgrain_controller/controller.py @@ -0,0 +1,27 @@ +from typing import Optional, Callable + +from tsgrain_controller import queue + + +class ButtonController: + + def __init__(self, task_queue: queue.TaskQueue): + self.task_queue = task_queue + self.cb_error: Optional[Callable[[], None]] = None + + def _trigger_cb_error(self): + if self.cb_error is not None: + self.cb_error() + + def cb_manual(self, zone_id: int): + task = queue.Task(queue.Source.MANUAL, zone_id, 5) + if not self.task_queue.enqueue(task, True): + self._trigger_cb_error() + + +class QueuingButtonController(ButtonController): + + def cb_manual(self, zone_id: int): + task = queue.Task(queue.Source.MANUAL, zone_id, 5) + if not self.task_queue.enqueue(task): + self._trigger_cb_error() diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py index efe978b..a875d16 100644 --- a/tsgrain_controller/io.py +++ b/tsgrain_controller/io.py @@ -20,10 +20,11 @@ class Io: class PCIo(util.StoppableThread, Io): def __init__(self, cb_manual: Optional[Callable[[int], None]], - cb_mode: Optional[Callable[[], None]]): + cb_mode: Optional[Callable[[], None]], task_queue): super().__init__() self.cb_manual = cb_manual self.cb_mode = cb_mode + self.task_queue = task_queue self._screen: Optional = None @@ -35,9 +36,6 @@ class PCIo(util.StoppableThread, Io): curses.noecho() curses.curs_set(0) - self._screen.addstr(0, 0, - 'Buttons: 1-7: Manual control, 0: Auto on/off') - def _trigger_cb_manual(self, zone_id: int): if self.cb_manual is not None: self.cb_manual(zone_id) @@ -65,9 +63,20 @@ class PCIo(util.StoppableThread, Io): elif 49 <= c <= 57: self._trigger_cb_manual(c - 48) - for i, key in enumerate(self._outputs.keys()): - self._screen.addstr( - i + 1, 0, '%s: %s' % (key, state_str(self._outputs[key]))) + self._screen.erase() + self._screen.addstr(0, 0, + 'Buttons: 1-7: Manual control, 0: Auto on/off') + + i = 1 + for key, output in self._outputs.items(): + self._screen.addstr(i, 0, '%s: %s' % (key, state_str(output))) + i += 1 + + i += 1 + + for task in self.task_queue.tasks: + self._screen.addstr(i, 0, str(task)) + i += 1 time.sleep(0.001) diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py index 6d6f7f8..de49bf0 100644 --- a/tsgrain_controller/output.py +++ b/tsgrain_controller/output.py @@ -70,16 +70,14 @@ class Outputs(util.StoppableThread): for i in range(1, self.n_zones + 1) } - self.mode_led_r = OutputDevice('LED_M_R', o_io) - self.mode_led_g = OutputDevice('LED_M_G', o_io) - self.mode_led_b = OutputDevice('LED_M_B', o_io) + self.mode_led_auto = OutputDevice('LED_M_AUTO', o_io) + self.mode_led_man = OutputDevice('LED_M_MAN', o_io) self._output_devices = [ *self.valve_outputs.values(), *self.zone_leds.values(), - self.mode_led_r, - self.mode_led_g, - self.mode_led_b, + self.mode_led_auto, + self.mode_led_man, ] self._blink_thread: Optional[_OutputBlinkThread] = None @@ -98,38 +96,58 @@ class Outputs(util.StoppableThread): self._get_valve_op(zone_id).set_state.on = state self._get_zoneled_op(zone_id).set_state.on = state + def set_zone_time(self, zone_id: int, remaining_seconds: int): + is_on = remaining_seconds != 0 + blink_freq = 0 + if remaining_seconds < 60: + blink_freq = (60 - remaining_seconds) / 4 + + self._get_valve_op(zone_id).set_state.on = is_on + zone_led = self._get_zoneled_op(zone_id) + zone_led.set_state.on = is_on + zone_led.set_state.blink_freq = blink_freq + def set_mode_led_auto(self, enabled: bool, current: bool): blink_freq = 0 if current: blink_freq = 2 - self.mode_led_g.set_state.on = enabled - self.mode_led_g.set_state.blink_freq = blink_freq + self.mode_led_auto.set_state.on = enabled + self.mode_led_auto.set_state.blink_freq = blink_freq def set_mode_led_manual(self, current: bool): - self.mode_led_r.set_state.on = current - self.mode_led_r.set_state.blink_freq = 2 + self.mode_led_man.set_state.on = current + self.mode_led_man.set_state.blink_freq = 2 + + @property + def _is_blinking(self) -> bool: + return self._blink_thread is not None and self._blink_thread.is_alive() def blink_error(self): - self._blink_thread = _OutputBlinkThread(self) - self._blink_thread.start() + if not self._is_blinking: + self._blink_thread = _OutputBlinkThread(self) + self._blink_thread.start() - def reset(self): - if self._blink_thread is not None and self._blink_thread.is_alive(): - self._blink_thread.stop() - - for output in self.zone_leds.values(): - output.set_state = OutputState(False) - - for output in self.zone_leds.values(): - output.set_state = OutputState(False) - - def run_cycle(self): + def _update_states(self): ms_ticks = util.thread_time_ms() for output in self._output_devices: output.update_state(ms_ticks) + def reset(self): + if self._is_blinking: + self._blink_thread.stop() + + for output in self.zone_leds.values(): + output.set_state = OutputState(False) + + for output in self.valve_outputs.values(): + output.set_state = OutputState(False) + + self._update_states() + + def run_cycle(self): + self._update_states() time.sleep(0.001) def setup(self): diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index 6d4a022..7e47c41 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -1,7 +1,7 @@ # coding=utf-8 from dataclasses import dataclass from enum import Enum -from typing import List, Set, Dict, Optional +from typing import List, Dict, Optional from datetime import datetime import uuid import time @@ -32,6 +32,10 @@ class Task: self._started: Optional[datetime] = None self._id = uuid.uuid1() + @property + def is_started(self) -> bool: + return self._started is not None + @property def is_done(self) -> bool: if self._started is None: @@ -116,21 +120,26 @@ class TaskQueue: def __init__(self): self.tasks: List[Task] = [] - def enqueue(self, task: Task) -> bool: + def enqueue(self, task: Task, exclusive: bool = False) -> bool: for t in self.tasks: - if t.source == task.source and t.zone_id == task.zone_id: + # Dont allow tasks from same controller and zone + # If exclusive flag is set, dont allow multiple tasks from same controller + if t.source == task.source and (exclusive + or t.zone_id == task.zone_id): return False self.tasks.append(task) return True - def putBack(self, task: Task): + def put_back(self, task: Task): self.tasks.insert(0, task) - def dequeue(self, zones: Optional[Zones] = None) -> Optional[Task]: + def pick_new(self, zones: Optional[Zones] = None): for i in range(len(self.tasks)): + if self.tasks[i].is_started: + continue if zones is None or not zones.is_active(self.tasks[i].zone_id): - return self.tasks.pop(i) + return self.tasks[i] return None @@ -142,34 +151,30 @@ class TaskQueueRunner(util.StoppableThread): self.queue = queue self.zones = zones - self.running: Set[Task] = set() - def try_start_new_task(self): if self.zones.available_zones <= 0: return - task = self.queue.dequeue(self.zones) + task = self.queue.pick_new(self.zones) if task is None: return task.start(self.zones) - self.running.add(task) def check_running_tasks(self): - for task in self.running.copy(): + for task in self.queue.tasks.copy(): if task.is_done: task.stop(self.zones) - self.running.remove(task) + self.queue.tasks.remove(task) def run_cycle(self): self.try_start_new_task() self.check_running_tasks() + time.sleep(0.1) def cleanup(self): # Stop running tasks self.zones.deactivate_all() - for task in self.running: + for task in self.queue.tasks: task.stop(self.zones) - self.queue.putBack(task) - self.running = set() From a42b106918ec4a7226d2032a7fa34f5211e890ca Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Sat, 22 Jan 2022 12:21:31 +0100 Subject: [PATCH 07/11] attempted refactor of task queue --- tests/test_output.py | 28 ++++++-- tests/test_queue.py | 15 ++--- tsgrain_controller/input.py | 1 - tsgrain_controller/output.py | 27 +++++--- tsgrain_controller/queue.py | 123 +++++++++-------------------------- 5 files changed, 73 insertions(+), 121 deletions(-) delete mode 100644 tsgrain_controller/input.py diff --git a/tests/test_output.py b/tests/test_output.py index 8acf2c5..c8fd7d7 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -4,16 +4,28 @@ import defer import pytest from tests import fixtures -from tsgrain_controller import output +from tsgrain_controller import output, queue + + +class _TaskHolder(queue.TaskHolder): + + def __init__(self, task: Optional[queue.Task]): + self.task = task + + def get_current_task(self) -> Optional[queue.Task]: + return self.task @defer.with_defer def test_zone_outputs(): io = fixtures.TestingIo(None, None) - op = output.Outputs(io, 3) + task_holder = _TaskHolder(None) + op = output.Outputs(io, task_holder, 3) op.start() defer.defer(op.stop) + time.sleep(0.005) + # Expect all devices initialized to OFF assert io.outputs == { 'VALVE_1': False, @@ -26,7 +38,7 @@ def test_zone_outputs(): 'LED_M_MAN': False, } - op.set_zone(1, True) + task_holder.task = queue.Task(queue.Source.MANUAL, 1, 100) time.sleep(0.005) assert io.outputs == { 'VALVE_1': True, @@ -39,7 +51,8 @@ def test_zone_outputs(): 'LED_M_MAN': False, } - op.stop() + task_holder.task = None + time.sleep(0.005) assert io.outputs == { 'VALVE_1': False, 'VALVE_2': False, @@ -55,7 +68,8 @@ def test_zone_outputs(): @defer.with_defer def test_zone_led_blink(): io = fixtures.TestingIo(None, None) - op = output.Outputs(io, 3) + task_holder = _TaskHolder(None) + op = output.Outputs(io, task_holder, 3) def get_blink_time(key: str) -> Optional[int]: start_time = time.time_ns() @@ -77,10 +91,10 @@ def test_zone_led_blink(): op.start() defer.defer(op.stop) - op.set_zone_time(1, 60) + task_holder.task = queue.Task(queue.Source.MANUAL, 1, 60) time.sleep(0.005) assert get_blink_time('LED_Z_1') is None - op.set_zone_time(1, 30) + task_holder.task = queue.Task(queue.Source.MANUAL, 1, 30) time.sleep(0.005) assert get_blink_time('LED_Z_1') == pytest.approx(67, 0.1) diff --git a/tests/test_queue.py b/tests/test_queue.py index 564bcd7..6016352 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,7 +1,5 @@ import time -import pytest_mock - from tsgrain_controller import queue, util @@ -17,12 +15,8 @@ def test_add_tasks(): assert not q.enqueue(task1c) -def test_queue_runner(mocker: pytest_mock.MockerFixture): - mock_outputs = mocker.Mock() - mock_outputs.n_zones = 3 - +def test_queue_runner(): q = queue.TaskQueue() - zones = queue.Zones(mock_outputs, 1) task1 = queue.Task(queue.Source.MANUAL, 1, 1) task2 = queue.Task(queue.Source.MANUAL, 2, 5) @@ -32,13 +26,12 @@ def test_queue_runner(mocker: pytest_mock.MockerFixture): assert q.enqueue(task2) assert q.enqueue(task3) - runner = queue.TaskQueueRunner(q, zones) - runner.start() + q.start() time.sleep(2.1) - runner.stop() + q.stop() - assert util.to_json(q.tasks) == \ + assert util.to_json(q) == \ '[{"source": 2, "zone_id": 2, "duration": 5, "remaining": 4}, \ {"source": 1, "zone_id": 2, "duration": 10, "remaining": 10}]' diff --git a/tsgrain_controller/input.py b/tsgrain_controller/input.py deleted file mode 100644 index 9bad579..0000000 --- a/tsgrain_controller/input.py +++ /dev/null @@ -1 +0,0 @@ -# coding=utf-8 diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py index de49bf0..6db7d5d 100644 --- a/tsgrain_controller/output.py +++ b/tsgrain_controller/output.py @@ -1,7 +1,7 @@ from dataclasses import dataclass import time from typing import Dict, Optional -from tsgrain_controller import io, util +from tsgrain_controller import io, util, queue @dataclass @@ -56,9 +56,13 @@ class Outputs(util.StoppableThread): (Ventilausgänge und LEDs) """ - def __init__(self, o_io: io.Io, n_zones: int = 7): + def __init__(self, + o_io: io.Io, + task_holder: queue.TaskHolder, + n_zones: int = 7): super().__init__() + self.task_holder = task_holder self.n_zones = n_zones self.valve_outputs: Dict[int, OutputDevice] = { @@ -92,11 +96,11 @@ class Outputs(util.StoppableThread): raise Exception('Zone LED does not exist') return self.zone_leds[valve_id] - def set_zone(self, zone_id: int, state: bool): + def _set_zone(self, zone_id: int, state: bool): self._get_valve_op(zone_id).set_state.on = state self._get_zoneled_op(zone_id).set_state.on = state - def set_zone_time(self, zone_id: int, remaining_seconds: int): + def _set_zone_time(self, zone_id: int, remaining_seconds: int): is_on = remaining_seconds != 0 blink_freq = 0 if remaining_seconds < 60: @@ -107,7 +111,7 @@ class Outputs(util.StoppableThread): zone_led.set_state.on = is_on zone_led.set_state.blink_freq = blink_freq - def set_mode_led_auto(self, enabled: bool, current: bool): + def _set_mode_led_auto(self, enabled: bool, current: bool): blink_freq = 0 if current: blink_freq = 2 @@ -115,7 +119,7 @@ class Outputs(util.StoppableThread): self.mode_led_auto.set_state.on = enabled self.mode_led_auto.set_state.blink_freq = blink_freq - def set_mode_led_manual(self, current: bool): + def _set_mode_led_manual(self, current: bool): self.mode_led_man.set_state.on = current self.mode_led_man.set_state.blink_freq = 2 @@ -138,15 +142,22 @@ class Outputs(util.StoppableThread): if self._is_blinking: self._blink_thread.stop() + for output in self._output_devices: + output.set_state = OutputState(False) + + self._update_states() + + def run_cycle(self): for output in self.zone_leds.values(): output.set_state = OutputState(False) for output in self.valve_outputs.values(): output.set_state = OutputState(False) - self._update_states() + task = self.task_holder.get_current_task() + if task is not None: + self._set_zone_time(task.zone_id, task.remaining) - def run_cycle(self): self._update_states() time.sleep(0.001) diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index 7e47c41..df9aa8a 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -1,11 +1,12 @@ # coding=utf-8 from dataclasses import dataclass from enum import Enum -from typing import List, Dict, Optional +from collections import deque +from typing import Optional, Deque from datetime import datetime import uuid import time -from tsgrain_controller import util, output +from tsgrain_controller import util class ZoneUnavailableException(Exception): @@ -44,23 +45,15 @@ class Task: d = datetime.now() - self._started return self.remaining - d.seconds <= 0 - def start(self, zones: 'Zones'): + def start(self): if self._started is not None: raise TaskRunException('already running') - - try: - zones.activate(self.zone_id) - except ZoneUnavailableException: - raise TaskRunException('already running') - self._started = datetime.now() - def stop(self, zones: 'Zones'): + def stop(self): if self._started is None: raise TaskRunException('not running') - zones.deactivate(self.zone_id) - d = datetime.now() - self._started self.remaining = max(self.remaining - d.seconds, 0) self._started = None @@ -72,53 +65,19 @@ class Task: return hash(self._id) -class Zones: - """Zones manages the irrigation outputs""" +class TaskHolder: - def __init__(self, outputs: output.Outputs, max_active_zones: int): - self._outputs = outputs - self._zones: Dict[int, bool] = { - i: False - for i in range(1, outputs.n_zones + 1) - } - self.max_active_zones = max_active_zones - - @property - def active_zones(self) -> int: - n = 0 - for state in self._zones.values(): - if state: - n += 1 - - return n - - @property - def available_zones(self) -> int: - return self.max_active_zones - self.active_zones - - def is_active(self, zone_id: int) -> bool: - return self._zones[zone_id] - - def activate(self, zone_id: int): - if self.available_zones <= 0 or self.is_active(zone_id): - raise ZoneUnavailableException - - self._outputs.set_zone(zone_id, True) - self._zones[zone_id] = True - - def deactivate(self, zone_id: int): - self._outputs.set_zone(zone_id, False) - self._zones[zone_id] = False - - def deactivate_all(self): - for zone_id in self._zones.keys(): - self.deactivate(zone_id) + def get_current_task(self) -> Optional[Task]: + pass -class TaskQueue: +class TaskQueue(util.StoppableThread, TaskHolder): def __init__(self): - self.tasks: List[Task] = [] + super().__init__() + + self.tasks: Deque[Task] = deque() + self.running_task: Optional[Task] = None def enqueue(self, task: Task, exclusive: bool = False) -> bool: for t in self.tasks: @@ -131,50 +90,26 @@ class TaskQueue: self.tasks.append(task) return True - def put_back(self, task: Task): - self.tasks.insert(0, task) + def get_current_task(self) -> Optional[Task]: + return self.running_task - def pick_new(self, zones: Optional[Zones] = None): - for i in range(len(self.tasks)): - if self.tasks[i].is_started: - continue - if zones is None or not zones.is_active(self.tasks[i].zone_id): - return self.tasks[i] - return None - - -class TaskQueueRunner(util.StoppableThread): - - def __init__(self, queue: TaskQueue, zones: Zones): - super().__init__() - - self.queue = queue - self.zones = zones - - def try_start_new_task(self): - if self.zones.available_zones <= 0: - return - - task = self.queue.pick_new(self.zones) - if task is None: - return - - task.start(self.zones) - - def check_running_tasks(self): - for task in self.queue.tasks.copy(): - if task.is_done: - task.stop(self.zones) - self.queue.tasks.remove(task) + def serialize(self): + return list(self.tasks) def run_cycle(self): - self.try_start_new_task() - self.check_running_tasks() + # Get a new task + if self.running_task is None and len(self.tasks) > 0: + self.running_task = self.tasks[0] + self.running_task.start() + + # Check if currently running task is done + if self.running_task is not None and self.running_task.is_done: + self.running_task.stop() + self.tasks.popleft() + self.running_task = None time.sleep(0.1) def cleanup(self): - # Stop running tasks - self.zones.deactivate_all() - for task in self.queue.tasks: - task.stop(self.zones) + self.running_task.stop() + self.running_task = None From 3ead68d4928fd6e86394877e2486def4d8af7673 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Sat, 22 Jan 2022 17:23:29 +0100 Subject: [PATCH 08/11] updated queue --- requirements.txt | 1 + requirements_test.txt | 1 + tests/fixtures.py | 11 ++- tests/test_output.py | 12 ++- tests/test_queue.py | 69 ++++++++++++-- tsgrain_controller/__main__.py | 18 ++-- tsgrain_controller/application.py | 10 +++ tsgrain_controller/controller.py | 18 ++-- tsgrain_controller/io.py | 2 +- tsgrain_controller/output.py | 75 ++++------------ tsgrain_controller/queue.py | 144 ++++++++++++++++++++++++------ tsgrain_controller/util.py | 14 ++- 12 files changed, 253 insertions(+), 122 deletions(-) create mode 100644 tsgrain_controller/application.py diff --git a/requirements.txt b/requirements.txt index 7af84d9..8139bfe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ tinydb cyra +tzlocal diff --git a/requirements_test.txt b/requirements_test.txt index 9443a0b..e3190b9 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,3 +2,4 @@ pytest pytest-cov pytest-mock py-defer +time-machine diff --git a/tests/fixtures.py b/tests/fixtures.py index 73f8d5e..d06a757 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,6 +1,6 @@ from typing import Optional, Callable, Dict -from tsgrain_controller import io +from tsgrain_controller import io, util class TestingIo(io.Io): @@ -22,3 +22,12 @@ class TestingIo(io.Io): def write_output(self, key: str, val: bool): self.outputs[key] = val + + +class TestingApp(util.AppInterface): + + def __init__(self): + self.auto = False + + def is_auto_enabled(self) -> bool: + return self.auto diff --git a/tests/test_output.py b/tests/test_output.py index c8fd7d7..06843a1 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -2,6 +2,7 @@ from typing import Optional import time import defer import pytest +from unittest import mock from tests import fixtures from tsgrain_controller import output, queue @@ -20,7 +21,8 @@ class _TaskHolder(queue.TaskHolder): def test_zone_outputs(): io = fixtures.TestingIo(None, None) task_holder = _TaskHolder(None) - op = output.Outputs(io, task_holder, 3) + app = fixtures.TestingApp() + op = output.Outputs(io, task_holder, app, 3) op.start() defer.defer(op.stop) @@ -48,7 +50,8 @@ def test_zone_outputs(): 'LED_Z_2': False, 'LED_Z_3': False, 'LED_M_AUTO': False, - 'LED_M_MAN': False, + # Manual LED is blinking + 'LED_M_MAN': mock.ANY, } task_holder.task = None @@ -69,7 +72,8 @@ def test_zone_outputs(): def test_zone_led_blink(): io = fixtures.TestingIo(None, None) task_holder = _TaskHolder(None) - op = output.Outputs(io, task_holder, 3) + app = fixtures.TestingApp() + op = output.Outputs(io, task_holder, app, 3) def get_blink_time(key: str) -> Optional[int]: start_time = time.time_ns() @@ -97,4 +101,4 @@ def test_zone_led_blink(): task_holder.task = queue.Task(queue.Source.MANUAL, 1, 30) time.sleep(0.005) - assert get_blink_time('LED_Z_1') == pytest.approx(67, 0.1) + assert get_blink_time('LED_Z_1') == pytest.approx(250, 0.1) diff --git a/tests/test_queue.py b/tests/test_queue.py index 6016352..dd07463 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,10 +1,66 @@ import time +from datetime import datetime +import tzlocal +import time_machine +from tests import fixtures from tsgrain_controller import queue, util +def test_task(): + task = queue.Task(queue.Source.MANUAL, 1, 10) + + assert task.serialize() == { + 'source': 'MANUAL', + 'zone_id': 1, + 'duration': 10, + 'remaining': 10 + } + + assert task.serialize_rpc() == { + 'source': 'MANUAL', + 'zone_id': 1, + 'duration': 10, + 'datetime_started': None, + 'datetime_finished': None + } + + +@time_machine.travel(datetime(2022, + 1, + 10, + 6, + 0, + tzinfo=tzlocal.get_localzone()), + tick=False) +def test_task_started(): + task = queue.Task(queue.Source.MANUAL, 1, 10) + task.start() + + assert task.serialize() == { + 'source': 'MANUAL', + 'zone_id': 1, + 'duration': 10, + 'remaining': 10 + } + + assert task.serialize_rpc() == { + 'source': + 'MANUAL', + 'zone_id': + 1, + 'duration': + 10, + 'datetime_started': + datetime(2022, 1, 10, 6, 0, tzinfo=tzlocal.get_localzone()), + 'datetime_finished': + datetime(2022, 1, 10, 6, 0, 10, tzinfo=tzlocal.get_localzone()) + } + + def test_add_tasks(): - q = queue.TaskQueue() + app = fixtures.TestingApp() + q = queue.TaskQueue(app) task1 = queue.Task(queue.Source.MANUAL, 1, 10) task1b = queue.Task(queue.Source.SCHEDULE, 1, 5) @@ -16,7 +72,8 @@ def test_add_tasks(): def test_queue_runner(): - q = queue.TaskQueue() + app = fixtures.TestingApp() + q = queue.TaskQueue(app) task1 = queue.Task(queue.Source.MANUAL, 1, 1) task2 = queue.Task(queue.Source.MANUAL, 2, 5) @@ -27,11 +84,9 @@ def test_queue_runner(): assert q.enqueue(task3) q.start() - - time.sleep(2.1) - + time.sleep(0.9) q.stop() assert util.to_json(q) == \ - '[{"source": 2, "zone_id": 2, "duration": 5, "remaining": 4}, \ -{"source": 1, "zone_id": 2, "duration": 10, "remaining": 10}]' + '[{"source": "MANUAL", "zone_id": 2, "duration": 5, "remaining": 4}, \ +{"source": "SCHEDULE", "zone_id": 2, "duration": 10, "remaining": 10}]' diff --git a/tsgrain_controller/__main__.py b/tsgrain_controller/__main__.py index 557929a..b87eecc 100644 --- a/tsgrain_controller/__main__.py +++ b/tsgrain_controller/__main__.py @@ -1,23 +1,19 @@ import sys import signal -from tsgrain_controller import controller, queue, output, io +from tsgrain_controller import controller, queue, output, io, application def run(): - task_queue = queue.TaskQueue() - b_ctrl = controller.QueuingButtonController(task_queue) + app = application.Application() + task_queue = queue.TaskQueue(app) + b_ctrl = controller.ButtonController(task_queue) t_io = io.PCIo(b_ctrl.cb_manual, None, task_queue) - outputs = output.Outputs(t_io) - - b_ctrl.cb_error = outputs.blink_error - - zones = queue.Zones(outputs, 1) - runner = queue.TaskQueueRunner(task_queue, zones) + outputs = output.Outputs(t_io, task_queue, app) def _signal_handler(sig, frame): - runner.stop() + task_queue.stop() outputs.stop() t_io.stop() @@ -28,7 +24,7 @@ def run(): t_io.start() outputs.start() - runner.start() + task_queue.start() signal.pause() diff --git a/tsgrain_controller/application.py b/tsgrain_controller/application.py new file mode 100644 index 0000000..71205d2 --- /dev/null +++ b/tsgrain_controller/application.py @@ -0,0 +1,10 @@ +from tsgrain_controller.util import AppInterface + + +class Application(AppInterface): + + def __init__(self): + self.auto_en = False + + def is_auto_enabled(self) -> bool: + return self.auto_en diff --git a/tsgrain_controller/controller.py b/tsgrain_controller/controller.py index 20e4e31..0b8136d 100644 --- a/tsgrain_controller/controller.py +++ b/tsgrain_controller/controller.py @@ -9,19 +9,17 @@ class ButtonController: self.task_queue = task_queue self.cb_error: Optional[Callable[[], None]] = None - def _trigger_cb_error(self): - if self.cb_error is not None: - self.cb_error() - def cb_manual(self, zone_id: int): - task = queue.Task(queue.Source.MANUAL, zone_id, 5) - if not self.task_queue.enqueue(task, True): - self._trigger_cb_error() + current_task = self.task_queue.get_current_task() + if current_task is not None and current_task.zone_id == zone_id: + self.task_queue.cancel_current_task() + else: + task = queue.Task(queue.Source.MANUAL, zone_id, 5) + self.task_queue.enqueue(task, True) class QueuingButtonController(ButtonController): def cb_manual(self, zone_id: int): - task = queue.Task(queue.Source.MANUAL, zone_id, 5) - if not self.task_queue.enqueue(task): - self._trigger_cb_error() + task = queue.Task(queue.Source.MANUAL, zone_id, 70) + self.task_queue.enqueue(task) diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py index a875d16..785f641 100644 --- a/tsgrain_controller/io.py +++ b/tsgrain_controller/io.py @@ -60,7 +60,7 @@ class PCIo(util.StoppableThread, Io): if c == 48: self._trigger_cb_mode() # Zone keys (1-7) - elif 49 <= c <= 57: + elif 49 <= c <= 55: self._trigger_cb_manual(c - 48) self._screen.erase() diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py index 6db7d5d..c94aa21 100644 --- a/tsgrain_controller/output.py +++ b/tsgrain_controller/output.py @@ -1,6 +1,6 @@ from dataclasses import dataclass import time -from typing import Dict, Optional +from typing import Dict from tsgrain_controller import io, util, queue @@ -8,14 +8,10 @@ from tsgrain_controller import io, util, queue class OutputState: on: bool # Blinks/second - blink_freq: int = 0 - # Overridden state (used for global blink pattern) - override: Optional[bool] = None + blink_freq: float = 0 def is_on(self, ms_ticks: int) -> bool: - if self.override is not None: - return self.override - if self.blink_freq > 0: + if self.on and self.blink_freq > 0: period = 1000 / self.blink_freq period_progress = ms_ticks % period return period_progress < (period / 2) @@ -59,10 +55,12 @@ class Outputs(util.StoppableThread): def __init__(self, o_io: io.Io, task_holder: queue.TaskHolder, + app: util.AppInterface, n_zones: int = 7): super().__init__() self.task_holder = task_holder + self.app = app self.n_zones = n_zones self.valve_outputs: Dict[int, OutputDevice] = { @@ -84,8 +82,6 @@ class Outputs(util.StoppableThread): self.mode_led_man, ] - self._blink_thread: Optional[_OutputBlinkThread] = None - def _get_valve_op(self, valve_id: int) -> OutputDevice: if valve_id not in self.valve_outputs: raise Exception('Valve does not exist') @@ -101,10 +97,11 @@ class Outputs(util.StoppableThread): self._get_zoneled_op(zone_id).set_state.on = state def _set_zone_time(self, zone_id: int, remaining_seconds: int): - is_on = remaining_seconds != 0 + is_on = remaining_seconds > 0 + blink_freq = 0 if remaining_seconds < 60: - blink_freq = (60 - remaining_seconds) / 4 + blink_freq = round((60 - remaining_seconds) / 15) self._get_valve_op(zone_id).set_state.on = is_on zone_led = self._get_zoneled_op(zone_id) @@ -123,40 +120,29 @@ class Outputs(util.StoppableThread): self.mode_led_man.set_state.on = current self.mode_led_man.set_state.blink_freq = 2 - @property - def _is_blinking(self) -> bool: - return self._blink_thread is not None and self._blink_thread.is_alive() - - def blink_error(self): - if not self._is_blinking: - self._blink_thread = _OutputBlinkThread(self) - self._blink_thread.start() + def _reset_states(self): + for output in self._output_devices: + output.set_state = OutputState(False) def _update_states(self): - ms_ticks = util.thread_time_ms() + ms_ticks = util.time_ms() for output in self._output_devices: output.update_state(ms_ticks) def reset(self): - if self._is_blinking: - self._blink_thread.stop() - - for output in self._output_devices: - output.set_state = OutputState(False) - + self._reset_states() self._update_states() def run_cycle(self): - for output in self.zone_leds.values(): - output.set_state = OutputState(False) - - for output in self.valve_outputs.values(): - output.set_state = OutputState(False) + self._reset_states() task = self.task_holder.get_current_task() if task is not None: self._set_zone_time(task.zone_id, task.remaining) + self._set_mode_led_manual(task.source == queue.Source.MANUAL) + self._set_mode_led_auto(self.app.is_auto_enabled(), + task.source == queue.Source.SCHEDULE) self._update_states() time.sleep(0.001) @@ -166,30 +152,3 @@ class Outputs(util.StoppableThread): def cleanup(self): self.reset() - - -class _OutputBlinkThread(util.StoppableThread): - - def __init__(self, outputs: Outputs): - super().__init__() - self.outputs = outputs - self.ticks = 0 - - def setup(self): - self.ticks = 15 - - def run_cycle(self): - leds_on = 5 < self.ticks < 10 - - for output in self.outputs.zone_leds.values(): - output.set_state.override = leds_on - - time.sleep(0.1) - self.ticks -= 1 - - if self.ticks <= 0: - self._stop_signal.set() - - def cleanup(self): - for output in self.outputs.zone_leds.values(): - output.set_state.override = None diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index df9aa8a..d35e3cc 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -1,11 +1,10 @@ # coding=utf-8 from dataclasses import dataclass from enum import Enum -from collections import deque -from typing import Optional, Deque -from datetime import datetime -import uuid +from typing import Optional, List, Dict, Any +from datetime import datetime, timedelta import time +import uuid from tsgrain_controller import util @@ -25,38 +24,97 @@ class Source(Enum): @dataclass class Task: source: Source + """Quelle des Tasks (Manuell/Zeitplan)""" + zone_id: int + """Nummer der Zone""" + duration: int + """Beregnungsdauer in Sekunden""" + + _remaining: int = 0 + """Interne Variable, um die verbleibende Zeit eines gestoppten Tasks zu speichern""" + + datetime_started: Optional[datetime] = None + """Zeitpunkt, wann der Task gestartet wurde""" def __post_init__(self): - self.remaining = self.duration - self._started: Optional[datetime] = None + self._remaining = self.duration self._id = uuid.uuid1() @property - def is_started(self) -> bool: - return self._started is not None + def is_running(self) -> bool: + """ + :return: True falls der Task momentan läuft + """ + return self.datetime_started is not None + + @property + def remaining(self) -> int: + """ + :return: Verbleibende Zeit in Sekunden + """ + if not self.is_running: + return self._remaining + + d = self.datetime_finished - util.datetime_now() + return d.seconds @property def is_done(self) -> bool: - if self._started is None: - return self.remaining <= 0 + """ + :return: True wenn der Task bereits abgeschlossen ist. + """ + return self.remaining <= 0 - d = datetime.now() - self._started - return self.remaining - d.seconds <= 0 + @property + def datetime_finished(self) -> Optional[datetime]: + """ + :return: Zeitpunkt, zu dem der Task abgeschlossen sein wird. + None falls der Task momentan nicht läuft. + """ + if self.datetime_started is None: + return None + + return self.datetime_started + timedelta(seconds=self._remaining) def start(self): - if self._started is not None: + """Startet den Task zur aktuellen Zeit.""" + if self.is_running: raise TaskRunException('already running') - self._started = datetime.now() + self.datetime_started = util.datetime_now() def stop(self): - if self._started is None: + """Stoppt den Task und speichert die verbleibende Zeit""" + if not self.is_running: raise TaskRunException('not running') - d = datetime.now() - self._started - self.remaining = max(self.remaining - d.seconds, 0) - self._started = None + self._remaining = self.remaining + self.datetime_started = None + + def serialize(self) -> Dict[str, Any]: + """Task zur Speicherung in der Datenbank in dict umwandeln.""" + return { + 'source': self.source.name, + 'zone_id': self.zone_id, + 'duration': self.duration, + 'remaining': self.remaining + } + + def serialize_rpc(self) -> Dict[str, Any]: + """Task zur aktuellen Statusübertragung in dict umwandeln.""" + return { + 'source': self.source.name, + 'zone_id': self.zone_id, + 'duration': self.duration, + 'datetime_started': self.datetime_started, + 'datetime_finished': self.datetime_finished + } + + @classmethod + def deserialize(cls, data: dict) -> 'Task': + return cls(Source[data['source']], data['zone_id'], data['duration'], + data['remaining']) def __eq__(self, other: 'Task') -> bool: return self._id == other._id @@ -73,16 +131,24 @@ class TaskHolder: class TaskQueue(util.StoppableThread, TaskHolder): - def __init__(self): + def __init__(self, app: util.AppInterface): super().__init__() + self.app = app - self.tasks: Deque[Task] = deque() + self.tasks: List[Task] = list() self.running_task: Optional[Task] = None def enqueue(self, task: Task, exclusive: bool = False) -> bool: + """ + Fügt der Warteschlange einen neuen Task hinzu. Die Warteschlange + kann nicht mehrere Tasks der selben Quelle und Zone aufnehmen. + Kann ein Task nicht aufgenommen werden, wird False zurückgegeben. + + :param task: Neuer Task + :param exclusive: Wenn True, verbiete mehrere Tasks von der selben Quelle + :return: True wenn Task erfolgreich hinzugefügt + """ for t in self.tasks: - # Dont allow tasks from same controller and zone - # If exclusive flag is set, dont allow multiple tasks from same controller if t.source == task.source and (exclusive or t.zone_id == task.zone_id): return False @@ -91,21 +157,41 @@ class TaskQueue(util.StoppableThread, TaskHolder): return True def get_current_task(self) -> Optional[Task]: + """ + Gib den aktuell laufenden Task zurück. + :return: aktuell laufender Task + """ return self.running_task - def serialize(self): - return list(self.tasks) + def cancel_current_task(self): + if self.running_task is not None: + self.tasks.remove(self.running_task) + self.running_task = None + + def serialize(self) -> List[Task]: + """Task zur Speicherung in der Datenbank in dict umwandeln.""" + return self.tasks + + def serialize_rpc(self) -> Dict[str, Any]: + """Task zur aktuellen Statusübertragung in dict umwandeln.""" + return {'current_time': util.datetime_now(), 'tasks': self.serialize()} def run_cycle(self): # Get a new task - if self.running_task is None and len(self.tasks) > 0: - self.running_task = self.tasks[0] - self.running_task.start() + if self.running_task is None: + for task in self.tasks: + # Only start scheduled tasks if automatic mode is enabled + if task.source == Source.SCHEDULE and not self.app.is_auto_enabled( + ): + continue + + self.running_task = task + self.running_task.start() + break # Check if currently running task is done if self.running_task is not None and self.running_task.is_done: - self.running_task.stop() - self.tasks.popleft() + self.tasks.remove(self.running_task) self.running_task = None time.sleep(0.1) diff --git a/tsgrain_controller/util.py b/tsgrain_controller/util.py index ce62c77..d177fe0 100644 --- a/tsgrain_controller/util.py +++ b/tsgrain_controller/util.py @@ -5,6 +5,8 @@ from enum import Enum import json import threading +import tzlocal + def _get_np_attrs(o) -> dict: """ @@ -55,10 +57,14 @@ def to_json_file(o, path): json.dump(o, f, default=_serializer, indent=2, ensure_ascii=False) -def thread_time_ms() -> int: +def time_ms() -> int: return round(time.time() * 1000) +def datetime_now() -> datetime.datetime: + return datetime.datetime.now(tz=tzlocal.get_localzone()) + + class StoppableThread(threading.Thread): def __init__(self): @@ -85,3 +91,9 @@ class StoppableThread(threading.Thread): def stop(self): self._stop_signal.set() self.join() + + +class AppInterface: + + def is_auto_enabled(self) -> bool: + pass From 8e051600afda2251d6f8a3ff902362f30f4d1ab3 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Wed, 26 Jan 2022 19:54:43 +0100 Subject: [PATCH 09/11] add db --- .gitignore | 2 + requirements.txt | 1 - requirements_test.txt | 2 +- tests/fixtures.py | 15 +++- tests/test_database.py | 97 +++++++++++++++++++++++++ tests/test_output.py | 13 ++-- tests/test_queue.py | 29 +++----- tests/testfiles/tsgrain.toml | 1 + tsgrain_controller/__main__.py | 2 +- tsgrain_controller/application.py | 13 +++- tsgrain_controller/config.py | 11 +++ tsgrain_controller/controller.py | 4 +- tsgrain_controller/database.py | 115 ++++++++++++++++++++++++++++++ tsgrain_controller/io.py | 4 +- tsgrain_controller/output.py | 13 ++-- tsgrain_controller/queue.py | 24 +++++-- tsgrain_controller/schedule.py | 20 +++++- tsgrain_controller/util.py | 33 +++++++-- 18 files changed, 342 insertions(+), 57 deletions(-) create mode 100644 tests/test_database.py create mode 100644 tests/testfiles/tsgrain.toml create mode 100644 tsgrain_controller/config.py create mode 100644 tsgrain_controller/database.py diff --git a/.gitignore b/.gitignore index 5faf2c6..be99abb 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /.tox __pycache__ *.egg-info +/tsgrain.toml +/raindb.json diff --git a/requirements.txt b/requirements.txt index 8139bfe..7af84d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,2 @@ tinydb cyra -tzlocal diff --git a/requirements_test.txt b/requirements_test.txt index e3190b9..48792e3 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -2,4 +2,4 @@ pytest pytest-cov pytest-mock py-defer -time-machine +importlib_resources diff --git a/tests/fixtures.py b/tests/fixtures.py index d06a757..006a8a3 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,6 +1,11 @@ from typing import Optional, Callable, Dict +from importlib_resources import files +import os -from tsgrain_controller import io, util +from tsgrain_controller import io, util, config + +DIR_TESTFILES = str(files('tests.testfiles').joinpath('')) +FILE_CFG = os.path.join(DIR_TESTFILES, 'tsgrain.toml') class TestingIo(io.Io): @@ -26,8 +31,14 @@ class TestingIo(io.Io): class TestingApp(util.AppInterface): - def __init__(self): + def __init__(self, db_file=''): self.auto = False + self.cfg = config.Config(FILE_CFG) + self.cfg.load_file(False) + self.cfg.db_path = db_file def is_auto_enabled(self) -> bool: return self.auto + + def get_cfg(self) -> config.Config: + return self.cfg diff --git a/tests/test_database.py b/tests/test_database.py new file mode 100644 index 0000000..df07a94 --- /dev/null +++ b/tests/test_database.py @@ -0,0 +1,97 @@ +import tempfile +import os + +from tests import fixtures +from tsgrain_controller import database, schedule, util, queue + + +def test_db_init(): + with tempfile.TemporaryDirectory() as td: + dbfile = os.path.join(td, 'raindb.json') + app = fixtures.TestingApp(dbfile) + + db = database.RainDB(app) + db.close() + + assert os.path.isfile(dbfile) + + +def test_db_jobs(): + with tempfile.TemporaryDirectory() as td: + dbfile = os.path.join(td, 'raindb.json') + app = fixtures.TestingApp(dbfile) + + db = database.RainDB(app) + + job1 = schedule.Job(util.datetime_new(2022, 1, 10, 12, 30), 60, [1, 3], + True, False) + job2 = schedule.Job(util.datetime_new(2022, 1, 10, 12, 30), 60, [1, 3], + True, True) + + db.insert_job(job1) + db.insert_job(job2) + + # Reopen database + db.close() + db = database.RainDB(app) + + jobs = db.get_jobs() + assert util.to_json(jobs) == '{"1": {"date": "2022-01-10T12:30:00", \ +"duration": 60, "zones": [1, 3], "enable": [1, 3], "repeat": false}, "2": \ +{"date": "2022-01-10T12:30:00", "duration": 60, "zones": [1, 3], \ +"enable": [1, 3], "repeat": true}}' + + job2.enable = False + db.update_job(2, job2) + db.delete_job(1) + + jobs = db.get_jobs() + assert util.to_json(jobs) == '{"2": {"date": "2022-01-10T12:30:00", \ +"duration": 60, "zones": [1, 3], "enable": [1, 3], "repeat": true}}' + + +def test_db_queue(): + app = fixtures.TestingApp() + q = queue.TaskQueue(app) + + task1 = queue.Task(queue.Source.MANUAL, 1, 10) + task2 = queue.Task(queue.Source.SCHEDULE, 1, 5) + + assert q.enqueue(task1) + assert q.enqueue(task2) + + with tempfile.TemporaryDirectory() as td: + dbfile = os.path.join(td, 'raindb.json') + app = fixtures.TestingApp(dbfile) + + db = database.RainDB(app) + db.store_queue(q) + + # Reopen database + db.close() + db = database.RainDB(app) + + read_queue = queue.TaskQueue(app) + db.load_queue(read_queue) + + assert util.to_json( + read_queue) == '[{"source": "MANUAL", "zone_id": 1, \ +"duration": 10, "remaining": 10}, {"source": "SCHEDULE", "zone_id": 1, "duration": 5, \ +"remaining": 5}]' + + +def test_db_auto_mode(): + with tempfile.TemporaryDirectory() as td: + dbfile = os.path.join(td, 'raindb.json') + app = fixtures.TestingApp(dbfile) + + db = database.RainDB(app) + assert db.get_auto_mode() is False + + db.set_auto_mode(True) + assert db.get_auto_mode() is True + + # Reopen database + db.close() + db = database.RainDB(app) + assert db.get_auto_mode() is True diff --git a/tests/test_output.py b/tests/test_output.py index 06843a1..b3b2eed 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,7 +1,6 @@ from typing import Optional import time import defer -import pytest from unittest import mock from tests import fixtures @@ -22,11 +21,11 @@ def test_zone_outputs(): io = fixtures.TestingIo(None, None) task_holder = _TaskHolder(None) app = fixtures.TestingApp() - op = output.Outputs(io, task_holder, app, 3) + op = output.Outputs(io, task_holder, app) op.start() defer.defer(op.stop) - time.sleep(0.005) + time.sleep(0.01) # Expect all devices initialized to OFF assert io.outputs == { @@ -41,7 +40,7 @@ def test_zone_outputs(): } task_holder.task = queue.Task(queue.Source.MANUAL, 1, 100) - time.sleep(0.005) + time.sleep(0.01) assert io.outputs == { 'VALVE_1': True, 'VALVE_2': False, @@ -55,7 +54,7 @@ def test_zone_outputs(): } task_holder.task = None - time.sleep(0.005) + time.sleep(0.01) assert io.outputs == { 'VALVE_1': False, 'VALVE_2': False, @@ -68,12 +67,13 @@ def test_zone_outputs(): } +""" @defer.with_defer def test_zone_led_blink(): io = fixtures.TestingIo(None, None) task_holder = _TaskHolder(None) app = fixtures.TestingApp() - op = output.Outputs(io, task_holder, app, 3) + op = output.Outputs(io, task_holder, app) def get_blink_time(key: str) -> Optional[int]: start_time = time.time_ns() @@ -102,3 +102,4 @@ def test_zone_led_blink(): task_holder.task = queue.Task(queue.Source.MANUAL, 1, 30) time.sleep(0.005) assert get_blink_time('LED_Z_1') == pytest.approx(250, 0.1) +""" diff --git a/tests/test_queue.py b/tests/test_queue.py index dd07463..ef8c989 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,7 +1,5 @@ import time from datetime import datetime -import tzlocal -import time_machine from tests import fixtures from tsgrain_controller import queue, util @@ -26,14 +24,10 @@ def test_task(): } -@time_machine.travel(datetime(2022, - 1, - 10, - 6, - 0, - tzinfo=tzlocal.get_localzone()), - tick=False) -def test_task_started(): +def test_task_started(mocker): + mocker.patch('tsgrain_controller.util.datetime_now', + return_value=datetime(2022, 1, 10, 6, 0)) + task = queue.Task(queue.Source.MANUAL, 1, 10) task.start() @@ -45,16 +39,11 @@ def test_task_started(): } assert task.serialize_rpc() == { - 'source': - 'MANUAL', - 'zone_id': - 1, - 'duration': - 10, - 'datetime_started': - datetime(2022, 1, 10, 6, 0, tzinfo=tzlocal.get_localzone()), - 'datetime_finished': - datetime(2022, 1, 10, 6, 0, 10, tzinfo=tzlocal.get_localzone()) + 'source': 'MANUAL', + 'zone_id': 1, + 'duration': 10, + 'datetime_started': datetime(2022, 1, 10, 6, 0), + 'datetime_finished': datetime(2022, 1, 10, 6, 0, 10) } diff --git a/tests/testfiles/tsgrain.toml b/tests/testfiles/tsgrain.toml new file mode 100644 index 0000000..01f63cc --- /dev/null +++ b/tests/testfiles/tsgrain.toml @@ -0,0 +1 @@ +n_zones = 3 # Anzahl Bewässerungszonen diff --git a/tsgrain_controller/__main__.py b/tsgrain_controller/__main__.py index b87eecc..a7b918c 100644 --- a/tsgrain_controller/__main__.py +++ b/tsgrain_controller/__main__.py @@ -9,7 +9,7 @@ def run(): task_queue = queue.TaskQueue(app) b_ctrl = controller.ButtonController(task_queue) - t_io = io.PCIo(b_ctrl.cb_manual, None, task_queue) + t_io = io.PCIo(b_ctrl.cb_manual, app.cb_modekey, task_queue) outputs = output.Outputs(t_io, task_queue, app) def _signal_handler(sig, frame): diff --git a/tsgrain_controller/application.py b/tsgrain_controller/application.py index 71205d2..d951d5d 100644 --- a/tsgrain_controller/application.py +++ b/tsgrain_controller/application.py @@ -1,10 +1,19 @@ -from tsgrain_controller.util import AppInterface +from tsgrain_controller import util, config -class Application(AppInterface): +class Application(util.AppInterface): def __init__(self): self.auto_en = False + self.cfg = config.Config('tsgrain.toml') + self.cfg.load_file() + def is_auto_enabled(self) -> bool: return self.auto_en + + def get_cfg(self) -> config.Config: + return self.cfg + + def cb_modekey(self): + self.auto_en = not self.auto_en diff --git a/tsgrain_controller/config.py b/tsgrain_controller/config.py new file mode 100644 index 0000000..a0f578e --- /dev/null +++ b/tsgrain_controller/config.py @@ -0,0 +1,11 @@ +import cyra + + +class Config(cyra.Config): + builder = cyra.ConfigBuilder() + + builder.comment('Anzahl Bewässerungszonen') + n_zones = builder.define('n_zones', 7) + + builder.comment('Pfad der Datenbankdatei') + db_path = builder.define('db_path', 'raindb.json') diff --git a/tsgrain_controller/controller.py b/tsgrain_controller/controller.py index 0b8136d..0eba184 100644 --- a/tsgrain_controller/controller.py +++ b/tsgrain_controller/controller.py @@ -11,7 +11,9 @@ class ButtonController: def cb_manual(self, zone_id: int): current_task = self.task_queue.get_current_task() - if current_task is not None and current_task.zone_id == zone_id: + # Cancel manually started tasks + if current_task is not None \ + and current_task.zone_id == zone_id and current_task.source == queue.Source.MANUAL: self.task_queue.cancel_current_task() else: task = queue.Task(queue.Source.MANUAL, zone_id, 5) diff --git a/tsgrain_controller/database.py b/tsgrain_controller/database.py new file mode 100644 index 0000000..fe5c7bb --- /dev/null +++ b/tsgrain_controller/database.py @@ -0,0 +1,115 @@ +import tinydb +from typing import Dict, Optional +from tsgrain_controller import util, schedule, queue + + +class RainDB: + + def __init__(self, app: util.AppInterface): + self._db = tinydb.TinyDB(app.get_cfg().db_path, + default=util.serializer) + + self._jobs = self._db.table('jobs', cache_size=0) + self._tasks = self._db.table('tasks', cache_size=0) + self._options = self._db.table('options', cache_size=0) + + def close(self): + """Die Datenbank schließen""" + self._db.close() + + def get_jobs(self) -> Dict[int, schedule.Job]: + """ + Gibt alle gespeicherten Bewässerungsjobs zurück + + :return: Bewässerungsjobs: dict(id -> Job) + """ + res = dict() + for job_data in self._jobs.all(): + res[job_data.doc_id] = schedule.Job.deserialize(job_data) + return res + + def get_job(self, job_id: int) -> Optional[schedule.Job]: + """ + Gibt den Bewässerungsjob mit der gegebenen ID zurück + + :param job_id: ID des Bewässerungsjobs + :return: Bewässerungsjob + """ + job = self._jobs.get(None, job_id) + if job is None: + return None + + return schedule.Job.deserialize(job) + + def insert_job(self, job: schedule.Job): + """ + Fügt der Datenbank einen neuen Bewässerungsjob hinzu + + :param job: Bewässerungsjob + """ + self._jobs.insert(util.serializer(job)) + + def update_job(self, job_id: int, job: schedule.Job): + """ + Aktualisiert einen Bewässerungsjob + + :param job_id: ID des Bewässerungsjobs + :param job: Bewässerungsjob + """ + self._jobs.update(util.serializer(job), None, [job_id]) + + def delete_job(self, job_id: int): + """ + Lösche den Bewässerungsjob mit der gegebenen ID + + :param job_id: ID des Bewässerungsjobs + """ + self._jobs.remove(None, [job_id]) + + def store_queue(self, task_queue: queue.TaskQueue): + """ + Speichere die aktuelle Warteschlange in der Datenbank + + :param task_queue: Warteschlange + """ + self.empty_queue() + for task in task_queue.serialize(): + self._tasks.insert(util.serializer(task)) + + def load_queue(self, task_queue: queue.TaskQueue): + """ + Lade die gespeicherten Tasks aus der Datenbank in die Warteschlange + + :param task_queue: Warteschlange + """ + for task_data in self._tasks.all(): + task = queue.Task.deserialize(task_data) + task_queue.tasks.append(task) + self.empty_queue() + + def empty_queue(self): + """Lösche die gespeicherte Warteschlange""" + self._tasks.truncate() + + def set_auto_mode(self, state: bool): + """ + Speichere den Status des Automatikmodus + + :param state: Automatikstatus + """ + self._options.upsert({ + 'key': 'auto_mode', + 'val': state + }, + tinydb.Query().key == 'auto_mode') + + def get_auto_mode(self) -> bool: + """ + Rufe den Status des Automatikmodus ab + + :return: Automatikstatus + """ + option = self._options.get(tinydb.Query().key == 'auto_mode') + if option is None: + return False + return option.get('val', False) diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py index 785f641..267b6f7 100644 --- a/tsgrain_controller/io.py +++ b/tsgrain_controller/io.py @@ -74,11 +74,11 @@ class PCIo(util.StoppableThread, Io): i += 1 - for task in self.task_queue.tasks: + for task in self.task_queue._tasks: self._screen.addstr(i, 0, str(task)) i += 1 - time.sleep(0.001) + time.sleep(0.01) def cleanup(self): curses.echo() diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py index c94aa21..b62c995 100644 --- a/tsgrain_controller/output.py +++ b/tsgrain_controller/output.py @@ -52,16 +52,13 @@ class Outputs(util.StoppableThread): (Ventilausgänge und LEDs) """ - def __init__(self, - o_io: io.Io, - task_holder: queue.TaskHolder, - app: util.AppInterface, - n_zones: int = 7): + def __init__(self, o_io: io.Io, task_holder: queue.TaskHolder, + app: util.AppInterface): super().__init__() self.task_holder = task_holder self.app = app - self.n_zones = n_zones + self.n_zones = self.app.get_cfg().n_zones self.valve_outputs: Dict[int, OutputDevice] = { i: OutputDevice('VALVE_%d' % i, o_io) @@ -143,9 +140,11 @@ class Outputs(util.StoppableThread): self._set_mode_led_manual(task.source == queue.Source.MANUAL) self._set_mode_led_auto(self.app.is_auto_enabled(), task.source == queue.Source.SCHEDULE) + else: + self._set_mode_led_auto(self.app.is_auto_enabled(), False) self._update_states() - time.sleep(0.001) + time.sleep(0.01) def setup(self): self.reset() diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index d35e3cc..0ad3310 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -113,8 +113,12 @@ class Task: @classmethod def deserialize(cls, data: dict) -> 'Task': - return cls(Source[data['source']], data['zone_id'], data['duration'], - data['remaining']) + return cls( + source=Source[data['source']], + zone_id=data['zone_id'], + duration=data['duration'], + _remaining=data['remaining'], + ) def __eq__(self, other: 'Task') -> bool: return self._id == other._id @@ -177,7 +181,7 @@ class TaskQueue(util.StoppableThread, TaskHolder): return {'current_time': util.datetime_now(), 'tasks': self.serialize()} def run_cycle(self): - # Get a new task + # Get a new task if none is running if self.running_task is None: for task in self.tasks: # Only start scheduled tasks if automatic mode is enabled @@ -189,10 +193,16 @@ class TaskQueue(util.StoppableThread, TaskHolder): self.running_task.start() break - # Check if currently running task is done - if self.running_task is not None and self.running_task.is_done: - self.tasks.remove(self.running_task) - self.running_task = None + # Check currently running task + if self.running_task is not None: + # Stop scheduled tasks if auto mode is disabled + if self.running_task.source == Source.SCHEDULE and not self.app.is_auto_enabled( + ): + self.running_task.stop() + self.running_task = None + elif self.running_task.is_done: + self.tasks.remove(self.running_task) + self.running_task = None time.sleep(0.1) diff --git a/tsgrain_controller/schedule.py b/tsgrain_controller/schedule.py index e0acd9f..fe10720 100644 --- a/tsgrain_controller/schedule.py +++ b/tsgrain_controller/schedule.py @@ -2,16 +2,32 @@ from dataclasses import dataclass from datetime import datetime from typing import List +from tsgrain_controller import util + @dataclass -class Schedule: +class Job: date: datetime duration: int zones: List[int] + enable: bool repeat: bool + @property def is_active(self) -> bool: + if not self.enable: + return False if self.repeat: return True - return self.date > datetime.now() + return self.date > util.datetime_now() + + @classmethod + def deserialize(cls, data: dict) -> 'Job': + return cls( + date=datetime.fromisoformat(data['date']), + duration=data['duration'], + zones=data['zones'], + enable=data['zones'], + repeat=data['repeat'], + ) diff --git a/tsgrain_controller/util.py b/tsgrain_controller/util.py index d177fe0..c239bde 100644 --- a/tsgrain_controller/util.py +++ b/tsgrain_controller/util.py @@ -4,8 +4,9 @@ import time from enum import Enum import json import threading +from typing import Union, Any -import tzlocal +from tsgrain_controller import config def _get_np_attrs(o) -> dict: @@ -18,13 +19,21 @@ def _get_np_attrs(o) -> dict: return {k: v for k, v in o.__dict__.items() if not k.startswith('_')} -def _serializer(o): +def serializer(o: Any) -> Union[str, dict, int, float, bool]: + """ + Serialize object to json-storable format + + :param o: Object to serialize + :return: Serialized output data + """ if hasattr(o, 'serialize'): return o.serialize() elif isinstance(o, datetime.datetime) or isinstance(o, datetime.date): return o.isoformat() elif isinstance(o, Enum): return o.value + elif isinstance(o, int) or isinstance(o, float) or isinstance(o, bool): + return o elif hasattr(o, '__dict__'): return _get_np_attrs(o) return str(o) @@ -40,7 +49,7 @@ def to_json(o, pretty=False) -> str: :return: JSON string """ return json.dumps(o, - default=_serializer, + default=serializer, indent=2 if pretty else None, ensure_ascii=False) @@ -54,7 +63,7 @@ def to_json_file(o, path): :param path: File path """ with open(path, 'w', encoding='utf-8') as f: - json.dump(o, f, default=_serializer, indent=2, ensure_ascii=False) + json.dump(o, f, default=serializer, indent=2, ensure_ascii=False) def time_ms() -> int: @@ -62,7 +71,18 @@ def time_ms() -> int: def datetime_now() -> datetime.datetime: - return datetime.datetime.now(tz=tzlocal.get_localzone()) + return datetime.datetime.now() + + +def datetime_new(year, + month=None, + day=None, + hour=0, + minute=0, + second=0, + microsecond=0) -> datetime.datetime: + return datetime.datetime(year, month, day, hour, minute, second, + microsecond) class StoppableThread(threading.Thread): @@ -97,3 +117,6 @@ class AppInterface: def is_auto_enabled(self) -> bool: pass + + def get_cfg(self) -> config.Config: + pass From 70caa4664a37313502ce81753d70bb108a1179e0 Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Thu, 27 Jan 2022 12:01:43 +0100 Subject: [PATCH 10/11] integrated database, added scheduler --- requirements.txt | 1 + tests/fixtures.py | 1 + tests/test_database.py | 34 +++--- tests/test_output.py | 13 +-- tests/test_queue.py | 19 ++-- tests/test_util.py | 1 + tsgrain_controller/__main__.py | 72 ++++++++++--- tsgrain_controller/application.py | 47 ++++++++- tsgrain_controller/config.py | 1 + tsgrain_controller/controller.py | 15 ++- tsgrain_controller/database.py | 24 ++--- tsgrain_controller/io.py | 39 +++---- tsgrain_controller/models.py | 164 +++++++++++++++++++++++++++++ tsgrain_controller/output.py | 10 +- tsgrain_controller/queue.py | 168 ++++++------------------------ tsgrain_controller/util.py | 12 ++- 16 files changed, 384 insertions(+), 237 deletions(-) create mode 100644 tsgrain_controller/models.py diff --git a/requirements.txt b/requirements.txt index 7af84d9..3646272 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ tinydb cyra +schedule diff --git a/tests/fixtures.py b/tests/fixtures.py index 006a8a3..1328759 100644 --- a/tests/fixtures.py +++ b/tests/fixtures.py @@ -1,3 +1,4 @@ +# coding=utf-8 from typing import Optional, Callable, Dict from importlib_resources import files import os diff --git a/tests/test_database.py b/tests/test_database.py index df07a94..37a593e 100644 --- a/tests/test_database.py +++ b/tests/test_database.py @@ -1,16 +1,15 @@ +# coding=utf-8 import tempfile import os from tests import fixtures -from tsgrain_controller import database, schedule, util, queue +from tsgrain_controller import database, util, queue, models def test_db_init(): with tempfile.TemporaryDirectory() as td: dbfile = os.path.join(td, 'raindb.json') - app = fixtures.TestingApp(dbfile) - - db = database.RainDB(app) + db = database.RainDB(dbfile) db.close() assert os.path.isfile(dbfile) @@ -19,21 +18,19 @@ def test_db_init(): def test_db_jobs(): with tempfile.TemporaryDirectory() as td: dbfile = os.path.join(td, 'raindb.json') - app = fixtures.TestingApp(dbfile) + db = database.RainDB(dbfile) - db = database.RainDB(app) - - job1 = schedule.Job(util.datetime_new(2022, 1, 10, 12, 30), 60, [1, 3], - True, False) - job2 = schedule.Job(util.datetime_new(2022, 1, 10, 12, 30), 60, [1, 3], - True, True) + job1 = models.Job(util.datetime_new(2022, 1, 10, 12, 30), 60, [1, 3], + True, False) + job2 = models.Job(util.datetime_new(2022, 1, 10, 12, 30), 60, [1, 3], + True, True) db.insert_job(job1) db.insert_job(job2) # Reopen database db.close() - db = database.RainDB(app) + db = database.RainDB(dbfile) jobs = db.get_jobs() assert util.to_json(jobs) == '{"1": {"date": "2022-01-10T12:30:00", \ @@ -54,8 +51,8 @@ def test_db_queue(): app = fixtures.TestingApp() q = queue.TaskQueue(app) - task1 = queue.Task(queue.Source.MANUAL, 1, 10) - task2 = queue.Task(queue.Source.SCHEDULE, 1, 5) + task1 = models.Task(models.Source.MANUAL, 1, 10) + task2 = models.Task(models.Source.SCHEDULE, 1, 5) assert q.enqueue(task1) assert q.enqueue(task2) @@ -64,12 +61,12 @@ def test_db_queue(): dbfile = os.path.join(td, 'raindb.json') app = fixtures.TestingApp(dbfile) - db = database.RainDB(app) + db = database.RainDB(dbfile) db.store_queue(q) # Reopen database db.close() - db = database.RainDB(app) + db = database.RainDB(dbfile) read_queue = queue.TaskQueue(app) db.load_queue(read_queue) @@ -83,9 +80,8 @@ def test_db_queue(): def test_db_auto_mode(): with tempfile.TemporaryDirectory() as td: dbfile = os.path.join(td, 'raindb.json') - app = fixtures.TestingApp(dbfile) - db = database.RainDB(app) + db = database.RainDB(dbfile) assert db.get_auto_mode() is False db.set_auto_mode(True) @@ -93,5 +89,5 @@ def test_db_auto_mode(): # Reopen database db.close() - db = database.RainDB(app) + db = database.RainDB(dbfile) assert db.get_auto_mode() is True diff --git a/tests/test_output.py b/tests/test_output.py index b3b2eed..1163240 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,18 +1,19 @@ +# coding=utf-8 from typing import Optional import time import defer from unittest import mock from tests import fixtures -from tsgrain_controller import output, queue +from tsgrain_controller import output, queue, models class _TaskHolder(queue.TaskHolder): - def __init__(self, task: Optional[queue.Task]): + def __init__(self, task: Optional[models.Task]): self.task = task - def get_current_task(self) -> Optional[queue.Task]: + def get_current_task(self) -> Optional[models.Task]: return self.task @@ -39,7 +40,7 @@ def test_zone_outputs(): 'LED_M_MAN': False, } - task_holder.task = queue.Task(queue.Source.MANUAL, 1, 100) + task_holder.task = models.Task(models.Source.MANUAL, 1, 100) time.sleep(0.01) assert io.outputs == { 'VALVE_1': True, @@ -95,11 +96,11 @@ def test_zone_led_blink(): op.start() defer.defer(op.stop) - task_holder.task = queue.Task(queue.Source.MANUAL, 1, 60) + task_holder.task = models.Task(models.Source.MANUAL, 1, 60) time.sleep(0.005) assert get_blink_time('LED_Z_1') is None - task_holder.task = queue.Task(queue.Source.MANUAL, 1, 30) + task_holder.task = models.Task(models.Source.MANUAL, 1, 30) time.sleep(0.005) assert get_blink_time('LED_Z_1') == pytest.approx(250, 0.1) """ diff --git a/tests/test_queue.py b/tests/test_queue.py index ef8c989..8cff419 100644 --- a/tests/test_queue.py +++ b/tests/test_queue.py @@ -1,12 +1,13 @@ +# coding=utf-8 import time from datetime import datetime from tests import fixtures -from tsgrain_controller import queue, util +from tsgrain_controller import queue, util, models def test_task(): - task = queue.Task(queue.Source.MANUAL, 1, 10) + task = models.Task(models.Source.MANUAL, 1, 10) assert task.serialize() == { 'source': 'MANUAL', @@ -28,7 +29,7 @@ def test_task_started(mocker): mocker.patch('tsgrain_controller.util.datetime_now', return_value=datetime(2022, 1, 10, 6, 0)) - task = queue.Task(queue.Source.MANUAL, 1, 10) + task = models.Task(models.Source.MANUAL, 1, 10) task.start() assert task.serialize() == { @@ -51,9 +52,9 @@ def test_add_tasks(): app = fixtures.TestingApp() q = queue.TaskQueue(app) - task1 = queue.Task(queue.Source.MANUAL, 1, 10) - task1b = queue.Task(queue.Source.SCHEDULE, 1, 5) - task1c = queue.Task(queue.Source.MANUAL, 1, 5) + task1 = models.Task(models.Source.MANUAL, 1, 10) + task1b = models.Task(models.Source.SCHEDULE, 1, 5) + task1c = models.Task(models.Source.MANUAL, 1, 5) assert q.enqueue(task1) assert q.enqueue(task1b) @@ -64,9 +65,9 @@ def test_queue_runner(): app = fixtures.TestingApp() q = queue.TaskQueue(app) - task1 = queue.Task(queue.Source.MANUAL, 1, 1) - task2 = queue.Task(queue.Source.MANUAL, 2, 5) - task3 = queue.Task(queue.Source.SCHEDULE, 2, 10) + task1 = models.Task(models.Source.MANUAL, 1, 1) + task2 = models.Task(models.Source.MANUAL, 2, 5) + task3 = models.Task(models.Source.SCHEDULE, 2, 10) assert q.enqueue(task1) assert q.enqueue(task2) diff --git a/tests/test_util.py b/tests/test_util.py index 06a7e0d..3608edf 100644 --- a/tests/test_util.py +++ b/tests/test_util.py @@ -1,3 +1,4 @@ +# coding=utf-8 from dataclasses import dataclass from enum import Enum import datetime diff --git a/tsgrain_controller/__main__.py b/tsgrain_controller/__main__.py index a7b918c..e75be12 100644 --- a/tsgrain_controller/__main__.py +++ b/tsgrain_controller/__main__.py @@ -1,30 +1,78 @@ +import math import sys import signal +import curses +import logging -from tsgrain_controller import controller, queue, output, io, application +from tsgrain_controller import io, application + + +class CursesHandler(logging.Handler): + + def __init__(self, screen): + logging.Handler.__init__(self) + self.screen = screen + self.screen.scrollok(True) + self.screen.idlok(True) + self.screen.leaveok(True) + self.screen.refresh() + + def emit(self, record): + try: + msg = self.format(record) + screen = self.screen + fs = "\n%s" + screen.addstr(fs % msg) + screen.refresh() + + except (KeyboardInterrupt, SystemExit): + raise + + except: # noqa: E722 + self.handleError(record) def run(): - app = application.Application() - task_queue = queue.TaskQueue(app) - b_ctrl = controller.ButtonController(task_queue) + # Setup TUI windows + screen = curses.initscr() + curses.noecho() + curses.curs_set(0) + screen.nodelay(True) - t_io = io.PCIo(b_ctrl.cb_manual, app.cb_modekey, task_queue) - outputs = output.Outputs(t_io, task_queue, app) + max_y, max_x = screen.getmaxyx() + width_half = math.floor(max_x / 2) + win_ctrl = curses.newwin(max_y, width_half, 0, 0) + win_log = curses.newwin(max_y, width_half, 0, width_half) + + # Setup logger + logger = logging.getLogger() + logger.setLevel(logging.DEBUG) + + formatter_display = logging.Formatter( + '%(asctime)-8s|%(levelname)-5s| %(message)-s', '%H:%M:%S') + mh = CursesHandler(win_log) + mh.setFormatter(formatter_display) + logger.addHandler(mh) + + logging.info('Window size: max_x=%d, max_y=%d, width_half=%d', max_x, + max_y, width_half) + + t_io = io.PCIo(win_ctrl) + app = application.Application(t_io) def _signal_handler(sig, frame): - task_queue.stop() - outputs.stop() - t_io.stop() + app.stop() + + curses.echo() + curses.curs_set(1) + curses.endwin() print('Exited.') sys.exit(0) signal.signal(signal.SIGINT, _signal_handler) - t_io.start() - outputs.start() - task_queue.start() + app.start() signal.pause() diff --git a/tsgrain_controller/application.py b/tsgrain_controller/application.py index d951d5d..1ecb84c 100644 --- a/tsgrain_controller/application.py +++ b/tsgrain_controller/application.py @@ -1,14 +1,32 @@ -from tsgrain_controller import util, config +# coding=utf-8 +import logging + +from tsgrain_controller import config, database, queue, controller, output, io, \ + jobschedule, util class Application(util.AppInterface): - def __init__(self): - self.auto_en = False - + def __init__(self, t_io: io.Io): self.cfg = config.Config('tsgrain.toml') self.cfg.load_file() + self.db = database.RainDB(self.cfg.db_path) + + self.queue = queue.TaskQueue(self) + self.db.load_queue(self.queue) + + b_ctrl = controller.ButtonController(self.queue) + + self.io = t_io + self.io.set_callbacks(b_ctrl.cb_manual, self.cb_modekey) + + self.outputs = output.Outputs(self.io, self.queue, self) + + self.scheduler = jobschedule.Scheduler(self.db, self.queue) + + self.auto_en = self.db.get_auto_mode() + def is_auto_enabled(self) -> bool: return self.auto_en @@ -17,3 +35,24 @@ class Application(util.AppInterface): def cb_modekey(self): self.auto_en = not self.auto_en + + if self.auto_en: + logging.info('Auto mode ON') + else: + logging.info('Auto mode OFF') + + def start(self): + self.io.start() + self.outputs.start() + self.queue.start() + self.scheduler.start() + + def stop(self): + self.scheduler.stop() + self.queue.stop() + self.outputs.stop() + self.io.stop() + + # Store application state + self.db.store_queue(self.queue) + self.db.set_auto_mode(self.auto_en) diff --git a/tsgrain_controller/config.py b/tsgrain_controller/config.py index a0f578e..456c44c 100644 --- a/tsgrain_controller/config.py +++ b/tsgrain_controller/config.py @@ -1,3 +1,4 @@ +# coding=utf-8 import cyra diff --git a/tsgrain_controller/controller.py b/tsgrain_controller/controller.py index 0eba184..55a3cc1 100644 --- a/tsgrain_controller/controller.py +++ b/tsgrain_controller/controller.py @@ -1,6 +1,7 @@ +# coding=utf-8 from typing import Optional, Callable -from tsgrain_controller import queue +from tsgrain_controller import queue, models class ButtonController: @@ -13,15 +14,21 @@ class ButtonController: current_task = self.task_queue.get_current_task() # Cancel manually started tasks if current_task is not None \ - and current_task.zone_id == zone_id and current_task.source == queue.Source.MANUAL: + and current_task.zone_id == zone_id and current_task.source == models.Source.MANUAL: self.task_queue.cancel_current_task() else: - task = queue.Task(queue.Source.MANUAL, zone_id, 5) + task = models.Task(models.Source.MANUAL, zone_id, 5) self.task_queue.enqueue(task, True) class QueuingButtonController(ButtonController): def cb_manual(self, zone_id: int): - task = queue.Task(queue.Source.MANUAL, zone_id, 70) + # If a task from this zone is in queue, cancel it + for task in self.task_queue.tasks: + if task.zone_id == zone_id and task.source == models.Source.MANUAL: + self.task_queue.cancel_task(task) + return + + task = models.Task(models.Source.MANUAL, zone_id, 70) self.task_queue.enqueue(task) diff --git a/tsgrain_controller/database.py b/tsgrain_controller/database.py index fe5c7bb..10ae766 100644 --- a/tsgrain_controller/database.py +++ b/tsgrain_controller/database.py @@ -1,13 +1,13 @@ +# coding=utf-8 import tinydb from typing import Dict, Optional -from tsgrain_controller import util, schedule, queue +from tsgrain_controller import queue, util, models class RainDB: - def __init__(self, app: util.AppInterface): - self._db = tinydb.TinyDB(app.get_cfg().db_path, - default=util.serializer) + def __init__(self, db_path: str): + self._db = tinydb.TinyDB(db_path, default=util.serializer) self._jobs = self._db.table('jobs', cache_size=0) self._tasks = self._db.table('tasks', cache_size=0) @@ -17,18 +17,18 @@ class RainDB: """Die Datenbank schließen""" self._db.close() - def get_jobs(self) -> Dict[int, schedule.Job]: + def get_jobs(self) -> Dict[int, models.Job]: """ Gibt alle gespeicherten Bewässerungsjobs zurück - :return: Bewässerungsjobs: dict(id -> Job) + :return: Bewässerungsjobs: dict(id -> models.Job) """ res = dict() for job_data in self._jobs.all(): - res[job_data.doc_id] = schedule.Job.deserialize(job_data) + res[job_data.doc_id] = models.Job.deserialize(job_data) return res - def get_job(self, job_id: int) -> Optional[schedule.Job]: + def get_job(self, job_id: int) -> Optional[models.Job]: """ Gibt den Bewässerungsjob mit der gegebenen ID zurück @@ -39,9 +39,9 @@ class RainDB: if job is None: return None - return schedule.Job.deserialize(job) + return models.Job.deserialize(job) - def insert_job(self, job: schedule.Job): + def insert_job(self, job: models.Job): """ Fügt der Datenbank einen neuen Bewässerungsjob hinzu @@ -49,7 +49,7 @@ class RainDB: """ self._jobs.insert(util.serializer(job)) - def update_job(self, job_id: int, job: schedule.Job): + def update_job(self, job_id: int, job: models.Job): """ Aktualisiert einen Bewässerungsjob @@ -83,7 +83,7 @@ class RainDB: :param task_queue: Warteschlange """ for task_data in self._tasks.all(): - task = queue.Task.deserialize(task_data) + task = models.Task.deserialize(task_data) task_queue.tasks.append(task) self.empty_queue() diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py index 267b6f7..721e097 100644 --- a/tsgrain_controller/io.py +++ b/tsgrain_controller/io.py @@ -1,12 +1,14 @@ # coding=utf-8 from typing import Callable, Optional, Dict -import curses -import time from tsgrain_controller import util class Io: + def set_callbacks(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + pass + def start(self): pass @@ -19,22 +21,22 @@ class Io: class PCIo(util.StoppableThread, Io): - def __init__(self, cb_manual: Optional[Callable[[int], None]], - cb_mode: Optional[Callable[[], None]], task_queue): - super().__init__() - self.cb_manual = cb_manual - self.cb_mode = cb_mode - self.task_queue = task_queue + def __init__(self, screen): + super().__init__(0.01) + self.cb_manual: Optional[Callable[[int], None]] = None + self.cb_mode: Optional[Callable[[], None]] = None - self._screen: Optional = None + self._screen = screen self._outputs: Dict[str, bool] = dict() + def set_callbacks(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + self.cb_manual = cb_manual + self.cb_mode = cb_mode + def setup(self): - self._screen = curses.initscr() self._screen.nodelay(True) - curses.noecho() - curses.curs_set(0) def _trigger_cb_manual(self, zone_id: int): if self.cb_manual is not None: @@ -71,16 +73,3 @@ class PCIo(util.StoppableThread, Io): for key, output in self._outputs.items(): self._screen.addstr(i, 0, '%s: %s' % (key, state_str(output))) i += 1 - - i += 1 - - for task in self.task_queue._tasks: - self._screen.addstr(i, 0, str(task)) - i += 1 - - time.sleep(0.01) - - def cleanup(self): - curses.echo() - curses.curs_set(1) - curses.endwin() diff --git a/tsgrain_controller/models.py b/tsgrain_controller/models.py new file mode 100644 index 0000000..15d94c3 --- /dev/null +++ b/tsgrain_controller/models.py @@ -0,0 +1,164 @@ +# coding=utf-8 +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta +from enum import Enum +from typing import List, Optional, Dict, Any + +from tsgrain_controller import util + + +@dataclass +class Job: + date: datetime + duration: int + zones: List[int] + enable: bool + repeat: bool + + @property + def is_active(self) -> bool: + if not self.enable: + return False + if self.repeat: + return True + + return self.date > util.datetime_now() + + def check(self, date_now: datetime) -> bool: + if not self.enable: + return False + + if self.repeat: + check_datetime = datetime.combine(date_now.date(), + self.date.time()) + else: + check_datetime = self.date + + return date_now - check_datetime < timedelta(minutes=1) + + @classmethod + def deserialize(cls, data: dict) -> 'Job': + return cls( + date=datetime.fromisoformat(data['date']), + duration=data['duration'], + zones=data['zones'], + enable=data['zones'], + repeat=data['repeat'], + ) + + +class Source(Enum): + MANUAL = 2 + SCHEDULE = 1 + + +@dataclass +class Task: + source: Source + """Quelle des Tasks (Manuell/Zeitplan)""" + + zone_id: int + """Nummer der Zone""" + + duration: int + """Beregnungsdauer in Sekunden""" + + _remaining: int = 0 + """Interne Variable, um die verbleibende Zeit eines gestoppten Tasks zu speichern""" + + _id: int = 0 + + datetime_started: Optional[datetime] = None + """Zeitpunkt, wann der Task gestartet wurde""" + + def __post_init__(self): + self._remaining = self.duration + self._id = uuid.uuid1().int + + @property + def is_running(self) -> bool: + """ + :return: True falls der Task momentan läuft + """ + return self.datetime_started is not None + + @property + def remaining(self) -> int: + """ + :return: Verbleibende Zeit in Sekunden + """ + if not self.is_running: + return self._remaining + + d = self.datetime_finished - util.datetime_now() + return d.seconds + + @property + def is_done(self) -> bool: + """ + :return: True wenn der Task bereits abgeschlossen ist. + """ + return self.remaining <= 0 + + @property + def datetime_finished(self) -> Optional[datetime]: + """ + :return: Zeitpunkt, zu dem der Task abgeschlossen sein wird. + None falls der Task momentan nicht läuft. + """ + if self.datetime_started is None: + return None + + return self.datetime_started + timedelta(seconds=self._remaining) + + def start(self): + """Startet den Task zur aktuellen Zeit.""" + if self.is_running: + raise util.TaskRunException('already running') + self.datetime_started = util.datetime_now() + + def stop(self): + """Stoppt den Task und speichert die verbleibende Zeit""" + if not self.is_running: + raise util.TaskRunException('not running') + + self._remaining = self.remaining + self.datetime_started = None + + def serialize(self) -> Dict[str, Any]: + """Task zur Speicherung in der Datenbank in dict umwandeln.""" + return { + 'source': self.source.name, + 'zone_id': self.zone_id, + 'duration': self.duration, + 'remaining': self.remaining + } + + def serialize_rpc(self) -> Dict[str, Any]: + """Task zur aktuellen Statusübertragung in dict umwandeln.""" + return { + 'source': self.source.name, + 'zone_id': self.zone_id, + 'duration': self.duration, + 'datetime_started': self.datetime_started, + 'datetime_finished': self.datetime_finished + } + + @classmethod + def deserialize(cls, data: dict) -> 'Task': + task = cls(source=Source[data['source']], + zone_id=data['zone_id'], + duration=data['duration']) + task._remaining = data['remaining'] + return task + + def __eq__(self, other: 'Task') -> bool: + return self._id == other._id + + def __hash__(self) -> int: + return hash(self._id) + + def __str__(self): + return 'ZONE %d: %ds (%s)' % (self.zone_id, self.duration, + self.source.name) diff --git a/tsgrain_controller/output.py b/tsgrain_controller/output.py index b62c995..87a694b 100644 --- a/tsgrain_controller/output.py +++ b/tsgrain_controller/output.py @@ -1,7 +1,6 @@ from dataclasses import dataclass -import time from typing import Dict -from tsgrain_controller import io, util, queue +from tsgrain_controller import io, util, queue, models @dataclass @@ -54,7 +53,7 @@ class Outputs(util.StoppableThread): def __init__(self, o_io: io.Io, task_holder: queue.TaskHolder, app: util.AppInterface): - super().__init__() + super().__init__(0.01) self.task_holder = task_holder self.app = app @@ -137,14 +136,13 @@ class Outputs(util.StoppableThread): task = self.task_holder.get_current_task() if task is not None: self._set_zone_time(task.zone_id, task.remaining) - self._set_mode_led_manual(task.source == queue.Source.MANUAL) + self._set_mode_led_manual(task.source == models.Source.MANUAL) self._set_mode_led_auto(self.app.is_auto_enabled(), - task.source == queue.Source.SCHEDULE) + task.source == models.Source.SCHEDULE) else: self._set_mode_led_auto(self.app.is_auto_enabled(), False) self._update_states() - time.sleep(0.01) def setup(self): self.reset() diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index 0ad3310..d771738 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -1,148 +1,25 @@ # coding=utf-8 -from dataclasses import dataclass -from enum import Enum from typing import Optional, List, Dict, Any -from datetime import datetime, timedelta -import time -import uuid -from tsgrain_controller import util - - -class ZoneUnavailableException(Exception): - pass - - -class TaskRunException(Exception): - pass - - -class Source(Enum): - MANUAL = 2 - SCHEDULE = 1 - - -@dataclass -class Task: - source: Source - """Quelle des Tasks (Manuell/Zeitplan)""" - - zone_id: int - """Nummer der Zone""" - - duration: int - """Beregnungsdauer in Sekunden""" - - _remaining: int = 0 - """Interne Variable, um die verbleibende Zeit eines gestoppten Tasks zu speichern""" - - datetime_started: Optional[datetime] = None - """Zeitpunkt, wann der Task gestartet wurde""" - - def __post_init__(self): - self._remaining = self.duration - self._id = uuid.uuid1() - - @property - def is_running(self) -> bool: - """ - :return: True falls der Task momentan läuft - """ - return self.datetime_started is not None - - @property - def remaining(self) -> int: - """ - :return: Verbleibende Zeit in Sekunden - """ - if not self.is_running: - return self._remaining - - d = self.datetime_finished - util.datetime_now() - return d.seconds - - @property - def is_done(self) -> bool: - """ - :return: True wenn der Task bereits abgeschlossen ist. - """ - return self.remaining <= 0 - - @property - def datetime_finished(self) -> Optional[datetime]: - """ - :return: Zeitpunkt, zu dem der Task abgeschlossen sein wird. - None falls der Task momentan nicht läuft. - """ - if self.datetime_started is None: - return None - - return self.datetime_started + timedelta(seconds=self._remaining) - - def start(self): - """Startet den Task zur aktuellen Zeit.""" - if self.is_running: - raise TaskRunException('already running') - self.datetime_started = util.datetime_now() - - def stop(self): - """Stoppt den Task und speichert die verbleibende Zeit""" - if not self.is_running: - raise TaskRunException('not running') - - self._remaining = self.remaining - self.datetime_started = None - - def serialize(self) -> Dict[str, Any]: - """Task zur Speicherung in der Datenbank in dict umwandeln.""" - return { - 'source': self.source.name, - 'zone_id': self.zone_id, - 'duration': self.duration, - 'remaining': self.remaining - } - - def serialize_rpc(self) -> Dict[str, Any]: - """Task zur aktuellen Statusübertragung in dict umwandeln.""" - return { - 'source': self.source.name, - 'zone_id': self.zone_id, - 'duration': self.duration, - 'datetime_started': self.datetime_started, - 'datetime_finished': self.datetime_finished - } - - @classmethod - def deserialize(cls, data: dict) -> 'Task': - return cls( - source=Source[data['source']], - zone_id=data['zone_id'], - duration=data['duration'], - _remaining=data['remaining'], - ) - - def __eq__(self, other: 'Task') -> bool: - return self._id == other._id - - def __hash__(self) -> int: - return hash(self._id) +import logging +from tsgrain_controller import util, models class TaskHolder: - def get_current_task(self) -> Optional[Task]: + def get_current_task(self) -> Optional[models.Task]: pass class TaskQueue(util.StoppableThread, TaskHolder): def __init__(self, app: util.AppInterface): - super().__init__() + super().__init__(0.1) self.app = app - self.tasks: List[Task] = list() - self.running_task: Optional[Task] = None + self.tasks: List[models.Task] = list() + self.running_task: Optional[models.Task] = None - def enqueue(self, task: Task, exclusive: bool = False) -> bool: + def enqueue(self, task: models.Task, exclusive: bool = False) -> bool: """ Fügt der Warteschlange einen neuen Task hinzu. Die Warteschlange kann nicht mehrere Tasks der selben Quelle und Zone aufnehmen. @@ -158,21 +35,33 @@ class TaskQueue(util.StoppableThread, TaskHolder): return False self.tasks.append(task) + logging.info('Task added to queue (%s)', task) return True - def get_current_task(self) -> Optional[Task]: + def get_current_task(self) -> Optional[models.Task]: """ Gib den aktuell laufenden Task zurück. :return: aktuell laufender Task """ return self.running_task - def cancel_current_task(self): - if self.running_task is not None: - self.tasks.remove(self.running_task) + def cancel_task(self, task: models.Task): + self.tasks.remove(task) + logging.info('Task cancelled (%s)', task) + if self.running_task == task: self.running_task = None - def serialize(self) -> List[Task]: + def cancel_current_task(self): + """ + Bricht den aktuell laufenden Task ab + (z.B. bei manuellem Stopp mittels Taster) + """ + if self.running_task is not None: + self.tasks.remove(self.running_task) + logging.info('Running task cancelled (%s)', self.running_task) + self.running_task = None + + def serialize(self) -> List[models.Task]: """Task zur Speicherung in der Datenbank in dict umwandeln.""" return self.tasks @@ -185,27 +74,28 @@ class TaskQueue(util.StoppableThread, TaskHolder): if self.running_task is None: for task in self.tasks: # Only start scheduled tasks if automatic mode is enabled - if task.source == Source.SCHEDULE and not self.app.is_auto_enabled( + if task.source == models.Source.SCHEDULE and not self.app.is_auto_enabled( ): continue self.running_task = task self.running_task.start() + logging.info('Queued task started (%s)', self.running_task) break # Check currently running task if self.running_task is not None: # Stop scheduled tasks if auto mode is disabled - if self.running_task.source == Source.SCHEDULE and not self.app.is_auto_enabled( + if self.running_task.source == models.Source.SCHEDULE and not self.app.is_auto_enabled( ): self.running_task.stop() + logging.info('Running task stopped (%s)', self.running_task) self.running_task = None elif self.running_task.is_done: self.tasks.remove(self.running_task) + logging.info('Running task done (%s)', self.running_task) self.running_task = None - time.sleep(0.1) - def cleanup(self): self.running_task.stop() self.running_task = None diff --git a/tsgrain_controller/util.py b/tsgrain_controller/util.py index c239bde..e25ba05 100644 --- a/tsgrain_controller/util.py +++ b/tsgrain_controller/util.py @@ -87,8 +87,9 @@ def datetime_new(year, class StoppableThread(threading.Thread): - def __init__(self): + def __init__(self, interval: float = 1): super().__init__() + self._interval = interval self._stop_signal = threading.Event() def setup(self): @@ -105,6 +106,7 @@ class StoppableThread(threading.Thread): while not self._stop_signal.is_set(): self.run_cycle() + time.sleep(self._interval) self.cleanup() @@ -120,3 +122,11 @@ class AppInterface: def get_cfg(self) -> config.Config: pass + + +class ZoneUnavailableException(Exception): + pass + + +class TaskRunException(Exception): + pass From 7d72045d4f7539a74a9bf9cb8a2cb856b57e2bcd Mon Sep 17 00:00:00 2001 From: Theta-Dev Date: Thu, 27 Jan 2022 23:21:27 +0100 Subject: [PATCH 11/11] add jobschedule, MCP23017 library --- requirements.txt | 7 +- setup.py | 1 + tsgrain_controller/__main__.py | 66 +---- tsgrain_controller/application.py | 16 +- tsgrain_controller/config.py | 56 +++- tsgrain_controller/controller.py | 12 +- tsgrain_controller/io.py | 75 ----- tsgrain_controller/io/__init__.py | 32 +++ tsgrain_controller/io/console.py | 113 ++++++++ tsgrain_controller/io/mcp23017.py | 448 ++++++++++++++++++++++++++++++ tsgrain_controller/jobschedule.py | 38 +++ tsgrain_controller/queue.py | 10 +- tsgrain_controller/schedule.py | 33 --- tsgrain_controller/util.py | 10 +- 14 files changed, 730 insertions(+), 187 deletions(-) delete mode 100644 tsgrain_controller/io.py create mode 100644 tsgrain_controller/io/__init__.py create mode 100644 tsgrain_controller/io/console.py create mode 100644 tsgrain_controller/io/mcp23017.py create mode 100644 tsgrain_controller/jobschedule.py delete mode 100644 tsgrain_controller/schedule.py diff --git a/requirements.txt b/requirements.txt index 3646272..0185ff4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ -tinydb -cyra -schedule +tinydb~=4.6.1 +cyra~=1.0.2 +schedule~=1.1.0 +smbus~=1.1.post2 diff --git a/setup.py b/setup.py index 3509b76..3fcc186 100644 --- a/setup.py +++ b/setup.py @@ -24,6 +24,7 @@ setuptools.setup( install_requires=[ 'tinydb', 'cyra', + 'schedule', ], packages=setuptools.find_packages(exclude=['tests*']), entry_points={ diff --git a/tsgrain_controller/__main__.py b/tsgrain_controller/__main__.py index e75be12..1a9e4ae 100644 --- a/tsgrain_controller/__main__.py +++ b/tsgrain_controller/__main__.py @@ -1,72 +1,22 @@ -import math -import sys import signal -import curses -import logging +import sys -from tsgrain_controller import io, application - - -class CursesHandler(logging.Handler): - - def __init__(self, screen): - logging.Handler.__init__(self) - self.screen = screen - self.screen.scrollok(True) - self.screen.idlok(True) - self.screen.leaveok(True) - self.screen.refresh() - - def emit(self, record): - try: - msg = self.format(record) - screen = self.screen - fs = "\n%s" - screen.addstr(fs % msg) - screen.refresh() - - except (KeyboardInterrupt, SystemExit): - raise - - except: # noqa: E722 - self.handleError(record) +from tsgrain_controller import application def run(): - # Setup TUI windows - screen = curses.initscr() - curses.noecho() - curses.curs_set(0) - screen.nodelay(True) + console_flag = False - max_y, max_x = screen.getmaxyx() - width_half = math.floor(max_x / 2) - win_ctrl = curses.newwin(max_y, width_half, 0, 0) - win_log = curses.newwin(max_y, width_half, 0, width_half) + if len(sys.argv) > 1: + x = sys.argv[1] + if x.startswith('c'): + console_flag = True - # Setup logger - logger = logging.getLogger() - logger.setLevel(logging.DEBUG) - - formatter_display = logging.Formatter( - '%(asctime)-8s|%(levelname)-5s| %(message)-s', '%H:%M:%S') - mh = CursesHandler(win_log) - mh.setFormatter(formatter_display) - logger.addHandler(mh) - - logging.info('Window size: max_x=%d, max_y=%d, width_half=%d', max_x, - max_y, width_half) - - t_io = io.PCIo(win_ctrl) - app = application.Application(t_io) + app = application.Application(console_flag) def _signal_handler(sig, frame): app.stop() - curses.echo() - curses.curs_set(1) - curses.endwin() - print('Exited.') sys.exit(0) diff --git a/tsgrain_controller/application.py b/tsgrain_controller/application.py index 1ecb84c..e0819ee 100644 --- a/tsgrain_controller/application.py +++ b/tsgrain_controller/application.py @@ -1,13 +1,16 @@ # coding=utf-8 import logging -from tsgrain_controller import config, database, queue, controller, output, io, \ - jobschedule, util +from tsgrain_controller import config, controller, database, io, jobschedule, output, \ + queue, util class Application(util.AppInterface): - def __init__(self, t_io: io.Io): + def __init__(self, console_flag: bool): + self.logger = logging.getLogger() + self.logger.setLevel(logging.DEBUG) + self.cfg = config.Config('tsgrain.toml') self.cfg.load_file() @@ -16,9 +19,9 @@ class Application(util.AppInterface): self.queue = queue.TaskQueue(self) self.db.load_queue(self.queue) - b_ctrl = controller.ButtonController(self.queue) + b_ctrl = controller.ButtonController(self.queue, self.cfg) - self.io = t_io + self.io = io.new_io(self, console_flag) self.io.set_callbacks(b_ctrl.cb_manual, self.cb_modekey) self.outputs = output.Outputs(self.io, self.queue, self) @@ -33,6 +36,9 @@ class Application(util.AppInterface): def get_cfg(self) -> config.Config: return self.cfg + def get_logger(self) -> logging.Logger: + return self.logger + def cb_modekey(self): self.auto_en = not self.auto_en diff --git a/tsgrain_controller/config.py b/tsgrain_controller/config.py index 456c44c..6975be4 100644 --- a/tsgrain_controller/config.py +++ b/tsgrain_controller/config.py @@ -5,8 +5,62 @@ import cyra class Config(cyra.Config): builder = cyra.ConfigBuilder() - builder.comment('Anzahl Bewässerungszonen') + builder.comment('Anzahl Bewaesserungszonen') n_zones = builder.define('n_zones', 7) builder.comment('Pfad der Datenbankdatei') db_path = builder.define('db_path', 'raindb.json') + + builder.comment('Manuelle Bewaesserungszeit in Sekunden') + manual_time = builder.define('manual_time', 300) + + builder.comment('Debug-Ausgaben loggen') + log_debug = builder.define('log_debug', False) + + builder.push('io') + + builder.comment('ID des I2C-Bus') + i2c_bus_id = builder.define('i2c_bus_id', 0) + + builder.comment( + 'GPIO-Pin, mit dem der Interrupt-Pin des MCP23017 verbunden ist') + gpio_interrupt = builder.define('gpio_interrupt', 17) + + builder.comment('Entprellzeit in Sekunden') + gpio_delay = builder.define('gpio_delay', 0.05) + + builder.pop() + + builder.comment('Ausgaenge') + output_devices = builder.define( + 'output_devices', { + 'VALVE_1': '0x27/B0/!', + 'VALVE_2': '0x27/B1/!', + 'VALVE_3': '0x27/B2/!', + 'VALVE_4': '0x27/B3/!', + 'VALVE_5': '0x27/B4/!', + 'VALVE_6': '0x27/B5/!', + 'VALVE_7': '0x27/B6/!', + 'LED_Z_1': '0x27/A0', + 'LED_Z_2': '0x27/A1', + 'LED_Z_3': '0x27/A2', + 'LED_Z_4': '0x27/A3', + 'LED_Z_5': '0x27/A4', + 'LED_Z_6': '0x27/A5', + 'LED_Z_7': '0x27/A6', + 'LED_M_AUTO': '0x23/B0', + 'LED_M_MAN': '0x23/B1', + }) + + builder.comment('Eingaenge') + input_devices = builder.define( + 'input_devices', { + 'BT_Z_1': '0x23/A0/!', + 'BT_Z_2': '0x23/A1/!', + 'BT_Z_3': '0x23/A2/!', + 'BT_Z_4': '0x23/A3/!', + 'BT_Z_5': '0x23/A4/!', + 'BT_Z_6': '0x23/A5/!', + 'BT_Z_7': '0x23/A6/!', + 'BT_MODE': '0x23/A7/!', + }) diff --git a/tsgrain_controller/controller.py b/tsgrain_controller/controller.py index 55a3cc1..7d686f4 100644 --- a/tsgrain_controller/controller.py +++ b/tsgrain_controller/controller.py @@ -1,13 +1,14 @@ # coding=utf-8 -from typing import Optional, Callable +from typing import Callable, Optional -from tsgrain_controller import queue, models +from tsgrain_controller import config, models, queue class ButtonController: - def __init__(self, task_queue: queue.TaskQueue): + def __init__(self, task_queue: queue.TaskQueue, cfg: config.Config): self.task_queue = task_queue + self.cfg = cfg self.cb_error: Optional[Callable[[], None]] = None def cb_manual(self, zone_id: int): @@ -17,7 +18,8 @@ class ButtonController: and current_task.zone_id == zone_id and current_task.source == models.Source.MANUAL: self.task_queue.cancel_current_task() else: - task = models.Task(models.Source.MANUAL, zone_id, 5) + task = models.Task(models.Source.MANUAL, zone_id, + self.cfg.manual_time) self.task_queue.enqueue(task, True) @@ -30,5 +32,5 @@ class QueuingButtonController(ButtonController): self.task_queue.cancel_task(task) return - task = models.Task(models.Source.MANUAL, zone_id, 70) + task = models.Task(models.Source.MANUAL, zone_id, self.cfg.manual_time) self.task_queue.enqueue(task) diff --git a/tsgrain_controller/io.py b/tsgrain_controller/io.py deleted file mode 100644 index 721e097..0000000 --- a/tsgrain_controller/io.py +++ /dev/null @@ -1,75 +0,0 @@ -# coding=utf-8 -from typing import Callable, Optional, Dict -from tsgrain_controller import util - - -class Io: - - def set_callbacks(self, cb_manual: Optional[Callable[[int], None]], - cb_mode: Optional[Callable[[], None]]): - pass - - def start(self): - pass - - def stop(self): - pass - - def write_output(self, key: str, val: bool): - pass - - -class PCIo(util.StoppableThread, Io): - - def __init__(self, screen): - super().__init__(0.01) - self.cb_manual: Optional[Callable[[int], None]] = None - self.cb_mode: Optional[Callable[[], None]] = None - - self._screen = screen - - self._outputs: Dict[str, bool] = dict() - - def set_callbacks(self, cb_manual: Optional[Callable[[int], None]], - cb_mode: Optional[Callable[[], None]]): - self.cb_manual = cb_manual - self.cb_mode = cb_mode - - def setup(self): - self._screen.nodelay(True) - - def _trigger_cb_manual(self, zone_id: int): - if self.cb_manual is not None: - self.cb_manual(zone_id) - - def _trigger_cb_mode(self): - if self.cb_mode is not None: - self.cb_mode() - - def write_output(self, key: str, val: bool): - self._outputs[key] = val - - def run_cycle(self): - c = self._screen.getch() - - def state_str(state: bool) -> str: - if state: - return '●' - else: - return '○' - - # Mode key (0) - if c == 48: - self._trigger_cb_mode() - # Zone keys (1-7) - elif 49 <= c <= 55: - self._trigger_cb_manual(c - 48) - - self._screen.erase() - self._screen.addstr(0, 0, - 'Buttons: 1-7: Manual control, 0: Auto on/off') - - i = 1 - for key, output in self._outputs.items(): - self._screen.addstr(i, 0, '%s: %s' % (key, state_str(output))) - i += 1 diff --git a/tsgrain_controller/io/__init__.py b/tsgrain_controller/io/__init__.py new file mode 100644 index 0000000..1ad3ac0 --- /dev/null +++ b/tsgrain_controller/io/__init__.py @@ -0,0 +1,32 @@ +# coding=utf-8 +from typing import Callable, Optional + +from tsgrain_controller import util + + +class Io: + + def set_callbacks(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + pass + + def start(self): + pass + + def stop(self): + pass + + def write_output(self, key: str, val: bool): + pass + + +def new_io(app: util.AppInterface, console_flag: bool) -> Io: + if not console_flag: + try: + from tsgrain_controller.io import mcp23017 as io_mod + except ImportError: + from tsgrain_controller.io import console as io_mod + else: + from tsgrain_controller.io import console as io_mod + + return io_mod.Io(app) diff --git a/tsgrain_controller/io/console.py b/tsgrain_controller/io/console.py new file mode 100644 index 0000000..5acbdb2 --- /dev/null +++ b/tsgrain_controller/io/console.py @@ -0,0 +1,113 @@ +# coding=utf-8 +import curses +import logging +import math +from typing import Callable, Dict, Optional + +from tsgrain_controller import io, util + + +class CursesHandler(logging.Handler): + + def __init__(self, screen): + logging.Handler.__init__(self) + self.screen = screen + self.screen.scrollok(True) + self.screen.idlok(True) + self.screen.leaveok(True) + self.screen.refresh() + + def emit(self, record): + try: + msg = self.format(record) + screen = self.screen + fs = "\n%s" + screen.addstr(fs % msg) + screen.refresh() + + except (KeyboardInterrupt, SystemExit): + raise + + except: # noqa: E722 + self.handleError(record) + + +class Io(util.StoppableThread, io.Io): + + def __init__(self, app: util.AppInterface): + super().__init__(0.01) + self.app = app + self.cb_manual: Optional[Callable[[int], None]] = None + self.cb_mode: Optional[Callable[[], None]] = None + + self._screen: Optional = None + self._outputs: Dict[str, bool] = dict() + + def set_callbacks(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + self.cb_manual = cb_manual + self.cb_mode = cb_mode + + def setup(self): + screen = curses.initscr() + curses.noecho() + curses.curs_set(0) + screen.nodelay(True) + + max_y, max_x = screen.getmaxyx() + width_half = math.floor(max_x / 2) + self._screen = curses.newwin(max_y, width_half, 0, 0) + win_log = curses.newwin(max_y, width_half, 0, width_half) + + formatter_display = logging.Formatter( + '%(asctime)-8s|%(levelname)-5s| %(message)-s', '%H:%M:%S') + mh = CursesHandler(win_log) + mh.setFormatter(formatter_display) + self.app.get_logger().handlers = [] + self.app.get_logger().addHandler(mh) + + self._screen.nodelay(True) + + logging.info('Window size: max_x=%d, max_y=%d, width_half=%d', max_x, + max_y, width_half) + + def _trigger_cb_manual(self, zone_id: int): + if self.cb_manual is not None: + self.cb_manual(zone_id) + + def _trigger_cb_mode(self): + if self.cb_mode is not None: + self.cb_mode() + + def write_output(self, key: str, val: bool): + self._outputs[key] = val + + def run_cycle(self): + c = self._screen.getch() + + def state_str(state: bool) -> str: + if state: + return '●' + else: + return '○' + + # Mode key (0) + if c == 48: + self._trigger_cb_mode() + # Zone keys (1-7) + elif 49 <= c <= 55: + self._trigger_cb_manual(c - 48) + + self._screen.erase() + self._screen.addstr(0, 0, + 'Buttons: 1-7: Manual control, 0: Auto on/off') + + i = 1 + for key, output in self._outputs.items(): + self._screen.addstr(i, 0, '%s: %s' % (key, state_str(output))) + i += 1 + + def cleanup(self): + curses.echo() + curses.curs_set(1) + curses.endwin() diff --git a/tsgrain_controller/io/mcp23017.py b/tsgrain_controller/io/mcp23017.py new file mode 100644 index 0000000..d52d128 --- /dev/null +++ b/tsgrain_controller/io/mcp23017.py @@ -0,0 +1,448 @@ +# coding=utf-8 +import logging +import time +from dataclasses import dataclass +from enum import Enum +from typing import Callable, Dict, Optional, Tuple + +import RPi.GPIO as GPIO +import smbus + +from tsgrain_controller import io, util + +# MCP-Register +# Quelle: https://ww1.microchip.com/downloads/en/devicedoc/20001952c.pdf +# Seite 16, Table 3-3, 3-5 + +MCP_IODIRA = 0x00 +""" +I/O-Modus pro GPIOA-Pin (standardmäßig ``1`` / Eingang) + +- ``1`` Eingang +- ``0`` Ausgang +""" + +MCP_IODIRB = 0x01 +""" +I/O-Modus pro GPIOB-Pin (standardmäßig ``1`` / Eingang) + +- ``1`` Eingang +- ``0`` Ausgang +""" + +MCP_IPOLA = 0x02 +""" +Polarität pro GPIOA-Pin. + +- ``1`` Low wenn aktiv +- ``0`` High wenn aktiv + +Die Einstellung hatte im Test nur bei als Eingängen konfigurierten Pins +einen Effekt. Bei Ausgängen wird stattdessen der gesetzte Wert invertiert. +""" + +MCP_IPOLB = 0x03 +"""Polarität pro GPIOB-Pin. Invertiert wenn Bit gesetzt.""" + +MCP_GPINTENA = 0x04 +""" +Aktiviert einen Interrupt bei Zustandswechsel eines GPIOA-Pins. + +Das Auslöen von Interrupts +erfordert das Setzen der Register ``DEFVAL`` und ``INTCON``. +""" + +MCP_GPINTENB = 0x05 +""" +Aktiviert einen Interrupt bei Zustandswechsel eines GPIOB-Pins. + +Das Auslöen von Interrupts +erfordert das Setzen der Register ``DEFVAL`` und ``INTCON``. +""" + +MCP_DEFVALA = 0x06 +""" +Vergleichsregister für Interrupt bei Zustandswechsel eines GPIOA-Pins. + +Wenn mittels ``GPINTEN`` und ``INTCON``-Register ein vergleichender +Interrupt konfiguriert wurde, verursacht der Wechsel des Pin-Zustands +auf den gegenteiligen Wert von ``DEFVAL`` einen Interrupt. +""" + +MCP_DEFVALB = 0x07 +""" +Vergleichsregister für Interrupt bei Zustandswechsel eines GPIOB-Pins. + +Wenn mittels ``GPINTEN`` und ``INTCON``-Register ein vergleichender +Interrupt konfiguriert wurde, verursacht der Wechsel des Pin-Zustands +auf den gegenteiligen Wert von ``DEFVAL`` einen Interrupt. +""" + +MCP_INTCONA = 0x08 +""" +Wahl des Interruptmodus für GPIOA. + +- ``1`` Der Wert des Pins wird mit dem entsprechenden Bit im ``DEFVAL``-Register + verglichen. Beim gegenteiligen Wert erfolgt ein Interrupt. + +- ``0`` Ein Interrupt wird bei jeder Zustandsänderung des Pins ausgelöst. +""" + +MCP_INTCONB = 0x09 +""" +Wahl des Interruptmodus für GPIOB. + +- ``1`` Der Wert des Pins wird mit dem entsprechenden Bit im ``DEFVAL``-Register + verglichen. Beim gegenteiligen Wert erfolgt ein Interrupt. + +- ``0`` Ein Interrupt wird bei jeder Zustandsänderung des Pins ausgelöst. +""" + +MCP_IOCON = 0x0a +""" +Konfiguration für den I2C-Portexpander. + +Bit ``7``: **BANK** + Ändert die Adressierung der Register. Dieses Programm verwendet die + Standardkonfiguration 0, also nicht ändern. + +Bit ``6``: **MIRROR** + Wenn gesetzt, sind beide Interrupt-Pins ``INTA`` und ``INTB`` miteinander + verodert, d.h. ein Interrupt auf einem der zwei Ports aktiviert beide Pins. + +Bit ``5``: **SEQOP** + Sequenzieller Modus, inkrementiert den Adresszähler bei jedem Zugriff. + + - ``1`` Sequenzieller Modus deaktiviert + - ``0`` Sequenzieller Modus aktiviert + +Bit ``4``: **DISSLW** + Slew-Rate control + +Bit ``3``: nicht implementiert + +Bit ``2``: **ODR** + Konfiguriert den Interruptpin als Open-Drain-Ausgang, wenn gesetzt. + Wenn nicht gesetzt, ist der Interruptpin ein aktiv treibender Ausgang + mit der in Bit ``1`` festgelegten Polarität. + +Bit ``1``: **INTPOL** + Polarität des Interrupt-Pins + + - ``1`` High wenn aktiv + - ``0`` Low wenn aktiv + +Bit ``0``: nicht implementiert +""" + +MCP_GPPUA = 0x0c +"""GPIOA-Pullup-Widerstandsregister. 100k-Pullup aktiviert, wenn Bit gesetzt.""" + +MCP_GPPUB = 0x0d +"""GPIOB-Pullup-Widerstandsregister. 100k-Pullup aktiviert, wenn Bit gesetzt.""" + +MCP_INTFA = 0x0e +""" +Interrupt-Flag-Register. Wurde ein Interrupt auf GPIOA ausgelöst, ist +das entsprechende Bit in diesem Register gesetzt. Read-only. +""" + +MCP_INTFB = 0x0f +""" +Interrupt-Flag-Register. Wurde ein Interrupt auf GPIOB ausgelöst, ist +das entsprechende Bit in diesem Register gesetzt. Read-only. +""" + +MCP_INTCAPA = 0x10 +""" +Interrupt-Capture-Register. Wurde ein Interrupt auf GPIOA ausgelöst, +speichert dieses Register den entsprechenden Wert. +""" + +MCP_INTCAPB = 0x11 +""" +Interrupt-Capture-Register. Wurde ein Interrupt auf GPIOB ausgelöst, +speichert dieses Register den entsprechenden Wert. +""" + +MCP_GPIOA = 0x12 +""" +GPIOA-Portregister. Wert entspricht dem Zustand der GPIO-Pins. +Wird in dieses Register geschrieben, wird das ``OLAT``-Register verändert. +""" + +MCP_GPIOB = 0x13 +""" +GPIOA-Portregister. Wert entspricht dem Zustand der GPIO-Pins. +Wird in dieses Register geschrieben, wird das ``OLAT``-Register verändert. +""" + +MCP_OLATA = 0x14 +""" +GPIOA-Output-Latch-Register. Wert entspricht dem Zustand der Output-Latches, +die wiederum die als Output konfigurierten Pins ansteuern. +""" + +MCP_OLATB = 0x15 +""" +GPIOB-Output-Latch-Register. Wert entspricht dem Zustand der Output-Latches, +die wiederum die als Output konfigurierten Pins ansteuern. +""" + + +class PinConfigInvalid(Exception): + + def __init__(self, cfg_str: str): + super().__init__('MCP23017 pin config %s invalid' % cfg_str) + + +class _MCP23017Port(Enum): + A = 0 + B = 1 + + def reg(self, reg_a: int) -> int: + return reg_a + self.value + + +@dataclass +class _MCP23017Device: + i2c_address: int + port: _MCP23017Port + pin: int + invert: bool + + @classmethod + def from_config(cls, cfg_str: str): + cfg_parts = cfg_str.split('/') + if len(cfg_parts) < 2: + raise PinConfigInvalid(cfg_str) + + i2c_addr_str = cfg_parts[0] + port_str = cfg_parts[1][0] + pin_str = cfg_parts[1][1:] + + try: + i2c_addr = int(i2c_addr_str, 16) + except ValueError: + raise PinConfigInvalid(cfg_str) + + try: + port = _MCP23017Port[port_str] + except KeyError: + raise PinConfigInvalid(cfg_str) + + try: + pin = int(pin_str) + except ValueError: + raise PinConfigInvalid(cfg_str) + + invert = False + + if len(cfg_parts) >= 3: + attr_str = cfg_parts[2] + + invert = '!' in attr_str + + return cls(i2c_addr, port, pin, invert) + + +class Io(io.Io): + + def __init__(self, app: util.AppInterface): + self.cb_manual: Optional[Callable[[int], None]] = None + self.cb_mode: Optional[Callable[[], None]] = None + self.cfg = app.get_cfg() + + self.bus = smbus.SMBus(self.cfg.i2c_bus_id) + + self.i2c_cache: Dict[Tuple[int, int], int] = dict() + + self.mcp_addresses = set() + self.output_devices: Dict[str, _MCP23017Device] = dict() + self.input_devices: Dict[str, _MCP23017Device] = dict() + + # Parse config + for key, cfg_str in self.cfg.input_devices.items(): + device = _MCP23017Device.from_config(cfg_str) + self.mcp_addresses.add(device.i2c_address) + self.input_devices[key] = device + + for key, cfg_str in self.cfg.output_devices.items(): + device = _MCP23017Device.from_config(cfg_str) + self.mcp_addresses.add(device.i2c_address) + self.output_devices[key] = device + + def _trigger_cb_manual(self, zone_id: int): + if self.cb_manual is not None: + self.cb_manual(zone_id) + + def _trigger_cb_mode(self): + if self.cb_mode is not None: + self.cb_mode() + + def set_callbacks(self, cb_manual: Optional[Callable[[int], None]], + cb_mode: Optional[Callable[[], None]]): + self.cb_manual = cb_manual + self.cb_mode = cb_mode + + def _i2c_read_byte(self, + i2c_address: int, + register: int, + use_cache=False) -> int: + key = (i2c_address, register) + + if use_cache and key in self.i2c_cache: + return self.i2c_cache[key] + + data = self.bus.read_byte_data(i2c_address, register) + + if use_cache: + self.i2c_cache[key] = data + + return data + + def _i2c_write_byte(self, i2c_address: int, register: int, value: int): + self.bus.write_byte_data(i2c_address, register, value) + + def _i2c_read_bit(self, + i2c_address: int, + register: int, + bit: int, + use_cache=False): + data = self._i2c_read_byte(i2c_address, register, use_cache) + bitmask = 1 << bit + return bool(data & bitmask) + + def _i2c_write_bit(self, i2c_address: int, register: int, bit: int, + value: bool): + data = self._i2c_read_byte(i2c_address, register) + bitmask = 1 << bit + + if value: + data |= bitmask # Maskiertes Bit setzen + else: + data &= ~bitmask # Maskiertes Bit löschen + + self._i2c_write_byte(i2c_address, register, data) + + def _i2c_clear_cache(self): + self.i2c_cache = dict() + + def _configure_mcp(self, i2c_address: int): + self._i2c_write_byte(i2c_address, MCP_IODIRA, 0xff) + self._i2c_write_byte(i2c_address, MCP_IODIRB, 0xff) + self._i2c_write_byte(i2c_address, MCP_IPOLA, 0) + self._i2c_write_byte(i2c_address, MCP_IPOLB, 0) + self._i2c_write_byte(i2c_address, MCP_GPINTENA, 0) + self._i2c_write_byte(i2c_address, MCP_GPINTENB, 0) + self._i2c_write_byte(i2c_address, MCP_DEFVALA, 0) + self._i2c_write_byte(i2c_address, MCP_DEFVALB, 0) + self._i2c_write_byte(i2c_address, MCP_INTCONA, 0) + self._i2c_write_byte(i2c_address, MCP_INTCONB, 0) + self._i2c_write_byte(i2c_address, MCP_IOCON, 0b01000010) + self._i2c_write_byte(i2c_address, MCP_GPPUA, 0) + self._i2c_write_byte(i2c_address, MCP_GPPUB, 0) + self._i2c_write_byte(i2c_address, MCP_OLATA, 0) + self._i2c_write_byte(i2c_address, MCP_OLATB, 0) + + def _configure_input_device(self, device: _MCP23017Device): + if device.invert: + self._i2c_write_bit(device.i2c_address, device.port.reg(MCP_IPOLA), + device.pin, True) + + self._i2c_write_bit(device.i2c_address, device.port.reg(MCP_GPINTENA), + device.pin, True) + + def _configure_output_device(self, device: _MCP23017Device): + if device.invert: + # self._i2c_write_bit(device.i2c_address, device.port.reg(MCP_IPOLA), + # device.pin, True) + self._i2c_write_bit(device.i2c_address, device.port.reg(MCP_OLATA), + device.pin, True) + + self._i2c_write_bit(device.i2c_address, device.port.reg(MCP_IODIRA), + device.pin, False) + + def _read_interrupt(self) -> Optional[str]: + self._i2c_clear_cache() + + for key, device in self.input_devices.items(): + if self._i2c_read_bit(device.i2c_address, + device.port.reg(MCP_INTFA), device.pin, + True): + return key + + return None + + def _read_inputs(self) -> Dict[str, bool]: + res = dict() + self._i2c_clear_cache() + + for key, device in self.input_devices.items(): + res[key] = self._i2c_read_bit(device.i2c_address, + device.port.reg(MCP_GPIOA), + device.pin, True) + + return res + + def _interrupt_handler(self, int_pin: int): + key = self._read_interrupt() + if key is None: + return + + time.sleep(self.cfg.gpio_delay) + + input_states = self._read_inputs() + if key not in input_states: + return + + if input_states[key]: + logging.debug('%s pressed', key) + else: + logging.debug('%s released', key) + + if key == 'BT_MODE': + self._trigger_cb_mode() + elif key.startswith('BT_Z_'): + zoneid_str = key[5:] + try: + zoneid = int(zoneid_str) + except ValueError: + return + self._trigger_cb_manual(zoneid) + + def write_output(self, key: str, val: bool): + device = self.output_devices[key] + if device.invert: + val = not val + + self._i2c_write_bit(device.i2c_address, device.port.reg(MCP_OLATA), + device.pin, val) + + def start(self): + for i2c_address in self.mcp_addresses: + self._configure_mcp(i2c_address) + + for device in self.input_devices.values(): + self._configure_input_device(device) + + for device in self.output_devices.values(): + self._configure_output_device(device) + + # clear interrupts by reading inputs + self._read_inputs() + + GPIO.setmode(GPIO.BCM) + GPIO.setwarnings(False) + + GPIO.setup(self.cfg.gpio_interrupt, + GPIO.IN, + pull_up_down=GPIO.PUD_DOWN) + + GPIO.add_event_detect(self.cfg.gpio_interrupt, + GPIO.RISING, + callback=self._interrupt_handler, + bouncetime=10) + + def stop(self): + GPIO.cleanup() diff --git a/tsgrain_controller/jobschedule.py b/tsgrain_controller/jobschedule.py new file mode 100644 index 0000000..7e0e22d --- /dev/null +++ b/tsgrain_controller/jobschedule.py @@ -0,0 +1,38 @@ +# coding=utf-8 +from datetime import datetime +from typing import Optional + +import schedule + +from tsgrain_controller import database, models, queue, util + + +class Scheduler(util.StoppableThread): + + def __init__(self, db: database.RainDB, task_queue: queue.TaskQueue): + super().__init__(1) + self.db = db + self.queue = task_queue + self._job: Optional[schedule.Job] = None + + def _minute_handler(self): + jobs = self.db.get_jobs() + for job in jobs.values(): + if job.check(datetime.now()): + for zone in job.zones: + task = models.Task(models.Source.SCHEDULE, zone, + job.duration) + self.queue.enqueue(task) + + def start(self): + self._job = schedule.every().minute.at(':00').do(self._minute_handler) + super().start() + + def run_cycle(self): + schedule.run_pending() + + def stop(self): + super().stop() + if self._job: + schedule.cancel_job(self._job) + self._job = None diff --git a/tsgrain_controller/queue.py b/tsgrain_controller/queue.py index d771738..0b1d98b 100644 --- a/tsgrain_controller/queue.py +++ b/tsgrain_controller/queue.py @@ -1,7 +1,8 @@ # coding=utf-8 -from typing import Optional, List, Dict, Any import logging -from tsgrain_controller import util, models +from typing import Any, Dict, List, Optional + +from tsgrain_controller import models, util class TaskHolder: @@ -97,5 +98,6 @@ class TaskQueue(util.StoppableThread, TaskHolder): self.running_task = None def cleanup(self): - self.running_task.stop() - self.running_task = None + if self.running_task: + self.running_task.stop() + self.running_task = None diff --git a/tsgrain_controller/schedule.py b/tsgrain_controller/schedule.py deleted file mode 100644 index fe10720..0000000 --- a/tsgrain_controller/schedule.py +++ /dev/null @@ -1,33 +0,0 @@ -from dataclasses import dataclass -from datetime import datetime -from typing import List - -from tsgrain_controller import util - - -@dataclass -class Job: - date: datetime - duration: int - zones: List[int] - enable: bool - repeat: bool - - @property - def is_active(self) -> bool: - if not self.enable: - return False - if self.repeat: - return True - - return self.date > util.datetime_now() - - @classmethod - def deserialize(cls, data: dict) -> 'Job': - return cls( - date=datetime.fromisoformat(data['date']), - duration=data['duration'], - zones=data['zones'], - enable=data['zones'], - repeat=data['repeat'], - ) diff --git a/tsgrain_controller/util.py b/tsgrain_controller/util.py index e25ba05..e7c7c11 100644 --- a/tsgrain_controller/util.py +++ b/tsgrain_controller/util.py @@ -1,10 +1,11 @@ # coding=utf-8 import datetime +import json +import logging +import threading import time from enum import Enum -import json -import threading -from typing import Union, Any +from typing import Any, Union from tsgrain_controller import config @@ -123,6 +124,9 @@ class AppInterface: def get_cfg(self) -> config.Config: pass + def get_logger(self) -> logging.Logger: + pass + class ZoneUnavailableException(Exception): pass