Skip to content

Commit 9c40eca

Browse files
committed
Sync with branch 'dev_m'
Added nodes for alert system. So far only an alert switch can be triggered. Email and Telegram aren't supported yet. Fixed a startup crash when QuestDB is missing. Renamed CalibrationAux to ScaleAux, this will break existing config files!
2 parents 4e694af + 4267c63 commit 9c40eca

File tree

11 files changed

+623
-377
lines changed

11 files changed

+623
-377
lines changed

ToDo

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ aquaPi ToDo list
3434
controller: null (out=in), delay
3535
input: schedule analog?, random
3636
aux: min, limiter
37-
misc: email, telegram (pyTelegramBotAPI), history, cloud telemetry, macro
37+
misc: email, telegram, history, cloud telemetry, macro
3838

3939
- macros!?! = scheduled/triggered sender of msgs on the bus? wait for msgs?
4040
This will require affected nodes to supended their listening to avoid conflicts.
@@ -78,7 +78,31 @@ aquaPi ToDo list
7878
adapter µUSB->USB A plus cable USB A -> USB B
7979

8080
- Telegram bot in Python:
81+
package pyTelegramBotAPI,
8182
https://thepythoncorner.com/posts/2021-01-16-how-create-telegram-bot-in-python/
83+
in channel BotFather /newbot: name aquaPi, bot @UNIQUE_bot, remeber bot token,
84+
find own "chat id": https://web.telegram.org/z/#?tgaddr=tg://resolve?domain=schwabix,
85+
this redirects to a URL ending in your chat id
86+
OR join and start @RawDataBot, it will reply with json showing your chat:id
87+
OR join and start your bot, then https://api.telegram.org/bot<YOUR_BOT_TOKEN>/getUpdates
88+
send message - 8135... is bot token, 1261... is chat id - multiple subscribers??:
89+
https://api.telegram.org/botXXXXXXXXX:AAEQ0ec6XT6i0BSvF3ldVrmepETMTjyxNiE/sendMessage?chat_id=XXXXXXXXX&text=bot6135_to_1261..
90+
and modify/update it:
91+
https://api.telegram.org/botXXXXXXXXX:AAEQ0ec6XT6i0BSvF3ldVrmepETMTjyxNiE/editMessageText?message_id=2983&chat_id=XXXXXXXXX&text=changed_Text
92+
get message history (depth?), could be used to receive commands:
93+
https://api.telegram.org/botYYYYYYYYYY:AAHH4nCC-vD8clvfiMryls_ZpdJi_HskctM/getUpdates
94+
sample ->
95+
{"ok":true,"result":[{"update_id":23832511,
96+
"message":{"message_id":1,"from":{"id":126177523,"is_bot":false,"first_name":"Markus","username":"schwabix","language_code":"de"},"chat":{"id":126177523,"first_name":"Markus","username":"schwabix","type":"private"},"date":1691342697,"text":"/start","entities":[{"offset":0,"length":6,"type":"bot_command"}]}},{"update_id":23832512,
97+
...
98+
"message":{"message_id":7,"from":{"id":126177523,"is_bot":false,"first_name":"Markus","username":"schwabix","language_code":"de"},"chat":{"id":126177523,"first_name":"Markus","username":"schwabix","type":"private"},"date":1691343059,"text":"soso"}},{"update_id":23832516,
99+
"message":{"message_id":10,"from":{"id":126177523,"is_bot":false,"first_name":"Markus","username":"schwabix","language_code":"de"},"chat":{"id":126177523,"first_name":"Markus","username":"schwabix","type":"private"},"date":1691343182,"text":"silence"}}]}
100+
101+
or bot as group member allows multiple receivers, but only 1 bot??:
102+
https://api.telegram.org/botYYYYYYYYYY:AAHH4nCC-vD8clvfiMryls_ZpdJi_HskctM/sendMessage?chat_id=-978207359&text=ph%20Alarm!
103+
104+
OR simple sample without dedicated package!!:
105+
https://medium.com/codex/using-python-to-send-telegram-messages-in-3-simple-steps-419a8b5e5e2
82106

83107
- packaging and deployment ... long way to there!
84108

aquaPi/api.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
)
1313
from http import HTTPStatus
1414

15-
from .machineroom.misc_nodes import BusRole
15+
from .machineroom.msg_bus import BusRole
1616
from .pages.sse_util import send_sse_events
1717

1818

aquaPi/driver/DriverAlert.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
Extract from medium.com "using-python-to-send-telegram-messages-in-3-simple-steps"
3+
4+
2. Getting your chat ID
5+
6+
In Telegram, every chat has a chat ID, and we need this chat ID to send Telegram messages using Python.
7+
8+
Send your Telegram bot a message (any random message)
9+
Run this Python script to find your chat ID
10+
11+
import requests
12+
TOKEN = "YOUR TELEGRAM BOT TOKEN"
13+
url = f"https://api.telegram.org/bot{TOKEN}/getUpdates"
14+
print(requests.get(url).json())
15+
16+
This script calls the getUpdates function, which kinda checks for new messages. We can find our chat ID from the returned JSON (the one in red)
17+
18+
Note: if you don’t send your Telegram bot a message, your results might be empty.
19+
20+
3. Copy and paste the chat ID into our next step
21+
3. Sending your Telegram message using Python
22+
23+
Copy and paste 1) your Telegram bot token and 2) your chat ID from the previous 2 steps into the following Python script. (And do customize your message too)
24+
25+
import requests
26+
TOKEN = "YOUR TELEGRAM BOT TOKEN"
27+
chat_id = "YOUR CHAT ID"
28+
message = "hello from your telegram bot"
29+
url = f"https://api.telegram.org/bot{TOKEN}/sendMessage?chat_id={chat_id}&text={message}"
30+
print(requests.get(url).json()) # this sends the message
31+
"""

aquaPi/driver/DriverGPIO.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,6 @@ def find_ports():
9999
'GPIO 4 in': IoPort(PortFunc.Bin, DriverGPIO, {'pin': 4, 'fake': True}, []), # 1-wire
100100
'GPIO 4 out': IoPort(PortFunc.Bout, DriverGPIO, {'pin': 4, 'fake': True}, []), # 1-wire
101101
'GPIO 12 out': IoPort(PortFunc.Bout, DriverGPIO, {'pin': 12, 'fake': True}, []), # Heater relay
102-
'GPIO 13 out': IoPort(PortFunc.Bout, DriverGPIO, {'pin': 13, 'fake': True}, []), # (Cooler relay)
103102
'GPIO 18 in': IoPort(PortFunc.Bin, DriverGPIO, {'pin': 18, 'fake': True}, []),
104103
'GPIO 18 out': IoPort(PortFunc.Bout, DriverGPIO, {'pin': 18, 'fake': True}, []), # PWM 0
105104
'GPIO 19 in': IoPort(PortFunc.Bin, DriverGPIO, {'pin': 19, 'fake': True}, []),

aquaPi/driver/base.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,9 @@ def __init__(self, msg='Failed to write value to the output.'):
7676

7777

7878
class PortFunc(Enum):
79-
Bin, Bout, Ain, Aout = range(1, 5)
79+
""" Function of a port driver: Bool/Analog/String + In/Out
80+
"""
81+
Bin, Bout, Ain, Aout, Sin, Sout = range(1, 7)
8082

8183

8284
class PinFunc(Enum):

aquaPi/machineroom/__init__.py

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
from .in_nodes import AnalogInput, ScheduleInput
1111
from .out_nodes import SwitchDevice, AnalogDevice
1212
from .aux_nodes import ScaleAux, MinAux, MaxAux, AvgAux
13-
from .misc_nodes import History
13+
from .hist_nodes import History
14+
from .alert_nodes import Alert, AlertAbove, AlertBelow
1415

1516

1617
log = logging.getLogger('MachineRoom')
@@ -109,11 +110,13 @@ def create_default_nodes(self):
109110
Distraction: interesting fact on English:
110111
"fish" is plural, "fishes" is several species of fish
111112
"""
112-
REAL_CONFIG = True #False # this disables the other test configs
113+
REAL_CONFIG = True # False # this disables the other test configs
114+
115+
TEST_ALERT = True
113116

114117
TEST_PH = True
115118

116-
SIM_LIGHT = True
119+
SIM_LIGHT = False #True
117120
DAWN_LIGHT = SIM_LIGHT and False # True
118121

119122
SIM_TEMP = True
@@ -295,3 +298,13 @@ def create_default_nodes(self):
295298
[w1_temp.id, w2_temp.id, w_temp.id,
296299
w_heat.id, w_cool.id])
297300
t_history.plugin(self.bus)
301+
302+
if TEST_ALERT:
303+
led_alert = Alert('Alert LED',
304+
[AlertAbove(calib_ph.id, 7.2), AlertBelow(calib_ph.id, 6.8)],
305+
'GPIO 1 out')
306+
led_alert.plugin(self.bus)
307+
mail_alert = Alert('Alert Mail',
308+
[AlertAbove(w_temp.id, 26.0), AlertBelow(w_temp.id, 23.0)],
309+
'GPIO 0 out') #TEMP, no drivers for email/Telegram yet
310+
mail_alert.plugin(self.bus)

aquaPi/machineroom/alert_nodes.py

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#!/usr/bin/env python3
2+
3+
from abc import ABC, abstractmethod
4+
import logging
5+
# from time import time
6+
7+
from .msg_bus import (BusListener, BusRole, MsgData, MsgFilter)
8+
from ..driver import (PortFunc, io_registry, DriverReadError)
9+
10+
11+
log = logging.getLogger('AlertNodes')
12+
log.brief = log.warning # alias, warning is used as brief info, level info is verbose
13+
14+
log.setLevel(logging.WARNING)
15+
log.setLevel(logging.INFO)
16+
# log.setLevel(logging.DEBUG)
17+
18+
19+
# ========== alert conditions ==========
20+
21+
22+
class AlertCond(ABC):
23+
""" Base class for all kind of alerting conditions
24+
25+
node_id - id of node this condition applies to
26+
threshold - the limit _check() will use
27+
"""
28+
def __init__(self, node_id, threshold):
29+
self.node_id = node_id
30+
self.threshold = threshold
31+
self._alerted = False
32+
33+
def check_change(self, msg):
34+
alerted = self._check(msg)
35+
if (alerted == self._alerted):
36+
return None
37+
self._alerted = alerted
38+
return self._alerted
39+
40+
def is_alerted(self):
41+
return self._alerted
42+
43+
@abstractmethod
44+
def _check(self, msg):
45+
pass
46+
47+
def alert_text(self, msg, bus):
48+
name = bus.get_node(msg.sender).name or msg.sender
49+
return self._text(msg, name)
50+
51+
@abstractmethod
52+
def _text(self, msg, name):
53+
pass
54+
55+
56+
class AlertAbove(AlertCond):
57+
""" Alert when data above threshold
58+
"""
59+
def _check(self, msg):
60+
return msg.data > self.threshold
61+
62+
def _text(self, msg, name):
63+
return 'Value of %s is %s: %.4f [limit %.4f]\n' \
64+
% (name, 'too high' if self._alerted else 'OK', msg.data, self.threshold)
65+
66+
67+
class AlertBelow(AlertCond):
68+
""" Alert when data below threshold
69+
"""
70+
def _check(self, msg):
71+
return msg.data < self.threshold
72+
73+
def _text(self, msg, name):
74+
return 'Value of %s is %s: %.4f [limit %.4f]\n' \
75+
% (name, 'too low' if self._alerted else 'OK', msg.data, self.threshold)
76+
77+
78+
#class AlertLongActive _check = now - _last_off > threshold, _text = "Overload/High utilization"
79+
#class AlertLongInactive _check = now - _last_on > threshold
80+
81+
# ========== alert node ==========
82+
83+
84+
class Alert(BusListener):
85+
""" A multi-input node, checking alert conditions with output
86+
to email/telegram/etc.
87+
88+
Options:
89+
name - unique name of this output node in UI
90+
conditions - collection of alert conditions
91+
driver - port name of driver of type S(tring)out or B(inary)out
92+
93+
Output:
94+
- nothing -
95+
"""
96+
ROLE = BusRole.ALERTS
97+
98+
def __init__(self, name, conditions, port, _cont=False):
99+
super().__init__(name, _cont=_cont)
100+
self.data = 0 # just anything for MsgBorn
101+
self._driver = None
102+
self._port = None
103+
self.port = port
104+
if isinstance(conditions, AlertCond):
105+
conditions = [conditions]
106+
self.conditions = conditions
107+
self._inputs = MsgFilter({c.node_id for c in self.conditions})
108+
109+
110+
def __getstate__(self):
111+
state = super().__getstate__()
112+
state.update(conditions=self.conditions)
113+
state.update(port=self.port)
114+
return state
115+
116+
def __setstate__(self, state):
117+
self.__init__(state['name'], state['conditions'], state['port'],
118+
_cont=True)
119+
120+
@property
121+
def port(self):
122+
return self._port
123+
124+
@port.setter
125+
def port(self, port):
126+
if self._driver:
127+
io_registry.driver_destruct(self._port, self._driver)
128+
if port:
129+
self._driver = io_registry.driver_factory(port)
130+
self._port = port
131+
132+
def listen(self, msg):
133+
if isinstance(msg, MsgData):
134+
any_alert = False
135+
any_change = False
136+
all_msgs = ''
137+
for cond in [c for c in self.conditions if c.node_id == msg.sender]:
138+
log.debug('## (%s) check %f against %f - %s', type(cond), msg.data, cond.threshold, cond.node_id)
139+
cond_change = cond.check_change(msg)
140+
141+
cond_txt = cond.alert_text(msg, self._bus)
142+
if cond_change is not None:
143+
all_msgs += cond_txt
144+
any_alert |= cond_change
145+
any_change = True
146+
log.debug('## "%s" changed to "%s"', type(cond), cond_txt)
147+
elif cond.is_alerted():
148+
all_msgs += cond_txt
149+
any_alert |= True
150+
log.debug('## "%s" still is "%s"', type(cond), cond_txt)
151+
152+
if any_alert or any_change:
153+
log.warning(all_msgs)
154+
155+
# IDEA might add a repeat interval here
156+
if any_change:
157+
if self._driver.func == PortFunc.Bout:
158+
self._driver.write(100 if any_alert else 0)
159+
log.info('Alert device "%s" set to %d', self._driver.name, 100 if any_alert else 0)
160+
elif self._driver.func == PortFunc.Sout:
161+
self._driver.write(all_msgs)
162+
log.info('Alert receiver "%s" will get msg: "%s"', self._driver.name, all_msgs)
163+
self.post(MsgData(self.id, all_msgs)) #TODO MsgAlert ??
164+
165+
def get_settings(self):
166+
return []
167+
## settings = super().get_settings()
168+
## settings.append(('duration', 'max. Dauer', self.duration,
169+
## 'type="number" min="0" max="%d"' % (24*60*60)))
170+
## return settings
171+

0 commit comments

Comments
 (0)