Compare commits
11 commits
1a34a7f115
...
7d72045d4f
Author | SHA1 | Date | |
---|---|---|---|
7d72045d4f | |||
70caa4664a | |||
8e051600af | |||
3ead68d492 | |||
a42b106918 | |||
81a28e9fd3 | |||
bc69e81bf6 | |||
e86d434eda | |||
ae9e4c7c3d | |||
dd20335717 | |||
91115493f6 |
24 changed files with 1893 additions and 7 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -3,3 +3,5 @@
|
||||||
/.tox
|
/.tox
|
||||||
__pycache__
|
__pycache__
|
||||||
*.egg-info
|
*.egg-info
|
||||||
|
/tsgrain.toml
|
||||||
|
/raindb.json
|
||||||
|
|
26
README.rst
26
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
|
5. Ist der Timer abgelaufen, wird der Ventilausgang deaktivert und der Bewässerungsauftrag
|
||||||
aus der Warteschlange entfernt.
|
aus der Warteschlange entfernt.
|
||||||
6. Wird ein Bewässerungsauftrag abgebrochen (entweder durch Tastendruck oder durch
|
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.
|
Bewässerungsauftrag aus der Warteschlange entfernt werden.
|
||||||
7. Wird der Controller beendet, werden alle laufenden Aufträge angehalten. Die
|
7. Wird der Controller beendet, werden alle laufenden Aufträge angehalten. Die
|
||||||
Ventilausgänge werden deaktiviert und die gesamte Warteschlange inklusive
|
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
|
Datenmodelle
|
||||||
============
|
============
|
||||||
|
|
||||||
|
Source
|
||||||
|
------
|
||||||
|
|
||||||
|
``name``
|
||||||
|
Name der Quelle: manual, schedule
|
||||||
|
|
||||||
|
``priority``
|
||||||
|
**Priorität:** Priorität der Quelle
|
||||||
|
|
||||||
Task
|
Task
|
||||||
----
|
----
|
||||||
|
|
||||||
``source``
|
``source``
|
||||||
**Quelle:** Zeitplan (mit Zeitplan-ID), Tastendruck
|
**Quelle:** Zeitplan (mit Zeitplan-ID), Tastendruck
|
||||||
|
|
||||||
``priority``
|
|
||||||
**Priorität:** Priorität der Bewässerungsaufgabe
|
|
||||||
|
|
||||||
``zone: int``
|
``zone: int``
|
||||||
ID der Bewässerungszone (Platz)
|
ID der Bewässerungszone (Platz)
|
||||||
|
|
||||||
|
@ -97,7 +103,7 @@ Task
|
||||||
Schedule
|
Schedule
|
||||||
--------
|
--------
|
||||||
|
|
||||||
``datetime: datetime``
|
``date: datetime``
|
||||||
Datum/Uhrzeit
|
Datum/Uhrzeit
|
||||||
|
|
||||||
``duration: int``
|
``duration: int``
|
||||||
|
@ -108,3 +114,13 @@ Schedule
|
||||||
|
|
||||||
``repeat: bool``
|
``repeat: bool``
|
||||||
Zeitplan täglich wiederholen
|
Zeitplan täglich wiederholen
|
||||||
|
|
||||||
|
|
||||||
|
Konfiguration
|
||||||
|
=============
|
||||||
|
|
||||||
|
``MAX_ACTIVE_ZONES``
|
||||||
|
|
||||||
|
in/output pins
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -1 +1,4 @@
|
||||||
tinydb
|
tinydb~=4.6.1
|
||||||
|
cyra~=1.0.2
|
||||||
|
schedule~=1.1.0
|
||||||
|
smbus~=1.1.post2
|
||||||
|
|
|
@ -1,2 +1,5 @@
|
||||||
pytest
|
pytest
|
||||||
pytest-cov
|
pytest-cov
|
||||||
|
pytest-mock
|
||||||
|
py-defer
|
||||||
|
importlib_resources
|
||||||
|
|
2
setup.py
2
setup.py
|
@ -23,6 +23,8 @@ setuptools.setup(
|
||||||
py_modules=['tsgrain_controller'],
|
py_modules=['tsgrain_controller'],
|
||||||
install_requires=[
|
install_requires=[
|
||||||
'tinydb',
|
'tinydb',
|
||||||
|
'cyra',
|
||||||
|
'schedule',
|
||||||
],
|
],
|
||||||
packages=setuptools.find_packages(exclude=['tests*']),
|
packages=setuptools.find_packages(exclude=['tests*']),
|
||||||
entry_points={
|
entry_points={
|
||||||
|
|
45
tests/fixtures.py
Normal file
45
tests/fixtures.py
Normal file
|
@ -0,0 +1,45 @@
|
||||||
|
# coding=utf-8
|
||||||
|
from typing import Optional, Callable, Dict
|
||||||
|
from importlib_resources import files
|
||||||
|
import os
|
||||||
|
|
||||||
|
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):
|
||||||
|
|
||||||
|
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 TestingApp(util.AppInterface):
|
||||||
|
|
||||||
|
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
|
93
tests/test_database.py
Normal file
93
tests/test_database.py
Normal file
|
@ -0,0 +1,93 @@
|
||||||
|
# coding=utf-8
|
||||||
|
import tempfile
|
||||||
|
import os
|
||||||
|
|
||||||
|
from tests import fixtures
|
||||||
|
from tsgrain_controller import database, util, queue, models
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_init():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
dbfile = os.path.join(td, 'raindb.json')
|
||||||
|
db = database.RainDB(dbfile)
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
assert os.path.isfile(dbfile)
|
||||||
|
|
||||||
|
|
||||||
|
def test_db_jobs():
|
||||||
|
with tempfile.TemporaryDirectory() as td:
|
||||||
|
dbfile = os.path.join(td, 'raindb.json')
|
||||||
|
db = database.RainDB(dbfile)
|
||||||
|
|
||||||
|
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(dbfile)
|
||||||
|
|
||||||
|
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 = models.Task(models.Source.MANUAL, 1, 10)
|
||||||
|
task2 = models.Task(models.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(dbfile)
|
||||||
|
db.store_queue(q)
|
||||||
|
|
||||||
|
# Reopen database
|
||||||
|
db.close()
|
||||||
|
db = database.RainDB(dbfile)
|
||||||
|
|
||||||
|
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')
|
||||||
|
|
||||||
|
db = database.RainDB(dbfile)
|
||||||
|
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(dbfile)
|
||||||
|
assert db.get_auto_mode() is True
|
106
tests/test_output.py
Normal file
106
tests/test_output.py
Normal file
|
@ -0,0 +1,106 @@
|
||||||
|
# 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, models
|
||||||
|
|
||||||
|
|
||||||
|
class _TaskHolder(queue.TaskHolder):
|
||||||
|
|
||||||
|
def __init__(self, task: Optional[models.Task]):
|
||||||
|
self.task = task
|
||||||
|
|
||||||
|
def get_current_task(self) -> Optional[models.Task]:
|
||||||
|
return self.task
|
||||||
|
|
||||||
|
|
||||||
|
@defer.with_defer
|
||||||
|
def test_zone_outputs():
|
||||||
|
io = fixtures.TestingIo(None, None)
|
||||||
|
task_holder = _TaskHolder(None)
|
||||||
|
app = fixtures.TestingApp()
|
||||||
|
op = output.Outputs(io, task_holder, app)
|
||||||
|
op.start()
|
||||||
|
defer.defer(op.stop)
|
||||||
|
|
||||||
|
time.sleep(0.01)
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
}
|
||||||
|
|
||||||
|
task_holder.task = models.Task(models.Source.MANUAL, 1, 100)
|
||||||
|
time.sleep(0.01)
|
||||||
|
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,
|
||||||
|
# Manual LED is blinking
|
||||||
|
'LED_M_MAN': mock.ANY,
|
||||||
|
}
|
||||||
|
|
||||||
|
task_holder.task = None
|
||||||
|
time.sleep(0.01)
|
||||||
|
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)
|
||||||
|
task_holder = _TaskHolder(None)
|
||||||
|
app = fixtures.TestingApp()
|
||||||
|
op = output.Outputs(io, task_holder, app)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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 = models.Task(models.Source.MANUAL, 1, 30)
|
||||||
|
time.sleep(0.005)
|
||||||
|
assert get_blink_time('LED_Z_1') == pytest.approx(250, 0.1)
|
||||||
|
"""
|
82
tests/test_queue.py
Normal file
82
tests/test_queue.py
Normal file
|
@ -0,0 +1,82 @@
|
||||||
|
# coding=utf-8
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from tests import fixtures
|
||||||
|
from tsgrain_controller import queue, util, models
|
||||||
|
|
||||||
|
|
||||||
|
def test_task():
|
||||||
|
task = models.Task(models.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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_started(mocker):
|
||||||
|
mocker.patch('tsgrain_controller.util.datetime_now',
|
||||||
|
return_value=datetime(2022, 1, 10, 6, 0))
|
||||||
|
|
||||||
|
task = models.Task(models.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),
|
||||||
|
'datetime_finished': datetime(2022, 1, 10, 6, 0, 10)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_add_tasks():
|
||||||
|
app = fixtures.TestingApp()
|
||||||
|
q = queue.TaskQueue(app)
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert not q.enqueue(task1c)
|
||||||
|
|
||||||
|
|
||||||
|
def test_queue_runner():
|
||||||
|
app = fixtures.TestingApp()
|
||||||
|
q = queue.TaskQueue(app)
|
||||||
|
|
||||||
|
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)
|
||||||
|
assert q.enqueue(task3)
|
||||||
|
|
||||||
|
q.start()
|
||||||
|
time.sleep(0.9)
|
||||||
|
q.stop()
|
||||||
|
|
||||||
|
assert util.to_json(q) == \
|
||||||
|
'[{"source": "MANUAL", "zone_id": 2, "duration": 5, "remaining": 4}, \
|
||||||
|
{"source": "SCHEDULE", "zone_id": 2, "duration": 10, "remaining": 10}]'
|
42
tests/test_util.py
Normal file
42
tests/test_util.py
Normal file
|
@ -0,0 +1,42 @@
|
||||||
|
# coding=utf-8
|
||||||
|
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
|
1
tests/testfiles/tsgrain.toml
Normal file
1
tests/testfiles/tsgrain.toml
Normal file
|
@ -0,0 +1 @@
|
||||||
|
n_zones = 3 # Anzahl Bewässerungszonen
|
|
@ -1,5 +1,30 @@
|
||||||
|
import signal
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from tsgrain_controller import application
|
||||||
|
|
||||||
|
|
||||||
def run():
|
def run():
|
||||||
pass
|
console_flag = False
|
||||||
|
|
||||||
|
if len(sys.argv) > 1:
|
||||||
|
x = sys.argv[1]
|
||||||
|
if x.startswith('c'):
|
||||||
|
console_flag = True
|
||||||
|
|
||||||
|
app = application.Application(console_flag)
|
||||||
|
|
||||||
|
def _signal_handler(sig, frame):
|
||||||
|
app.stop()
|
||||||
|
|
||||||
|
print('Exited.')
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
signal.signal(signal.SIGINT, _signal_handler)
|
||||||
|
|
||||||
|
app.start()
|
||||||
|
|
||||||
|
signal.pause()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
|
|
64
tsgrain_controller/application.py
Normal file
64
tsgrain_controller/application.py
Normal file
|
@ -0,0 +1,64 @@
|
||||||
|
# coding=utf-8
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from tsgrain_controller import config, controller, database, io, jobschedule, output, \
|
||||||
|
queue, util
|
||||||
|
|
||||||
|
|
||||||
|
class Application(util.AppInterface):
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
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.cfg)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
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)
|
66
tsgrain_controller/config.py
Normal file
66
tsgrain_controller/config.py
Normal file
|
@ -0,0 +1,66 @@
|
||||||
|
# coding=utf-8
|
||||||
|
import cyra
|
||||||
|
|
||||||
|
|
||||||
|
class Config(cyra.Config):
|
||||||
|
builder = cyra.ConfigBuilder()
|
||||||
|
|
||||||
|
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/!',
|
||||||
|
})
|
36
tsgrain_controller/controller.py
Normal file
36
tsgrain_controller/controller.py
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
# coding=utf-8
|
||||||
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
from tsgrain_controller import config, models, queue
|
||||||
|
|
||||||
|
|
||||||
|
class ButtonController:
|
||||||
|
|
||||||
|
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):
|
||||||
|
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 == models.Source.MANUAL:
|
||||||
|
self.task_queue.cancel_current_task()
|
||||||
|
else:
|
||||||
|
task = models.Task(models.Source.MANUAL, zone_id,
|
||||||
|
self.cfg.manual_time)
|
||||||
|
self.task_queue.enqueue(task, True)
|
||||||
|
|
||||||
|
|
||||||
|
class QueuingButtonController(ButtonController):
|
||||||
|
|
||||||
|
def cb_manual(self, zone_id: int):
|
||||||
|
# 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, self.cfg.manual_time)
|
||||||
|
self.task_queue.enqueue(task)
|
115
tsgrain_controller/database.py
Normal file
115
tsgrain_controller/database.py
Normal file
|
@ -0,0 +1,115 @@
|
||||||
|
# coding=utf-8
|
||||||
|
import tinydb
|
||||||
|
from typing import Dict, Optional
|
||||||
|
from tsgrain_controller import queue, util, models
|
||||||
|
|
||||||
|
|
||||||
|
class RainDB:
|
||||||
|
|
||||||
|
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)
|
||||||
|
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, models.Job]:
|
||||||
|
"""
|
||||||
|
Gibt alle gespeicherten Bewässerungsjobs zurück
|
||||||
|
|
||||||
|
:return: Bewässerungsjobs: dict(id -> models.Job)
|
||||||
|
"""
|
||||||
|
res = dict()
|
||||||
|
for job_data in self._jobs.all():
|
||||||
|
res[job_data.doc_id] = models.Job.deserialize(job_data)
|
||||||
|
return res
|
||||||
|
|
||||||
|
def get_job(self, job_id: int) -> Optional[models.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 models.Job.deserialize(job)
|
||||||
|
|
||||||
|
def insert_job(self, job: models.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: models.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 = models.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)
|
32
tsgrain_controller/io/__init__.py
Normal file
32
tsgrain_controller/io/__init__.py
Normal file
|
@ -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)
|
113
tsgrain_controller/io/console.py
Normal file
113
tsgrain_controller/io/console.py
Normal file
|
@ -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()
|
448
tsgrain_controller/io/mcp23017.py
Normal file
448
tsgrain_controller/io/mcp23017.py
Normal file
|
@ -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()
|
38
tsgrain_controller/jobschedule.py
Normal file
38
tsgrain_controller/jobschedule.py
Normal file
|
@ -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
|
164
tsgrain_controller/models.py
Normal file
164
tsgrain_controller/models.py
Normal file
|
@ -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)
|
151
tsgrain_controller/output.py
Normal file
151
tsgrain_controller/output.py
Normal file
|
@ -0,0 +1,151 @@
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Dict
|
||||||
|
from tsgrain_controller import io, util, queue, models
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OutputState:
|
||||||
|
on: bool
|
||||||
|
# Blinks/second
|
||||||
|
blink_freq: float = 0
|
||||||
|
|
||||||
|
def is_on(self, ms_ticks: int) -> bool:
|
||||||
|
if self.on and 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.set_state = OutputState(False)
|
||||||
|
self._state = False
|
||||||
|
|
||||||
|
self._io.write_output(self.name, False)
|
||||||
|
|
||||||
|
def _write_state(self, state: bool):
|
||||||
|
if state != self._state:
|
||||||
|
self._state = state
|
||||||
|
self._io.write_output(self.name, self._state)
|
||||||
|
|
||||||
|
def update_state(self, ms_ticks: int):
|
||||||
|
self._write_state(self.set_state.is_on(ms_ticks))
|
||||||
|
|
||||||
|
|
||||||
|
class Outputs(util.StoppableThread):
|
||||||
|
"""
|
||||||
|
Outputs ist für die Ausgabegeräte der Bewässerungssteuerung zuständig
|
||||||
|
(Ventilausgänge und LEDs)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, o_io: io.Io, task_holder: queue.TaskHolder,
|
||||||
|
app: util.AppInterface):
|
||||||
|
super().__init__(0.01)
|
||||||
|
|
||||||
|
self.task_holder = task_holder
|
||||||
|
self.app = app
|
||||||
|
self.n_zones = self.app.get_cfg().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_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_auto,
|
||||||
|
self.mode_led_man,
|
||||||
|
]
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
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):
|
||||||
|
is_on = remaining_seconds > 0
|
||||||
|
|
||||||
|
blink_freq = 0
|
||||||
|
if remaining_seconds < 60:
|
||||||
|
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)
|
||||||
|
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_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_man.set_state.on = current
|
||||||
|
self.mode_led_man.set_state.blink_freq = 2
|
||||||
|
|
||||||
|
def _reset_states(self):
|
||||||
|
for output in self._output_devices:
|
||||||
|
output.set_state = OutputState(False)
|
||||||
|
|
||||||
|
def _update_states(self):
|
||||||
|
ms_ticks = util.time_ms()
|
||||||
|
|
||||||
|
for output in self._output_devices:
|
||||||
|
output.update_state(ms_ticks)
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self._reset_states()
|
||||||
|
self._update_states()
|
||||||
|
|
||||||
|
def run_cycle(self):
|
||||||
|
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 == models.Source.MANUAL)
|
||||||
|
self._set_mode_led_auto(self.app.is_auto_enabled(),
|
||||||
|
task.source == models.Source.SCHEDULE)
|
||||||
|
else:
|
||||||
|
self._set_mode_led_auto(self.app.is_auto_enabled(), False)
|
||||||
|
|
||||||
|
self._update_states()
|
||||||
|
|
||||||
|
def setup(self):
|
||||||
|
self.reset()
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
self.reset()
|
103
tsgrain_controller/queue.py
Normal file
103
tsgrain_controller/queue.py
Normal file
|
@ -0,0 +1,103 @@
|
||||||
|
# coding=utf-8
|
||||||
|
import logging
|
||||||
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
|
from tsgrain_controller import models, util
|
||||||
|
|
||||||
|
|
||||||
|
class TaskHolder:
|
||||||
|
|
||||||
|
def get_current_task(self) -> Optional[models.Task]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TaskQueue(util.StoppableThread, TaskHolder):
|
||||||
|
|
||||||
|
def __init__(self, app: util.AppInterface):
|
||||||
|
super().__init__(0.1)
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
self.tasks: List[models.Task] = list()
|
||||||
|
self.running_task: Optional[models.Task] = None
|
||||||
|
|
||||||
|
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.
|
||||||
|
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:
|
||||||
|
if t.source == task.source and (exclusive
|
||||||
|
or t.zone_id == task.zone_id):
|
||||||
|
return False
|
||||||
|
|
||||||
|
self.tasks.append(task)
|
||||||
|
logging.info('Task added to queue (%s)', task)
|
||||||
|
return True
|
||||||
|
|
||||||
|
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_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 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
|
||||||
|
|
||||||
|
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 none is running
|
||||||
|
if self.running_task is None:
|
||||||
|
for task in self.tasks:
|
||||||
|
# Only start scheduled tasks if automatic mode is 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 == 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
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
if self.running_task:
|
||||||
|
self.running_task.stop()
|
||||||
|
self.running_task = None
|
136
tsgrain_controller/util.py
Normal file
136
tsgrain_controller/util.py
Normal file
|
@ -0,0 +1,136 @@
|
||||||
|
# coding=utf-8
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from enum import Enum
|
||||||
|
from typing import Any, Union
|
||||||
|
|
||||||
|
from tsgrain_controller import config
|
||||||
|
|
||||||
|
|
||||||
|
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: 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)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
def time_ms() -> int:
|
||||||
|
return round(time.time() * 1000)
|
||||||
|
|
||||||
|
|
||||||
|
def datetime_now() -> datetime.datetime:
|
||||||
|
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):
|
||||||
|
|
||||||
|
def __init__(self, interval: float = 1):
|
||||||
|
super().__init__()
|
||||||
|
self._interval = interval
|
||||||
|
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()
|
||||||
|
time.sleep(self._interval)
|
||||||
|
|
||||||
|
self.cleanup()
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self._stop_signal.set()
|
||||||
|
self.join()
|
||||||
|
|
||||||
|
|
||||||
|
class AppInterface:
|
||||||
|
|
||||||
|
def is_auto_enabled(self) -> bool:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_cfg(self) -> config.Config:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_logger(self) -> logging.Logger:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ZoneUnavailableException(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class TaskRunException(Exception):
|
||||||
|
pass
|
Loading…
Reference in a new issue