initial public
This commit is contained in:
commit
c412bf2ee0
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
/.idea
|
||||||
|
/venv
|
||||||
|
*.pyc
|
||||||
|
__pycache__
|
||||||
|
.DS_Store
|
||||||
|
/src/test/test_inverter_monitor.log
|
46
Makefile
Normal file
46
Makefile
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
INSTALL = /usr/bin/env install
|
||||||
|
GLOBAL_PREFIX = /usr/local
|
||||||
|
|
||||||
|
ifeq ($(shell id -u), 0)
|
||||||
|
USER_PREFIX = /usr/local
|
||||||
|
else
|
||||||
|
USER_PREFIX = $(HOME)/.local
|
||||||
|
endif
|
||||||
|
|
||||||
|
PROGRAMS = admin_bot inverter_bot pump_bot sensors_bot
|
||||||
|
PROGRAMS += inverter_mqtt_receiver inverter_mqtt_sender
|
||||||
|
PROGRAMS += sensors_mqtt_receiver sensors_mqtt_sender
|
||||||
|
PROGRAMS += si7021d
|
||||||
|
PROGRAMS += gpiorelayd
|
||||||
|
PROGRAMS += gpiosensord
|
||||||
|
#PROGRAMS += web_api
|
||||||
|
|
||||||
|
all:
|
||||||
|
@echo "Supported commands:"
|
||||||
|
@echo
|
||||||
|
@echo " \033[1mmake install\033[0m symlink all programs to $(USER_PREFIX)"
|
||||||
|
@echo " \033[1mmake install-tools\033[0m copy admin scripts to /usr/local/bin"
|
||||||
|
@echo " \033[1mmake venv\033[0m create virtualenv and install dependencies"
|
||||||
|
@echo " \033[1mmake web-api-dev\033[0m launch web api development server"
|
||||||
|
@echo
|
||||||
|
|
||||||
|
venv:
|
||||||
|
python3 -m venv venv
|
||||||
|
. ./venv/bin/activate && pip3 install -r requirements.txt
|
||||||
|
|
||||||
|
web-api-dev:
|
||||||
|
. ./venv/bin/activate && FLASK_ENV=development python3 src/web_api.py
|
||||||
|
|
||||||
|
install: check-root
|
||||||
|
for name in @(PROGRAMS); do ln -s src/${name}.py $(USER_PREFIX)/bin/$name; done
|
||||||
|
|
||||||
|
install-tools: check-root
|
||||||
|
$(INSTALL) tools/clickhouse-backup.sh $(GLOBAL_PREFIX)/bin
|
||||||
|
chmod +x $(GLOBAL_PREFIX)/bin/clickhouse-backup.sh
|
||||||
|
|
||||||
|
check-root:
|
||||||
|
ifneq ($(shell id -u), 0)
|
||||||
|
$(error "You must be root.")
|
||||||
|
endif
|
||||||
|
|
||||||
|
.PHONY: all install install-local install-tools venv web-api-dev check-root
|
23
assets/mqtt_ca.crt
Normal file
23
assets/mqtt_ca.crt
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
-----BEGIN CERTIFICATE-----
|
||||||
|
MIID4zCCAsugAwIBAgIUcW9D2Yym/nNf//Sfv1G8kwpEBCMwDQYJKoZIhvcNAQEL
|
||||||
|
BQAwgYAxCzAJBgNVBAYTAlJVMQ8wDQYDVQQIDAZNb3Njb3cxDzANBgNVBAcMBk1v
|
||||||
|
c2NvdzEUMBIGA1UECgwLU29sYXJNb24uUlUxFzAVBgNVBAMMDmNhLnNvbGFybW9u
|
||||||
|
LnJ1MSAwHgYJKoZIhvcNAQkBFhFhZG1pbkBzb2xhcm1vbi5ydTAeFw0yMTA1MTYx
|
||||||
|
NzI2MjRaFw0zMTA1MTQxNzI2MjRaMIGAMQswCQYDVQQGEwJSVTEPMA0GA1UECAwG
|
||||||
|
TW9zY293MQ8wDQYDVQQHDAZNb3Njb3cxFDASBgNVBAoMC1NvbGFyTW9uLlJVMRcw
|
||||||
|
FQYDVQQDDA5jYS5zb2xhcm1vbi5ydTEgMB4GCSqGSIb3DQEJARYRYWRtaW5Ac29s
|
||||||
|
YXJtb24ucnUwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDEEPOhEE74
|
||||||
|
LDWVhtY3fFQu1HD3GYv2b8SgXXk1evFs2QiLtw7wtvVG9jM+JjLadY50gMZYlrKe
|
||||||
|
NqFxj7OutTx0RnkFLQ0Q3xkEsQOlWVvgFf4qwZ8pEgAnmVGHQjBeM4vmgY0Dxnqd
|
||||||
|
GLrjLVKwEMYM1PiV3pp1vMDJGouoxp3bOL7Iz++/07Atim9g8RZ+gyw080JJUKdB
|
||||||
|
7alR3ZfND2GMFXd03aosE5c7YqIwjGrT73K4sdqP8ydwEPtjBfn4b746uERllsT1
|
||||||
|
EBc4Iv25RWdUy1p1YIaa8y9/34h7QPUSawjdnnL+Ktq9DCxv8WDKoSRK5E7bwswf
|
||||||
|
DKHFEmoI4IjHAgMBAAGjUzBRMB0GA1UdDgQWBBSqdoh/ZkUgfDWQoxjXU6CeIO4H
|
||||||
|
FDAfBgNVHSMEGDAWgBSqdoh/ZkUgfDWQoxjXU6CeIO4HFDAPBgNVHRMBAf8EBTAD
|
||||||
|
AQH/MA0GCSqGSIb3DQEBCwUAA4IBAQCM6JdaY+pT3E/8Tfz+M0R4kgqasyc9fAQP
|
||||||
|
g7tf2HrMPCtuIZF8aJYMNi0pfcnuUtr9FXFgGjyG+PZxqD2lHS+F/U5I8XqtTNJM
|
||||||
|
FW5Ls9dulRjmiGs0u8JbEX3igFTuCh0EZbtJgOLt2rOwSLv9PwI+ng4n8LBtbXVl
|
||||||
|
icfzWxGbnx/Bzoa7/Rk6Gs10Jf5bAeklchx/DbytSmoYSs9TxGdsrYkllznRts76
|
||||||
|
6DHptSctecdi0svL4cE9dXWl6OSgG674khWPTd0I9bcHgJCQ6T1gPLRpnFJJ1ZT6
|
||||||
|
ORgl25mkt+AX5U+naLMuUXU9TBKr3foxBMWqrSu5uC5K494Lbrvv
|
||||||
|
-----END CERTIFICATE-----
|
13
doc/arecord_opi_lite.md
Normal file
13
doc/arecord_opi_lite.md
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
In order to use microphone on **Orange Pi Lite**:
|
||||||
|
- enable audio codec in `armbian-config`
|
||||||
|
- put this to `/etc/rc.local` (and make it executable):
|
||||||
|
```
|
||||||
|
for v in unmute cap; do
|
||||||
|
/usr/bin/amixer set "Line In" $v
|
||||||
|
/usr/bin/amixer set "Mic1" $v
|
||||||
|
done
|
||||||
|
|
||||||
|
for k in "Mic1 Boost" "Line In" "Mic1"; do
|
||||||
|
/usr/bin/amixer set "$k" "86%"
|
||||||
|
done
|
||||||
|
```
|
19
doc/autossh.md
Normal file
19
doc/autossh.md
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
`/etc/systemd/system/my-ssh-tunnel.service`:
|
||||||
|
|
||||||
|
```
|
||||||
|
[Unit]
|
||||||
|
Description=ssh tunnel for localhost:22
|
||||||
|
After=network.target
|
||||||
|
StartLimitIntervalSec=0
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=user
|
||||||
|
Group=user
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=15
|
||||||
|
ExecStart=autossh -M 20001 -N -R 127.0.0.1:44223:127.0.0.1:22 -o StrictHostKeyChecking=no -o ExitOnForwardFailure=yes solarmon-tunnel@solarmon.ru
|
||||||
|
WorkingDirectory=/home/user
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
65
doc/database.md
Normal file
65
doc/database.md
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
# Databases
|
||||||
|
|
||||||
|
## Inverter database
|
||||||
|
|
||||||
|
ClickHouse tables:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE status (
|
||||||
|
ClientTime DateTime,
|
||||||
|
ReceivedTime DateTime,
|
||||||
|
HomeID UInt16,
|
||||||
|
GridVoltage UInt16,
|
||||||
|
GridFrequency UInt16,
|
||||||
|
ACOutputVoltage UInt16,
|
||||||
|
ACOutputFrequency UInt16,
|
||||||
|
ACOutputApparentPower UInt16,
|
||||||
|
ACOutputActivePower UInt16,
|
||||||
|
OutputLoadPercent UInt8,
|
||||||
|
BatteryVoltage UInt16,
|
||||||
|
BatteryVoltageSCC UInt16,
|
||||||
|
BatteryVoltageSCC2 UInt16,
|
||||||
|
BatteryDischargingCurrent UInt16,
|
||||||
|
BatteryChargingCurrent UInt16,
|
||||||
|
BatteryCapacity UInt8,
|
||||||
|
HeatSinkTemp UInt16,
|
||||||
|
MPPT1ChargerTemp UInt16,
|
||||||
|
MPPT2ChargerTemp UInt16,
|
||||||
|
PV1InputPower UInt16,
|
||||||
|
PV2InputPower UInt16,
|
||||||
|
PV1InputVoltage UInt16,
|
||||||
|
PV2InputVoltage UInt16,
|
||||||
|
MPPT1ChargerStatus Enum8('Abnormal' = 0, 'NotCharging' = 1, 'Charging' = 2),
|
||||||
|
MPPT2ChargerStatus Enum8('Abnormal' = 0, 'NotCharging' = 1, 'Charging' = 2),
|
||||||
|
BatteryPowerDirection Enum8('DoNothing' = 0, 'Charge' = 1, 'Discharge' = 2),
|
||||||
|
DCACPowerDirection Enum8('DoNothing' = 0, 'AC/DC' = 1, 'DC/AC' = 2),
|
||||||
|
LinePowerDirection Enum8('DoNothing' = 0, 'Input' = 1, 'Output' = 2),
|
||||||
|
LoadConnected Enum8('Disconnected' = 0, 'Connected' = 1)
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
PARTITION BY toYYYYMMDD(ReceivedTime)
|
||||||
|
ORDER BY (HomeID, ReceivedTime);
|
||||||
|
|
||||||
|
CREATE TABLE generation (
|
||||||
|
ClientTime DateTime,
|
||||||
|
ReceivedTime DateTime,
|
||||||
|
HomeID UInt16,
|
||||||
|
Watts UInt16
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
PARTITION BY toYYYYMMDD(ReceivedTime)
|
||||||
|
ORDER BY (HomeID, ReceivedTime);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## Sensors database
|
||||||
|
|
||||||
|
ClickHouse tables:
|
||||||
|
```sql
|
||||||
|
CREATE TABLE temp_table_name (
|
||||||
|
ClientTime DateTime,
|
||||||
|
ReceivedTime DateTime,
|
||||||
|
HomeID UInt16,
|
||||||
|
Temperature Int16,
|
||||||
|
RelativeHumidity UInt16
|
||||||
|
) ENGINE = MergeTree()
|
||||||
|
PARTITION BY toYYYYMMDD(ReceivedTime)
|
||||||
|
ORDER BY (HomeID, ReceivedTime);
|
||||||
|
```
|
7
doc/gpio_h3.md
Normal file
7
doc/gpio_h3.md
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
As root:
|
||||||
|
|
||||||
|
```
|
||||||
|
git clone https://github.com/duxingkei33/orangepi_PC_gpio_pyH3
|
||||||
|
cd orangepi_PC_gpio_pyH3
|
||||||
|
python3 setup.pysdlfksdf install
|
||||||
|
```
|
76
doc/inverter_bot.md
Normal file
76
doc/inverter_bot.md
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
# Inverter Bot
|
||||||
|
|
||||||
|
### Bot configuration
|
||||||
|
|
||||||
|
**`~/.config/inverter_bot/config.toml`**:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[bot]
|
||||||
|
token = "..."
|
||||||
|
users = [ 1, 2, 3 ]
|
||||||
|
notify_users = [ 1, 2 ]
|
||||||
|
|
||||||
|
[inverter]
|
||||||
|
host = "127.0.0.1"
|
||||||
|
port = 8305
|
||||||
|
|
||||||
|
[monitor]
|
||||||
|
vlow = 47
|
||||||
|
vcrit = 45
|
||||||
|
|
||||||
|
gen_currents = [2, 10, 20, 30]
|
||||||
|
gen_raise_intervals = [
|
||||||
|
180, # 3 minutes for 2 A, then
|
||||||
|
120, # 2 more minutes for 10 A, then
|
||||||
|
120, # 3 more minutes for 20 A, then, finally, 30 A
|
||||||
|
]
|
||||||
|
gen_cur30_v_limit = 56.9
|
||||||
|
gen_cur20_v_limit = 56.7
|
||||||
|
gen_cur10_v_limit = 54
|
||||||
|
|
||||||
|
gen_floating_v = 54
|
||||||
|
gen_floating_time_max = 7200
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
verbose = false
|
||||||
|
|
||||||
|
[api]
|
||||||
|
token = "..."
|
||||||
|
```
|
||||||
|
|
||||||
|
### systemd integration
|
||||||
|
|
||||||
|
**`/etc/systemd/system/inverter_bot.service`**:
|
||||||
|
|
||||||
|
```systemd
|
||||||
|
[Unit]
|
||||||
|
Description=inverter bot
|
||||||
|
After=inverterd.service
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=user
|
||||||
|
Group=user
|
||||||
|
Restart=on-failure
|
||||||
|
ExecStart=/home/user/home/bin/inverter_bot
|
||||||
|
WorkingDirectory=/home/user
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
### Commands
|
||||||
|
```
|
||||||
|
lang - Set language
|
||||||
|
status - Show status
|
||||||
|
config - Show configuration
|
||||||
|
errors - Show errors
|
||||||
|
flags - Toggle flags
|
||||||
|
calcw - Calculate daily watts usage
|
||||||
|
calcwadv - Advanced watts usage calculator
|
||||||
|
setbatuv - Set battery under voltage
|
||||||
|
setgencc - Set AC charging current
|
||||||
|
setgenct - Set AC charging thresholds
|
||||||
|
monstatus - Monitor: dump state
|
||||||
|
monsetcur - Monitor: set charging currents
|
||||||
|
```
|
33
doc/sensors_bot.md
Normal file
33
doc/sensors_bot.md
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
# Sensors Bot
|
||||||
|
|
||||||
|
Configuration is stored in **`~/.config/sensors_bot/config.toml`**.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[bot]
|
||||||
|
token = "..."
|
||||||
|
users = [
|
||||||
|
1, # user 1
|
||||||
|
2, # user 2
|
||||||
|
3, # user 3
|
||||||
|
]
|
||||||
|
|
||||||
|
[api]
|
||||||
|
token = ..."
|
||||||
|
|
||||||
|
[sensors.name1]
|
||||||
|
ip = "192.168.0.2"
|
||||||
|
port = 8306
|
||||||
|
label_ru = "Тут"
|
||||||
|
label_en = "Here"
|
||||||
|
|
||||||
|
[sensors.name2]
|
||||||
|
ip = "192.168.0.3"
|
||||||
|
port = 8307
|
||||||
|
label_ru = "Там"
|
||||||
|
label_en = "There"
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
verbose = false
|
||||||
|
```
|
72
doc/sound_node.md
Normal file
72
doc/sound_node.md
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
# Sound Node
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
apt install -y python3-aiohttp python3-requests python3-toml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Orange Pi Lite config (`/etc/sound_node.toml`):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[node]
|
||||||
|
listen = "0.0.0.0:8313"
|
||||||
|
process_wait_timeout = 10
|
||||||
|
name = "nodename"
|
||||||
|
|
||||||
|
record_max_time = 1800
|
||||||
|
storage = "/var/recordings"
|
||||||
|
|
||||||
|
[arecord]
|
||||||
|
bin = "/usr/bin/arecord"
|
||||||
|
|
||||||
|
[lame]
|
||||||
|
bin = "/usr/bin/lame"
|
||||||
|
bitrate = 192
|
||||||
|
|
||||||
|
[amixer]
|
||||||
|
bin = "/usr/bin/amixer"
|
||||||
|
controls = [
|
||||||
|
{
|
||||||
|
name = "Line In",
|
||||||
|
caps = ["mute", "cap", "volume"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "Mic1",
|
||||||
|
caps = ["mute", "cap", "volume"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "Mic1 Boost",
|
||||||
|
caps = ["volume"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
verbose = false
|
||||||
|
default_fmt = true
|
||||||
|
```
|
||||||
|
|
||||||
|
## Audio recording
|
||||||
|
|
||||||
|
Install `lame`.
|
||||||
|
|
||||||
|
Command to record audio: `arecord -v -f S16 -r 44100 -t raw 2>/dev/null | lame -r -s 44.1 -b 192 -m m - output.mp3 >/dev/null 2>/dev/null`
|
||||||
|
|
||||||
|
## Uploading audios to remote server
|
||||||
|
|
||||||
|
- Generate ssh keys for root on each sound node:
|
||||||
|
```
|
||||||
|
cd /root/.ssh
|
||||||
|
ssh-keygen -t ed25519
|
||||||
|
```
|
||||||
|
- Add public keys on the remote server
|
||||||
|
- Copy `tools/sync-recordings-to-remote.sh` script to `/usr/local/bin` on all sound nodes, don't forget to `chmod +x` it.
|
||||||
|
- Add following lines to the root crontab (on all sound nodes):
|
||||||
|
```
|
||||||
|
TG_TOKEN="your telegram bot token"
|
||||||
|
TG_CHAT_ID="your telegram chat id"
|
||||||
|
|
||||||
|
30 * * * * /usr/local/bin/sync-recordings-to-remote.sh
|
||||||
|
```
|
12
doc/test_api.md
Normal file
12
doc/test_api.md
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
# test_api.py
|
||||||
|
|
||||||
|
Config example:
|
||||||
|
```toml
|
||||||
|
[api]
|
||||||
|
host = "app-dev.domain.ru"
|
||||||
|
token = ""
|
||||||
|
basic_auth = "user:password"
|
||||||
|
|
||||||
|
[logging]
|
||||||
|
verbose = true
|
||||||
|
```
|
0
pyA20/__init__.pyi
Normal file
0
pyA20/__init__.pyi
Normal file
2
pyA20/gpio/connector.pyi
Normal file
2
pyA20/gpio/connector.pyi
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
gpio1 = 0
|
||||||
|
LED = 0
|
24
pyA20/gpio/gpio.pyi
Normal file
24
pyA20/gpio/gpio.pyi
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
HIGH = 1
|
||||||
|
LOW = 0
|
||||||
|
INPUT = 0
|
||||||
|
OUTPUT = 0
|
||||||
|
PULLUP = 0
|
||||||
|
PULLDOWN = 0
|
||||||
|
|
||||||
|
def init():
|
||||||
|
pass
|
||||||
|
|
||||||
|
def setcfg(gpio: int, cfg: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def getcfg(gpio: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def output(gpio: int, value: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def pullup(gpio: int, pull: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def input(gpio: int):
|
||||||
|
pass
|
36
pyA20/gpio/port.pyi
Normal file
36
pyA20/gpio/port.pyi
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
# these are not real values, just placeholders
|
||||||
|
|
||||||
|
PA12 = 0
|
||||||
|
PA11 = 0
|
||||||
|
PA6 = 0
|
||||||
|
|
||||||
|
PA1 = 0
|
||||||
|
PA0 = 0
|
||||||
|
|
||||||
|
PA3 = 0
|
||||||
|
PC0 = 0
|
||||||
|
PC1 = 0
|
||||||
|
PC2 = 0
|
||||||
|
PA19 = 0
|
||||||
|
PA7 = 0
|
||||||
|
PA8 = 0
|
||||||
|
PA9 = 0
|
||||||
|
PA10 = 0
|
||||||
|
PA20 = 0
|
||||||
|
|
||||||
|
PA13 = 0
|
||||||
|
PA14 = 0
|
||||||
|
PD14 = 0
|
||||||
|
PC4 = 0
|
||||||
|
PC7 = 0
|
||||||
|
PA2 = 0
|
||||||
|
PC3 = 0
|
||||||
|
PA21 = 0
|
||||||
|
PA18 = 0
|
||||||
|
PG8 = 0
|
||||||
|
PG9 = 0
|
||||||
|
PG6 = 0
|
||||||
|
PG7 = 0
|
||||||
|
|
||||||
|
POWER_LED = 0
|
||||||
|
STATUS_LED = 0
|
0
pyA20/port.pyi
Normal file
0
pyA20/port.pyi
Normal file
17
requirements.txt
Normal file
17
requirements.txt
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
paho-mqtt~=1.5.1
|
||||||
|
inverterd~=1.0.3
|
||||||
|
clickhouse-driver~=0.2.0
|
||||||
|
toml~=0.10.2
|
||||||
|
Flask~=2.0.2
|
||||||
|
mysql-connector-python~=8.0.27
|
||||||
|
Werkzeug~=2.0.2
|
||||||
|
uwsgi~=2.0.20
|
||||||
|
python-telegram-bot~=13.1
|
||||||
|
inverterd~=1.0.2
|
||||||
|
requests~=2.26.0
|
||||||
|
aiohttp~=3.8.1
|
||||||
|
pytz~=2021.3
|
||||||
|
|
||||||
|
# following can be installed from debian repositories
|
||||||
|
# matplotlib~=3.5.0
|
||||||
|
|
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
35
src/admin_bot.py
Executable file
35
src/admin_bot.py
Executable file
@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from typing import Optional
|
||||||
|
from telegram import ReplyKeyboardMarkup
|
||||||
|
from telegram.ext import MessageHandler
|
||||||
|
from home.config import config
|
||||||
|
from home.bot import Wrapper, Context, text_filter
|
||||||
|
|
||||||
|
|
||||||
|
def get_latest_logs(ctx: Context):
|
||||||
|
u = ctx.user
|
||||||
|
ctx.reply(ctx.lang('blbla'))
|
||||||
|
|
||||||
|
|
||||||
|
class AdminBot(Wrapper):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.lang.ru(get_latest_logs="Смотреть последние логи")
|
||||||
|
self.lang.en(get_latest_logs="Get latest logs")
|
||||||
|
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang('get_latest_logs')), self.wrap(get_latest_logs)))
|
||||||
|
|
||||||
|
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
|
||||||
|
buttons = [
|
||||||
|
[self.lang('get_latest_logs')]
|
||||||
|
]
|
||||||
|
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('admin_bot')
|
||||||
|
|
||||||
|
bot = AdminBot()
|
||||||
|
# bot.enable_logging(BotType.ADMIN)
|
||||||
|
bot.run()
|
24
src/gpiorelayd.py
Executable file
24
src/gpiorelayd.py
Executable file
@ -0,0 +1,24 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from home.config import config
|
||||||
|
from home.util import parse_addr
|
||||||
|
from home.relay.server import RelayServer
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.getegid() == 0:
|
||||||
|
sys.exit('Must be run as root.')
|
||||||
|
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
try:
|
||||||
|
s = RelayServer(pinname=config['relayd']['pin'],
|
||||||
|
addr=parse_addr(config['relayd']['listen']))
|
||||||
|
s.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.info('Exiting...')
|
0
src/home/__init__.py
Normal file
0
src/home/__init__.py
Normal file
11
src/home/api/__init__.py
Normal file
11
src/home/api/__init__.py
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
__all__ = ['WebAPIClient', 'RequestParams']
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name in __all__:
|
||||||
|
module = importlib.import_module(f'.web_api_client', __name__)
|
||||||
|
return getattr(module, name)
|
||||||
|
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
4
src/home/api/__init__.pyi
Normal file
4
src/home/api/__init__.pyi
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
from .web_api_client import (
|
||||||
|
RequestParams as RequestParams,
|
||||||
|
WebAPIClient as WebAPIClient
|
||||||
|
)
|
1
src/home/api/errors/__init__.py
Normal file
1
src/home/api/errors/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .api_response_error import ApiResponseError
|
28
src/home/api/errors/api_response_error.py
Normal file
28
src/home/api/errors/api_response_error.py
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
class ApiResponseError(Exception):
|
||||||
|
def __init__(self,
|
||||||
|
status_code: int,
|
||||||
|
error_type: str,
|
||||||
|
error_message: str,
|
||||||
|
error_stacktrace: Optional[list[str]] = None):
|
||||||
|
super().__init__()
|
||||||
|
self.status_code = status_code
|
||||||
|
self.error_message = error_message
|
||||||
|
self.error_type = error_type
|
||||||
|
self.error_stacktrace = error_stacktrace
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
def st_formatter(line: str):
|
||||||
|
return f'Remote| {line}'
|
||||||
|
|
||||||
|
s = f'{self.error_type}: {self.error_message} (HTTP {self.status_code})'
|
||||||
|
if self.error_stacktrace is not None:
|
||||||
|
st = []
|
||||||
|
for st_line in self.error_stacktrace:
|
||||||
|
st.append('\n'.join(st_formatter(st_subline) for st_subline in st_line.split('\n')))
|
||||||
|
s += '\nRemote stacktrace:\n'
|
||||||
|
s += '\n'.join(st)
|
||||||
|
|
||||||
|
return s
|
6
src/home/api/types/__init__.py
Normal file
6
src/home/api/types/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from .types import (
|
||||||
|
BotType,
|
||||||
|
TemperatureSensorDataType,
|
||||||
|
TemperatureSensorLocation,
|
||||||
|
SoundSensorLocation
|
||||||
|
)
|
29
src/home/api/types/types.py
Normal file
29
src/home/api/types/types.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
from enum import Enum, auto
|
||||||
|
|
||||||
|
|
||||||
|
class BotType(Enum):
|
||||||
|
INVERTER = auto()
|
||||||
|
PUMP = auto()
|
||||||
|
SENSORS = auto()
|
||||||
|
ADMIN = auto()
|
||||||
|
SOUND = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureSensorLocation(Enum):
|
||||||
|
BIG_HOUSE_1 = auto()
|
||||||
|
BIG_HOUSE_2 = auto()
|
||||||
|
STREET = auto()
|
||||||
|
DIANA = auto()
|
||||||
|
SPB1 = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class TemperatureSensorDataType(Enum):
|
||||||
|
TEMPERATURE = auto()
|
||||||
|
RELATIVE_HUMIDITY = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class SoundSensorLocation(Enum):
|
||||||
|
DIANA = auto()
|
||||||
|
BIG_HOUSE = auto()
|
||||||
|
SPB1 = auto()
|
||||||
|
|
210
src/home/api/web_api_client.py
Normal file
210
src/home/api/web_api_client.py
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
import requests
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from collections import namedtuple
|
||||||
|
from datetime import datetime
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Optional, Callable, Union
|
||||||
|
from requests.auth import HTTPBasicAuth
|
||||||
|
|
||||||
|
from .errors import ApiResponseError
|
||||||
|
from .types import *
|
||||||
|
from ..config import config
|
||||||
|
from ..util import stringify
|
||||||
|
from ..sound import RecordFile, SoundNodeClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
RequestParams = namedtuple('RequestParams', 'params, files, method')
|
||||||
|
|
||||||
|
|
||||||
|
class HTTPMethod(Enum):
|
||||||
|
GET = auto()
|
||||||
|
POST = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class WebAPIClient:
|
||||||
|
token: str
|
||||||
|
timeout: Union[float, tuple[float, float]]
|
||||||
|
basic_auth: Optional[HTTPBasicAuth]
|
||||||
|
do_async: bool
|
||||||
|
async_error_handler: Optional[Callable]
|
||||||
|
async_success_handler: Optional[Callable]
|
||||||
|
|
||||||
|
def __init__(self, timeout: Union[float, tuple[float, float]] = 5):
|
||||||
|
self.token = config['api']['token']
|
||||||
|
self.timeout = timeout
|
||||||
|
self.basic_auth = None
|
||||||
|
self.do_async = False
|
||||||
|
self.async_error_handler = None
|
||||||
|
self.async_success_handler = None
|
||||||
|
|
||||||
|
if 'basic_auth' in config['api']:
|
||||||
|
ba = config['api']['basic_auth']
|
||||||
|
col = ba.index(':')
|
||||||
|
|
||||||
|
user = ba[:col]
|
||||||
|
pw = ba[col+1:]
|
||||||
|
|
||||||
|
logger.debug(f'enabling basic auth: {user}:{pw}')
|
||||||
|
self.basic_auth = HTTPBasicAuth(user, pw)
|
||||||
|
|
||||||
|
# api methods
|
||||||
|
# -----------
|
||||||
|
|
||||||
|
def log_bot_request(self,
|
||||||
|
bot: BotType,
|
||||||
|
user_id: int,
|
||||||
|
message: str):
|
||||||
|
return self._post('logs/bot-request/', {
|
||||||
|
'bot': bot.value,
|
||||||
|
'user_id': str(user_id),
|
||||||
|
'message': message
|
||||||
|
})
|
||||||
|
|
||||||
|
def log_openwrt(self,
|
||||||
|
lines: list[tuple[int, str]]):
|
||||||
|
return self._post('logs/openwrt', {
|
||||||
|
'logs': stringify(lines)
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_sensors_data(self,
|
||||||
|
sensor: TemperatureSensorLocation,
|
||||||
|
hours: int):
|
||||||
|
data = self._get('sensors/data/', {
|
||||||
|
'sensor': sensor.value,
|
||||||
|
'hours': hours
|
||||||
|
})
|
||||||
|
return [(datetime.fromtimestamp(date), temp, hum) for date, temp, hum in data]
|
||||||
|
|
||||||
|
def add_sound_sensor_hits(self,
|
||||||
|
hits: list[tuple[str, int]]):
|
||||||
|
return self._post('sound_sensors/hits/', {
|
||||||
|
'hits': stringify(hits)
|
||||||
|
})
|
||||||
|
|
||||||
|
def get_sound_sensor_hits(self,
|
||||||
|
location: SoundSensorLocation,
|
||||||
|
after: datetime) -> list[dict]:
|
||||||
|
return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', {
|
||||||
|
'after': int(after.timestamp()),
|
||||||
|
'location': location.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
def get_last_sound_sensor_hits(self, location: SoundSensorLocation, last: int):
|
||||||
|
return self._process_sound_sensor_hits_data(self._get('sound_sensors/hits/', {
|
||||||
|
'last': last,
|
||||||
|
'location': location.value
|
||||||
|
}))
|
||||||
|
|
||||||
|
def recordings_list(self, extended=False, as_objects=False) -> Union[list[str], list[dict], list[RecordFile]]:
|
||||||
|
files = self._get('recordings/list/', {'extended': int(extended)})['data']
|
||||||
|
if as_objects:
|
||||||
|
return SoundNodeClient.record_list_from_serialized(files)
|
||||||
|
return files
|
||||||
|
|
||||||
|
def _process_sound_sensor_hits_data(self, data: list[dict]) -> list[dict]:
|
||||||
|
for item in data:
|
||||||
|
item['time'] = datetime.fromtimestamp(item['time'])
|
||||||
|
return data
|
||||||
|
|
||||||
|
# internal methods
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
def _get(self, *args, **kwargs):
|
||||||
|
return self._call(method=HTTPMethod.GET, *args, **kwargs)
|
||||||
|
|
||||||
|
def _post(self, *args, **kwargs):
|
||||||
|
return self._call(method=HTTPMethod.POST, *args, **kwargs)
|
||||||
|
|
||||||
|
def _call(self,
|
||||||
|
name: str,
|
||||||
|
params: dict,
|
||||||
|
method: HTTPMethod,
|
||||||
|
files: Optional[dict[str, str]] = None):
|
||||||
|
if not self.do_async:
|
||||||
|
return self._make_request(name, params, method, files)
|
||||||
|
else:
|
||||||
|
t = threading.Thread(target=self._make_request_in_thread, args=(name, params, method, files))
|
||||||
|
t.start()
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _make_request(self,
|
||||||
|
name: str,
|
||||||
|
params: dict,
|
||||||
|
method: HTTPMethod = HTTPMethod.GET,
|
||||||
|
files: Optional[dict[str, str]] = None) -> Optional[any]:
|
||||||
|
domain = config['api']['host']
|
||||||
|
kwargs = {}
|
||||||
|
|
||||||
|
if self.basic_auth is not None:
|
||||||
|
kwargs['auth'] = self.basic_auth
|
||||||
|
|
||||||
|
if method == HTTPMethod.GET:
|
||||||
|
if files:
|
||||||
|
raise RuntimeError('can\'t upload files using GET, please use me properly')
|
||||||
|
kwargs['params'] = params
|
||||||
|
f = requests.get
|
||||||
|
else:
|
||||||
|
kwargs['data'] = params
|
||||||
|
f = requests.post
|
||||||
|
|
||||||
|
fd = {}
|
||||||
|
if files:
|
||||||
|
for fname, fpath in files.items():
|
||||||
|
fd[fname] = open(fpath, 'rb')
|
||||||
|
kwargs['files'] = fd
|
||||||
|
|
||||||
|
try:
|
||||||
|
r = f(f'https://{domain}/api/{name}',
|
||||||
|
headers={'X-Token': self.token},
|
||||||
|
timeout=self.timeout,
|
||||||
|
**kwargs)
|
||||||
|
|
||||||
|
if r.headers['content-type'] != 'application/json':
|
||||||
|
raise ApiResponseError(r.status_code, 'TypeError', 'content-type is not application/json')
|
||||||
|
|
||||||
|
data = json.loads(r.text)
|
||||||
|
if r.status_code != 200 or data['result'] == 'error':
|
||||||
|
raise ApiResponseError(r.status_code,
|
||||||
|
data['error']['type'],
|
||||||
|
data['error']['message'],
|
||||||
|
data['error']['stacktrace'] if 'stacktrace' in data['error'] else None)
|
||||||
|
|
||||||
|
return data['data'] if 'data' in data else True
|
||||||
|
finally:
|
||||||
|
for fname, f in fd.items():
|
||||||
|
# logger.debug(f'closing file {fname} (fd={f})')
|
||||||
|
try:
|
||||||
|
f.close()
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _make_request_in_thread(self, name, params, method, files):
|
||||||
|
try:
|
||||||
|
result = self._make_request(name, params, method, files)
|
||||||
|
self._report_async_success(result, name, RequestParams(params=params, method=method, files=files))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
self._report_async_error(e, name, RequestParams(params=params, method=method, files=files))
|
||||||
|
|
||||||
|
def enable_async(self,
|
||||||
|
success_handler: Optional[Callable] = None,
|
||||||
|
error_handler: Optional[Callable] = None):
|
||||||
|
self.do_async = True
|
||||||
|
if error_handler:
|
||||||
|
self.async_error_handler = error_handler
|
||||||
|
if success_handler:
|
||||||
|
self.async_success_handler = success_handler
|
||||||
|
|
||||||
|
def _report_async_error(self, *args):
|
||||||
|
if self.async_error_handler:
|
||||||
|
self.async_error_handler(*args)
|
||||||
|
|
||||||
|
def _report_async_success(self, *args):
|
||||||
|
if self.async_success_handler:
|
||||||
|
self.async_success_handler(*args)
|
6
src/home/bot/__init__.py
Normal file
6
src/home/bot/__init__.py
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
from .reporting import ReportingHelper
|
||||||
|
from .lang import LangPack
|
||||||
|
from .wrapper import Wrapper, Context, text_filter
|
||||||
|
from .store import Store
|
||||||
|
from .errors import *
|
||||||
|
from .util import command_usage, user_any_name
|
2
src/home/bot/errors.py
Normal file
2
src/home/bot/errors.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
class StoreNotEnabledError(Exception):
|
||||||
|
pass
|
76
src/home/bot/lang.py
Normal file
76
src/home/bot/lang.py
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from typing import Union, Optional
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class LangStrings(dict):
|
||||||
|
_lang: Optional[str]
|
||||||
|
|
||||||
|
def __init__(self, *args, **kwargs):
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
self._lang = None
|
||||||
|
|
||||||
|
def setlang(self, lang: str):
|
||||||
|
self._lang = lang
|
||||||
|
|
||||||
|
def __missing__(self, key):
|
||||||
|
logger.warning(f'key {key} is missing in language {self._lang}')
|
||||||
|
return '{%s}' % key
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
raise NotImplementedError(f'setting translation strings this way is prohibited (was trying to set {key}={value})')
|
||||||
|
|
||||||
|
|
||||||
|
class LangPack:
|
||||||
|
strings: dict[str, LangStrings[str, str]]
|
||||||
|
default_lang: str
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.strings = {}
|
||||||
|
self.default_lang = 'en'
|
||||||
|
|
||||||
|
def ru(self, **kwargs) -> None:
|
||||||
|
self.set(kwargs, 'ru')
|
||||||
|
|
||||||
|
def en(self, **kwargs) -> None:
|
||||||
|
self.set(kwargs, 'en')
|
||||||
|
|
||||||
|
def set(self,
|
||||||
|
strings: Union[LangStrings, dict],
|
||||||
|
lang: str) -> None:
|
||||||
|
|
||||||
|
if isinstance(strings, dict) and not isinstance(strings, LangStrings):
|
||||||
|
strings = LangStrings(**strings)
|
||||||
|
strings.setlang(lang)
|
||||||
|
|
||||||
|
if lang not in self.strings:
|
||||||
|
self.strings[lang] = strings
|
||||||
|
else:
|
||||||
|
self.strings[lang].update(strings)
|
||||||
|
|
||||||
|
def all(self, key):
|
||||||
|
result = []
|
||||||
|
for strings in self.strings.values():
|
||||||
|
result.append(strings[key])
|
||||||
|
return result
|
||||||
|
|
||||||
|
@property
|
||||||
|
def languages(self) -> list[str]:
|
||||||
|
return list(self.strings.keys())
|
||||||
|
|
||||||
|
def get(self, key: str, lang: str, *args) -> str:
|
||||||
|
return self.strings[lang][key] % args
|
||||||
|
|
||||||
|
def __call__(self, *args, **kwargs):
|
||||||
|
return self.strings[self.default_lang][args[0]]
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.strings[self.default_lang][key]
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
raise NotImplementedError('setting translation strings this way is prohibited')
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return key in self.strings[self.default_lang]
|
22
src/home/bot/reporting.py
Normal file
22
src/home/bot/reporting.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from telegram import Message
|
||||||
|
from ..api import WebAPIClient as APIClient
|
||||||
|
from ..api.errors import ApiResponseError
|
||||||
|
from ..api.types import BotType
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class ReportingHelper:
|
||||||
|
def __init__(self, client: APIClient, bot_type: BotType):
|
||||||
|
self.client = client
|
||||||
|
self.bot_type = bot_type
|
||||||
|
|
||||||
|
def report(self, message, text: str = None) -> None:
|
||||||
|
if text is None:
|
||||||
|
text = message.text
|
||||||
|
try:
|
||||||
|
self.client.log_bot_request(self.bot_type, message.chat_id, text)
|
||||||
|
except ApiResponseError as error:
|
||||||
|
logger.exception(error)
|
80
src/home/bot/store.py
Normal file
80
src/home/bot/store.py
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import sqlite3
|
||||||
|
import os.path
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_database_path() -> str:
|
||||||
|
return os.path.join(os.environ['HOME'], '.config', config.app_name, 'bot.db')
|
||||||
|
|
||||||
|
|
||||||
|
class Store:
|
||||||
|
SCHEMA_VERSION = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.sqlite = sqlite3.connect(_get_database_path(), check_same_thread=False)
|
||||||
|
|
||||||
|
sqlite_version = self._get_sqlite_version()
|
||||||
|
logger.info(f'SQLite version: {sqlite_version}')
|
||||||
|
|
||||||
|
schema_version = self._get_schema_version()
|
||||||
|
logger.info(f'Schema version: {schema_version}')
|
||||||
|
|
||||||
|
if schema_version < 1:
|
||||||
|
self._database_init()
|
||||||
|
elif schema_version < Store.SCHEMA_VERSION:
|
||||||
|
self._database_upgrade(Store.SCHEMA_VERSION)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if self.sqlite:
|
||||||
|
self.sqlite.commit()
|
||||||
|
self.sqlite.close()
|
||||||
|
|
||||||
|
def _get_sqlite_version(self) -> str:
|
||||||
|
cursor = self.sqlite.cursor()
|
||||||
|
cursor.execute("SELECT sqlite_version()")
|
||||||
|
|
||||||
|
return cursor.fetchone()[0]
|
||||||
|
|
||||||
|
def _get_schema_version(self) -> int:
|
||||||
|
cursor = self.sqlite.execute('PRAGMA user_version')
|
||||||
|
return int(cursor.fetchone()[0])
|
||||||
|
|
||||||
|
def _set_schema_version(self, v) -> None:
|
||||||
|
self.sqlite.execute('PRAGMA user_version={:d}'.format(v))
|
||||||
|
logger.info(f'Schema set to {v}')
|
||||||
|
|
||||||
|
def _database_init(self) -> None:
|
||||||
|
cursor = self.sqlite.cursor()
|
||||||
|
cursor.execute("""CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
lang TEXT NOT NULL
|
||||||
|
)""")
|
||||||
|
self.sqlite.commit()
|
||||||
|
self._set_schema_version(1)
|
||||||
|
|
||||||
|
def _database_upgrade(self, version: int) -> None:
|
||||||
|
# do the upgrade here
|
||||||
|
|
||||||
|
# self.sqlite.commit()
|
||||||
|
self._set_schema_version(version)
|
||||||
|
|
||||||
|
def get_user_lang(self, user_id: int, default: str = 'en') -> str:
|
||||||
|
cursor = self.sqlite.cursor()
|
||||||
|
cursor.execute('SELECT lang FROM users WHERE id=?', (user_id,))
|
||||||
|
row = cursor.fetchone()
|
||||||
|
|
||||||
|
if row is None:
|
||||||
|
cursor.execute('INSERT INTO users (id, lang) VALUES (?, ?)', (user_id, default))
|
||||||
|
self.sqlite.commit()
|
||||||
|
return default
|
||||||
|
else:
|
||||||
|
return row[0]
|
||||||
|
|
||||||
|
def set_user_lang(self, user_id: int, lang: str) -> None:
|
||||||
|
cursor = self.sqlite.cursor()
|
||||||
|
cursor.execute('UPDATE users SET lang=? WHERE id=?', (lang, user_id))
|
||||||
|
self.sqlite.commit()
|
57
src/home/bot/util.py
Normal file
57
src/home/bot/util.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
from telegram import User
|
||||||
|
from .lang import LangStrings
|
||||||
|
|
||||||
|
_strings = {
|
||||||
|
'en': LangStrings(
|
||||||
|
usage='Usage',
|
||||||
|
arguments='Arguments'
|
||||||
|
),
|
||||||
|
'ru': LangStrings(
|
||||||
|
usage='Использование',
|
||||||
|
arguments='Аргументы'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def command_usage(command: str, arguments: dict, language='en') -> str:
|
||||||
|
if language not in _strings:
|
||||||
|
raise ValueError('unsupported language')
|
||||||
|
|
||||||
|
blocks = []
|
||||||
|
argument_names = []
|
||||||
|
argument_lines = []
|
||||||
|
for k, v in arguments.items():
|
||||||
|
argument_names.append(k)
|
||||||
|
argument_lines.append(
|
||||||
|
f'<code>{k}</code>: {v}'
|
||||||
|
)
|
||||||
|
|
||||||
|
command = f'/{command}'
|
||||||
|
if argument_names:
|
||||||
|
command += ' ' + ' '.join(argument_names)
|
||||||
|
|
||||||
|
blocks.append(
|
||||||
|
f'<b>{_strings[language]["usage"]}</b>\n'
|
||||||
|
f'<code>{command}</code>'
|
||||||
|
)
|
||||||
|
|
||||||
|
if argument_lines:
|
||||||
|
blocks.append(
|
||||||
|
f'<b>{_strings[language]["arguments"]}</b>\n' + '\n'.join(argument_lines)
|
||||||
|
)
|
||||||
|
|
||||||
|
return '\n\n'.join(blocks)
|
||||||
|
|
||||||
|
|
||||||
|
def user_any_name(user: User) -> str:
|
||||||
|
name = [user.first_name, user.last_name]
|
||||||
|
name = list(filter(lambda s: s is not None, name))
|
||||||
|
name = ' '.join(name).strip()
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
name = user.username
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
name = str(user.id)
|
||||||
|
|
||||||
|
return name
|
339
src/home/bot/wrapper.py
Normal file
339
src/home/bot/wrapper.py
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
from html import escape
|
||||||
|
from telegram import (
|
||||||
|
Update,
|
||||||
|
ParseMode,
|
||||||
|
ReplyKeyboardMarkup,
|
||||||
|
CallbackQuery,
|
||||||
|
User,
|
||||||
|
)
|
||||||
|
from telegram.ext import (
|
||||||
|
Updater,
|
||||||
|
Filters,
|
||||||
|
BaseFilter,
|
||||||
|
Handler,
|
||||||
|
CommandHandler,
|
||||||
|
MessageHandler,
|
||||||
|
CallbackQueryHandler,
|
||||||
|
CallbackContext,
|
||||||
|
ConversationHandler
|
||||||
|
)
|
||||||
|
from telegram.error import TimedOut
|
||||||
|
from ..config import config
|
||||||
|
from typing import Optional, Union
|
||||||
|
from .store import Store
|
||||||
|
from .lang import LangPack
|
||||||
|
from ..api.types import BotType
|
||||||
|
from ..api import WebAPIClient
|
||||||
|
from .reporting import ReportingHelper
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
languages = {
|
||||||
|
'en': 'English',
|
||||||
|
'ru': 'Русский'
|
||||||
|
}
|
||||||
|
LANG_STARTED = range(1)
|
||||||
|
user_filter: Optional[BaseFilter] = None
|
||||||
|
|
||||||
|
|
||||||
|
def default_langpack() -> LangPack:
|
||||||
|
lang = LangPack()
|
||||||
|
lang.en(
|
||||||
|
start_message="Select command on the keyboard.",
|
||||||
|
unknown_message="Unknown message",
|
||||||
|
cancel="Cancel",
|
||||||
|
select_language="Select language on the keyboard.",
|
||||||
|
invalid_language="Invalid language. Please try again.",
|
||||||
|
language_saved='Saved.',
|
||||||
|
)
|
||||||
|
lang.ru(
|
||||||
|
start_message="Выберите команду на клавиатуре.",
|
||||||
|
unknown_message="Неизвестная команда",
|
||||||
|
cancel="Отмена",
|
||||||
|
select_language="Выберите язык на клавиатуре.",
|
||||||
|
invalid_language="Неверный язык. Пожалуйста, попробуйте снова",
|
||||||
|
language_saved="Настройки сохранены."
|
||||||
|
)
|
||||||
|
return lang
|
||||||
|
|
||||||
|
|
||||||
|
def init_user_filter():
|
||||||
|
global user_filter
|
||||||
|
if user_filter is None:
|
||||||
|
if 'users' in config['bot']:
|
||||||
|
logger.info('allowed users: ' + str(config['bot']['users']))
|
||||||
|
user_filter = Filters.user(config['bot']['users'])
|
||||||
|
else:
|
||||||
|
user_filter = Filters.all # not sure if this is correct
|
||||||
|
|
||||||
|
|
||||||
|
def text_filter(*args):
|
||||||
|
init_user_filter()
|
||||||
|
return Filters.text(args[0] if isinstance(args[0], list) else [*args]) & user_filter
|
||||||
|
|
||||||
|
|
||||||
|
def exc2text(e: Exception) -> str:
|
||||||
|
tb = ''.join(traceback.format_tb(e.__traceback__))
|
||||||
|
return f'{e.__class__.__name__}: ' + escape(str(e)) + "\n\n" + escape(tb)
|
||||||
|
|
||||||
|
|
||||||
|
class IgnoreMarkup:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Context:
|
||||||
|
_update: Optional[Update]
|
||||||
|
_callback_context: Optional[CallbackContext]
|
||||||
|
_markup_getter: callable
|
||||||
|
_lang: LangPack
|
||||||
|
_store: Optional[Store]
|
||||||
|
_user_lang: Optional[str]
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
update: Optional[Update],
|
||||||
|
callback_context: Optional[CallbackContext],
|
||||||
|
markup_getter: callable,
|
||||||
|
lang: LangPack,
|
||||||
|
store: Optional[Store]):
|
||||||
|
self._update = update
|
||||||
|
self._callback_context = callback_context
|
||||||
|
self._markup_getter = markup_getter
|
||||||
|
self._lang = lang
|
||||||
|
self._store = store
|
||||||
|
self._user_lang = None
|
||||||
|
|
||||||
|
def reply(self, text, markup=None):
|
||||||
|
if markup is None:
|
||||||
|
markup = self._markup_getter(self)
|
||||||
|
kwargs = dict(parse_mode=ParseMode.HTML)
|
||||||
|
if not isinstance(markup, IgnoreMarkup):
|
||||||
|
kwargs['reply_markup'] = markup
|
||||||
|
self._update.message.reply_text(text, **kwargs)
|
||||||
|
|
||||||
|
def reply_exc(self, e: Exception) -> None:
|
||||||
|
self.reply(exc2text(e))
|
||||||
|
|
||||||
|
def answer(self, text: str = None):
|
||||||
|
self.callback_query.answer(text)
|
||||||
|
|
||||||
|
def edit(self, text, markup=None):
|
||||||
|
kwargs = dict(parse_mode=ParseMode.HTML)
|
||||||
|
if not isinstance(markup, IgnoreMarkup):
|
||||||
|
kwargs['reply_markup'] = markup
|
||||||
|
self.callback_query.edit_message_text(text, **kwargs)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self) -> str:
|
||||||
|
return self._update.message.text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def callback_query(self) -> CallbackQuery:
|
||||||
|
return self._update.callback_query
|
||||||
|
|
||||||
|
@property
|
||||||
|
def args(self) -> Optional[list[str]]:
|
||||||
|
return self._callback_context.args
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_id(self) -> int:
|
||||||
|
return self.user.id
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user(self) -> User:
|
||||||
|
return self._update.effective_user
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_lang(self) -> str:
|
||||||
|
if self._user_lang is None:
|
||||||
|
self._user_lang = self._store.get_user_lang(self.user_id)
|
||||||
|
return self._user_lang
|
||||||
|
|
||||||
|
def lang(self, key: str, *args) -> str:
|
||||||
|
return self._lang.get(key, self.user_lang, *args)
|
||||||
|
|
||||||
|
def is_callback_context(self) -> bool:
|
||||||
|
return self._update.callback_query and self._update.callback_query.data and self._update.callback_query.data != ''
|
||||||
|
|
||||||
|
|
||||||
|
class Wrapper:
|
||||||
|
store: Optional[Store]
|
||||||
|
updater: Updater
|
||||||
|
lang: LangPack
|
||||||
|
reporting: Optional[ReportingHelper]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.updater = Updater(config['bot']['token'],
|
||||||
|
request_kwargs={'read_timeout': 6, 'connect_timeout': 7})
|
||||||
|
self.lang = default_langpack()
|
||||||
|
self.store = Store()
|
||||||
|
self.reporting = None
|
||||||
|
|
||||||
|
init_user_filter()
|
||||||
|
|
||||||
|
dispatcher = self.updater.dispatcher
|
||||||
|
dispatcher.add_handler(CommandHandler('start', self.wrap(self.start), user_filter))
|
||||||
|
|
||||||
|
# transparently log all messages
|
||||||
|
self.add_handler(MessageHandler(Filters.all & user_filter, self.logging_message_handler), group=10)
|
||||||
|
self.add_handler(CallbackQueryHandler(self.logging_callback_handler), group=10)
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self._lang_setup()
|
||||||
|
self.updater.dispatcher.add_handler(
|
||||||
|
MessageHandler(Filters.all & user_filter, self.wrap(self.any))
|
||||||
|
)
|
||||||
|
|
||||||
|
# start the bot
|
||||||
|
self.updater.start_polling()
|
||||||
|
|
||||||
|
# run the bot until the user presses Ctrl-C or the process receives SIGINT, SIGTERM or SIGABRT
|
||||||
|
self.updater.idle()
|
||||||
|
|
||||||
|
def enable_logging(self, bot_type: BotType):
|
||||||
|
api = WebAPIClient(timeout=3)
|
||||||
|
api.enable_async()
|
||||||
|
|
||||||
|
self.reporting = ReportingHelper(api, bot_type)
|
||||||
|
|
||||||
|
def logging_message_handler(self, update: Update, context: CallbackContext):
|
||||||
|
if self.reporting is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.reporting.report(update.message)
|
||||||
|
|
||||||
|
def logging_callback_handler(self, update: Update, context: CallbackContext):
|
||||||
|
if self.reporting is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self.reporting.report(update.callback_query.message, text=update.callback_query.data)
|
||||||
|
|
||||||
|
def wrap(self, f: callable):
|
||||||
|
def handler(update: Update, context: CallbackContext):
|
||||||
|
ctx = Context(update,
|
||||||
|
callback_context=context,
|
||||||
|
markup_getter=self.markup,
|
||||||
|
lang=self.lang,
|
||||||
|
store=self.store)
|
||||||
|
|
||||||
|
try:
|
||||||
|
return f(ctx)
|
||||||
|
except Exception as e:
|
||||||
|
if not self.exception_handler(e, ctx) and not isinstance(e, TimedOut):
|
||||||
|
logger.exception(e)
|
||||||
|
if not ctx.is_callback_context():
|
||||||
|
ctx.reply_exc(e)
|
||||||
|
else:
|
||||||
|
self.notify_user(ctx.user_id, exc2text(e))
|
||||||
|
|
||||||
|
return handler
|
||||||
|
|
||||||
|
def add_handler(self, handler: Handler, group=0):
|
||||||
|
self.updater.dispatcher.add_handler(handler, group=group)
|
||||||
|
|
||||||
|
def start(self, ctx: Context):
|
||||||
|
if 'start_message' not in self.lang:
|
||||||
|
ctx.reply('Please define start_message or override start()')
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.reply(ctx.lang('start_message'))
|
||||||
|
|
||||||
|
def any(self, ctx: Context):
|
||||||
|
if 'invalid_command' not in self.lang:
|
||||||
|
ctx.reply('Please define invalid_command or override any()')
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.reply(ctx.lang('invalid_command'))
|
||||||
|
|
||||||
|
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def exception_handler(self, e: Exception, ctx: Context) -> Optional[bool]:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def notify_all(self, text_getter: callable, exclude: tuple[int] = ()) -> None:
|
||||||
|
if 'notify_users' not in config['bot']:
|
||||||
|
logger.error('notify_all() called but no notify_users directive found in the config')
|
||||||
|
return
|
||||||
|
|
||||||
|
for user_id in config['bot']['notify_users']:
|
||||||
|
if user_id in exclude:
|
||||||
|
continue
|
||||||
|
|
||||||
|
text = text_getter(self.store.get_user_lang(user_id))
|
||||||
|
self.updater.bot.send_message(chat_id=user_id,
|
||||||
|
text=text,
|
||||||
|
parse_mode='HTML')
|
||||||
|
|
||||||
|
def notify_user(self, user_id: int, text: Union[str, Exception]) -> None:
|
||||||
|
if isinstance(text, Exception):
|
||||||
|
text = exc2text(text)
|
||||||
|
self.updater.bot.send_message(chat_id=user_id, text=text, parse_mode='HTML')
|
||||||
|
|
||||||
|
def send_audio(self, user_id, **kwargs):
|
||||||
|
self.updater.bot.send_audio(chat_id=user_id, **kwargs)
|
||||||
|
|
||||||
|
def send_file(self, user_id, **kwargs):
|
||||||
|
self.updater.bot.send_document(chat_id=user_id, **kwargs)
|
||||||
|
|
||||||
|
#
|
||||||
|
# Language Selection
|
||||||
|
#
|
||||||
|
|
||||||
|
def _lang_setup(self):
|
||||||
|
supported = self.lang.languages
|
||||||
|
if len(supported) > 1:
|
||||||
|
cancel_filter = Filters.text(self.lang.all('cancel'))
|
||||||
|
|
||||||
|
self.add_handler(ConversationHandler(
|
||||||
|
entry_points=[CommandHandler('lang', self.wrap(self._lang_command), user_filter)],
|
||||||
|
states={
|
||||||
|
LANG_STARTED: [
|
||||||
|
*list(map(lambda key: MessageHandler(text_filter(languages[key]),
|
||||||
|
self.wrap(self._lang_input)), supported)),
|
||||||
|
MessageHandler(user_filter & ~cancel_filter, self.wrap(self._lang_invalid_input))
|
||||||
|
]
|
||||||
|
},
|
||||||
|
fallbacks=[MessageHandler(user_filter & cancel_filter, self.wrap(self._lang_cancel_input))]
|
||||||
|
))
|
||||||
|
|
||||||
|
def _lang_command(self, ctx: Context):
|
||||||
|
logger.debug(f'current language: {ctx.user_lang}')
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
for name in languages.values():
|
||||||
|
buttons.append(name)
|
||||||
|
markup = ReplyKeyboardMarkup([buttons, [ctx.lang('cancel')]], one_time_keyboard=False)
|
||||||
|
|
||||||
|
ctx.reply(ctx.lang('select_language'), markup=markup)
|
||||||
|
return LANG_STARTED
|
||||||
|
|
||||||
|
def _lang_input(self, ctx: Context):
|
||||||
|
lang = None
|
||||||
|
for key, value in languages.items():
|
||||||
|
if value == ctx.text:
|
||||||
|
lang = key
|
||||||
|
break
|
||||||
|
|
||||||
|
if lang is None:
|
||||||
|
ValueError('could not find the language')
|
||||||
|
|
||||||
|
self.store.set_user_lang(ctx.user_id, lang)
|
||||||
|
|
||||||
|
ctx.reply(ctx.lang('language_saved'), markup=IgnoreMarkup())
|
||||||
|
|
||||||
|
self.start(ctx)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
def _lang_invalid_input(self, ctx: Context):
|
||||||
|
ctx.reply(self.lang('invalid_language'), markup=IgnoreMarkup())
|
||||||
|
return LANG_STARTED
|
||||||
|
|
||||||
|
def _lang_cancel_input(self, ctx: Context):
|
||||||
|
self.start(ctx)
|
||||||
|
return ConversationHandler.END
|
||||||
|
|
||||||
|
@property
|
||||||
|
def user_filter(self):
|
||||||
|
return user_filter
|
1
src/home/config/__init__.py
Normal file
1
src/home/config/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .config import ConfigStore, config, is_development_mode
|
110
src/home/config/config.py
Normal file
110
src/home/config/config.py
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import toml
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from os.path import join, isdir, isfile
|
||||||
|
from typing import Optional, Any, MutableMapping
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
|
||||||
|
|
||||||
|
def _get_config_path(name: str) -> str:
|
||||||
|
dirname = join(os.environ['HOME'], '.config', name)
|
||||||
|
filename = join(os.environ['HOME'], '.config', f'{name}.toml')
|
||||||
|
if isdir(dirname):
|
||||||
|
return join(dirname, 'config.toml')
|
||||||
|
elif isfile(filename):
|
||||||
|
return filename
|
||||||
|
else:
|
||||||
|
raise IOError(f'configuration file not found (tried {dirname}/config.toml and {filename})')
|
||||||
|
|
||||||
|
|
||||||
|
class ConfigStore:
|
||||||
|
data: MutableMapping[str, Any]
|
||||||
|
app_name: Optional[str]
|
||||||
|
|
||||||
|
def __int__(self):
|
||||||
|
self.data = {}
|
||||||
|
self.app_name = None
|
||||||
|
|
||||||
|
def load(self, name: Optional[str] = None,
|
||||||
|
use_cli=True,
|
||||||
|
parser: ArgumentParser = None):
|
||||||
|
self.app_name = name
|
||||||
|
|
||||||
|
if (name is None) and (not use_cli):
|
||||||
|
raise RuntimeError('either config name must be none or use_cli must be True')
|
||||||
|
|
||||||
|
log_default_fmt = False
|
||||||
|
log_file = None
|
||||||
|
log_verbose = False
|
||||||
|
|
||||||
|
path = None
|
||||||
|
if use_cli:
|
||||||
|
if parser is None:
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument('--config', type=str, required=name is None,
|
||||||
|
help='Path to the config in TOML format')
|
||||||
|
parser.add_argument('--verbose', action='store_true')
|
||||||
|
parser.add_argument('--log-file', type=str)
|
||||||
|
parser.add_argument('--log-default-fmt', action='store_true')
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.config:
|
||||||
|
path = args.config
|
||||||
|
if args.verbose:
|
||||||
|
log_verbose = True
|
||||||
|
if args.log_file:
|
||||||
|
log_file = args.log_file
|
||||||
|
if args.log_default_fmt:
|
||||||
|
log_default_fmt = args.log_default_fmt
|
||||||
|
|
||||||
|
if name and path is None:
|
||||||
|
path = _get_config_path(name)
|
||||||
|
|
||||||
|
self.data = toml.load(path)
|
||||||
|
|
||||||
|
if 'logging' in self:
|
||||||
|
if not log_file and 'file' in self['logging']:
|
||||||
|
log_file = self['logging']['file']
|
||||||
|
if log_default_fmt and 'default_fmt' in self['logging']:
|
||||||
|
log_default_fmt = self['logging']['default_fmt']
|
||||||
|
|
||||||
|
setup_logging(log_verbose, log_file, log_default_fmt)
|
||||||
|
|
||||||
|
if use_cli:
|
||||||
|
return args
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self.data[key]
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
raise NotImplementedError('overwriting config values is prohibited')
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return key in self.data
|
||||||
|
|
||||||
|
|
||||||
|
config = ConfigStore()
|
||||||
|
|
||||||
|
|
||||||
|
def is_development_mode() -> bool:
|
||||||
|
if 'FLASK_ENV' in os.environ and os.environ['FLASK_ENV'] == 'development':
|
||||||
|
return True
|
||||||
|
|
||||||
|
return ('logging' in config) and ('verbose' in config['logging']) and (config['logging']['verbose'] is True)
|
||||||
|
|
||||||
|
|
||||||
|
def setup_logging(verbose=False, log_file=None, default_fmt=False):
|
||||||
|
logging_level = logging.INFO
|
||||||
|
if is_development_mode() or verbose:
|
||||||
|
logging_level = logging.DEBUG
|
||||||
|
|
||||||
|
log_config = {'level': logging_level}
|
||||||
|
if not default_fmt:
|
||||||
|
log_config['format'] = '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||||
|
|
||||||
|
if log_file is not None:
|
||||||
|
log_config['filename'] = log_file
|
||||||
|
log_config['encoding'] = 'utf-8'
|
||||||
|
|
||||||
|
logging.basicConfig(**log_config)
|
29
src/home/database/__init__.py
Normal file
29
src/home/database/__init__.py
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'get_mysql',
|
||||||
|
'mysql_now',
|
||||||
|
'get_clickhouse',
|
||||||
|
'SimpleState',
|
||||||
|
|
||||||
|
'SensorsDatabase',
|
||||||
|
'InverterDatabase',
|
||||||
|
'BotsDatabase'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name: str):
|
||||||
|
if name in __all__:
|
||||||
|
if name.endswith('Database'):
|
||||||
|
file = name[:-8].lower()
|
||||||
|
elif 'mysql' in name:
|
||||||
|
file = 'mysql'
|
||||||
|
elif 'clickhouse' in name:
|
||||||
|
file = 'clickhouse'
|
||||||
|
else:
|
||||||
|
file = 'simple_state'
|
||||||
|
|
||||||
|
module = importlib.import_module(f'.{file}', __name__)
|
||||||
|
return getattr(module, name)
|
||||||
|
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
11
src/home/database/__init__.pyi
Normal file
11
src/home/database/__init__.pyi
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
from .mysql import (
|
||||||
|
get_mysql as get_mysql,
|
||||||
|
mysql_now as mysql_now
|
||||||
|
)
|
||||||
|
from .clickhouse import get_clickhouse as get_clickhouse
|
||||||
|
|
||||||
|
from simple_state import SimpleState as SimpleState
|
||||||
|
|
||||||
|
from .sensors import SensorsDatabase as SensorsDatabase
|
||||||
|
from .inverter import InverterDatabase as InverterDatabase
|
||||||
|
from .bots import BotsDatabase as BotsDatabase
|
104
src/home/database/bots.py
Normal file
104
src/home/database/bots.py
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
import pytz
|
||||||
|
|
||||||
|
from .mysql import mysql_now, MySQLDatabase, datetime_fmt
|
||||||
|
from ..api.types import (
|
||||||
|
BotType,
|
||||||
|
SoundSensorLocation
|
||||||
|
)
|
||||||
|
from typing import Optional
|
||||||
|
from datetime import datetime
|
||||||
|
from html import escape
|
||||||
|
|
||||||
|
|
||||||
|
class OpenwrtLogRecord:
|
||||||
|
id: int
|
||||||
|
log_time: datetime
|
||||||
|
received_time: datetime
|
||||||
|
text: str
|
||||||
|
|
||||||
|
def __init__(self, id, text, log_time, received_time):
|
||||||
|
self.id = id
|
||||||
|
self.text = text
|
||||||
|
self.log_time = log_time
|
||||||
|
self.received_time = received_time
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<b>{self.log_time.strftime('%H:%M:%S')}</b> {escape(self.text)}"
|
||||||
|
|
||||||
|
|
||||||
|
class BotsDatabase(MySQLDatabase):
|
||||||
|
def add_request(self,
|
||||||
|
bot: BotType,
|
||||||
|
user_id: int,
|
||||||
|
message: str):
|
||||||
|
with self.cursor() as cursor:
|
||||||
|
cursor.execute("INSERT INTO requests_log (user_id, message, bot, time) VALUES (%s, %s, %s, %s)",
|
||||||
|
(user_id, message, bot.name.lower(), mysql_now()))
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def add_openwrt_logs(self,
|
||||||
|
lines: list[tuple[datetime, str]]):
|
||||||
|
now = datetime.now()
|
||||||
|
with self.cursor() as cursor:
|
||||||
|
for line in lines:
|
||||||
|
time, text = line
|
||||||
|
cursor.execute("INSERT INTO openwrt (log_time, received_time, text) VALUES (%s, %s, %s)",
|
||||||
|
(time.strftime(datetime_fmt), now.strftime(datetime_fmt), text))
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def add_sound_hits(self,
|
||||||
|
hits: list[tuple[SoundSensorLocation, int]],
|
||||||
|
time: datetime):
|
||||||
|
with self.cursor() as cursor:
|
||||||
|
for loc, count in hits:
|
||||||
|
cursor.execute("INSERT INTO sound_hits (location, `time`, hits) VALUES (%s, %s, %s)",
|
||||||
|
(loc.name.lower(), time.strftime(datetime_fmt), count))
|
||||||
|
self.commit()
|
||||||
|
|
||||||
|
def get_sound_hits(self,
|
||||||
|
location: SoundSensorLocation,
|
||||||
|
after: Optional[datetime] = None,
|
||||||
|
last: Optional[int] = None) -> list[dict]:
|
||||||
|
with self.cursor(dictionary=True) as cursor:
|
||||||
|
sql = "SELECT `time`, hits FROM sound_hits WHERE location=%s"
|
||||||
|
args = [location.name.lower()]
|
||||||
|
|
||||||
|
if after:
|
||||||
|
sql += ' AND `time` >= %s ORDER BY time DESC'
|
||||||
|
args.append(after)
|
||||||
|
elif last:
|
||||||
|
sql += ' ORDER BY time DESC LIMIT 0, %s'
|
||||||
|
args.append(last)
|
||||||
|
else:
|
||||||
|
raise ValueError('no `after`, no `last`, what do you expect?')
|
||||||
|
|
||||||
|
cursor.execute(sql, tuple(args))
|
||||||
|
data = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
data.append({
|
||||||
|
'time': row['time'],
|
||||||
|
'hits': row['hits']
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_openwrt_logs(self,
|
||||||
|
filter_text: str,
|
||||||
|
min_id: int,
|
||||||
|
limit: int = None) -> list[OpenwrtLogRecord]:
|
||||||
|
tz = pytz.timezone('Europe/Moscow')
|
||||||
|
with self.cursor(dictionary=True) as cursor:
|
||||||
|
sql = "SELECT * FROM openwrt WHERE text LIKE %s AND id > %s"
|
||||||
|
if limit is not None:
|
||||||
|
sql += f" LIMIT {limit}"
|
||||||
|
|
||||||
|
cursor.execute(sql, (f'%{filter_text}%', min_id))
|
||||||
|
data = []
|
||||||
|
for row in cursor.fetchall():
|
||||||
|
data.append(OpenwrtLogRecord(
|
||||||
|
id=int(row['id']),
|
||||||
|
text=row['text'],
|
||||||
|
log_time=row['log_time'].astimezone(tz),
|
||||||
|
received_time=row['received_time'].astimezone(tz)
|
||||||
|
))
|
||||||
|
|
||||||
|
return data
|
10
src/home/database/clickhouse.py
Normal file
10
src/home/database/clickhouse.py
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
from clickhouse_driver import Client as ClickhouseClient
|
||||||
|
|
||||||
|
_links = {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_clickhouse(db: str) -> ClickhouseClient:
|
||||||
|
if db not in _links:
|
||||||
|
_links[db] = ClickhouseClient.from_url(f'clickhouse://localhost/{db}')
|
||||||
|
|
||||||
|
return _links[db]
|
102
src/home/database/inverter.py
Normal file
102
src/home/database/inverter.py
Normal file
@ -0,0 +1,102 @@
|
|||||||
|
from .clickhouse import get_clickhouse
|
||||||
|
from time import time
|
||||||
|
|
||||||
|
|
||||||
|
class InverterDatabase:
|
||||||
|
def __init__(self):
|
||||||
|
self.db = get_clickhouse('solarmon')
|
||||||
|
|
||||||
|
def add_generation(self, home_id: int, client_time: int, watts: int) -> None:
|
||||||
|
self.db.execute(
|
||||||
|
'INSERT INTO generation (ClientTime, ReceivedTime, HomeID, Watts) VALUES',
|
||||||
|
[[client_time, round(time()), home_id, watts]]
|
||||||
|
)
|
||||||
|
|
||||||
|
def add_status(self, home_id: int,
|
||||||
|
client_time: int,
|
||||||
|
grid_voltage: int,
|
||||||
|
grid_freq: int,
|
||||||
|
ac_output_voltage: int,
|
||||||
|
ac_output_freq: int,
|
||||||
|
ac_output_apparent_power: int,
|
||||||
|
ac_output_active_power: int,
|
||||||
|
output_load_percent: int,
|
||||||
|
battery_voltage: int,
|
||||||
|
battery_voltage_scc: int,
|
||||||
|
battery_voltage_scc2: int,
|
||||||
|
battery_discharging_current: int,
|
||||||
|
battery_charging_current: int,
|
||||||
|
battery_capacity: int,
|
||||||
|
inverter_heat_sink_temp: int,
|
||||||
|
mppt1_charger_temp: int,
|
||||||
|
mppt2_charger_temp: int,
|
||||||
|
pv1_input_power: int,
|
||||||
|
pv2_input_power: int,
|
||||||
|
pv1_input_voltage: int,
|
||||||
|
pv2_input_voltage: int,
|
||||||
|
mppt1_charger_status: int,
|
||||||
|
mppt2_charger_status: int,
|
||||||
|
battery_power_direction: int,
|
||||||
|
dc_ac_power_direction: int,
|
||||||
|
line_power_direction: int,
|
||||||
|
load_connected: int) -> None:
|
||||||
|
self.db.execute("""INSERT INTO status (
|
||||||
|
ClientTime,
|
||||||
|
ReceivedTime,
|
||||||
|
HomeID,
|
||||||
|
GridVoltage,
|
||||||
|
GridFrequency,
|
||||||
|
ACOutputVoltage,
|
||||||
|
ACOutputFrequency,
|
||||||
|
ACOutputApparentPower,
|
||||||
|
ACOutputActivePower,
|
||||||
|
OutputLoadPercent,
|
||||||
|
BatteryVoltage,
|
||||||
|
BatteryVoltageSCC,
|
||||||
|
BatteryVoltageSCC2,
|
||||||
|
BatteryDischargingCurrent,
|
||||||
|
BatteryChargingCurrent,
|
||||||
|
BatteryCapacity,
|
||||||
|
HeatSinkTemp,
|
||||||
|
MPPT1ChargerTemp,
|
||||||
|
MPPT2ChargerTemp,
|
||||||
|
PV1InputPower,
|
||||||
|
PV2InputPower,
|
||||||
|
PV1InputVoltage,
|
||||||
|
PV2InputVoltage,
|
||||||
|
MPPT1ChargerStatus,
|
||||||
|
MPPT2ChargerStatus,
|
||||||
|
BatteryPowerDirection,
|
||||||
|
DCACPowerDirection,
|
||||||
|
LinePowerDirection,
|
||||||
|
LoadConnected) VALUES""", [[
|
||||||
|
client_time,
|
||||||
|
round(time()),
|
||||||
|
home_id,
|
||||||
|
grid_voltage,
|
||||||
|
grid_freq,
|
||||||
|
ac_output_voltage,
|
||||||
|
ac_output_freq,
|
||||||
|
ac_output_apparent_power,
|
||||||
|
ac_output_active_power,
|
||||||
|
output_load_percent,
|
||||||
|
battery_voltage,
|
||||||
|
battery_voltage_scc,
|
||||||
|
battery_voltage_scc2,
|
||||||
|
battery_discharging_current,
|
||||||
|
battery_charging_current,
|
||||||
|
battery_capacity,
|
||||||
|
inverter_heat_sink_temp,
|
||||||
|
mppt1_charger_temp,
|
||||||
|
mppt2_charger_temp,
|
||||||
|
pv1_input_power,
|
||||||
|
pv2_input_power,
|
||||||
|
pv1_input_voltage,
|
||||||
|
pv2_input_voltage,
|
||||||
|
mppt1_charger_status,
|
||||||
|
mppt2_charger_status,
|
||||||
|
battery_power_direction,
|
||||||
|
dc_ac_power_direction,
|
||||||
|
line_power_direction,
|
||||||
|
load_connected
|
||||||
|
]])
|
47
src/home/database/mysql.py
Normal file
47
src/home/database/mysql.py
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from mysql.connector import connect, MySQLConnection, Error
|
||||||
|
from typing import Optional
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
link: Optional[MySQLConnection] = None
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
datetime_fmt = '%Y-%m-%d %H:%M:%S'
|
||||||
|
|
||||||
|
|
||||||
|
def get_mysql() -> MySQLConnection:
|
||||||
|
global link
|
||||||
|
|
||||||
|
if link is not None:
|
||||||
|
return link
|
||||||
|
|
||||||
|
link = connect(
|
||||||
|
host=config['mysql']['host'],
|
||||||
|
user=config['mysql']['user'],
|
||||||
|
password=config['mysql']['password'],
|
||||||
|
database=config['mysql']['database'],
|
||||||
|
)
|
||||||
|
link.time_zone = '+01:00'
|
||||||
|
return link
|
||||||
|
|
||||||
|
|
||||||
|
def mysql_now() -> str:
|
||||||
|
return time.strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
|
||||||
|
class MySQLDatabase:
|
||||||
|
def __init__(self):
|
||||||
|
self.db = get_mysql()
|
||||||
|
|
||||||
|
def cursor(self, **kwargs):
|
||||||
|
try:
|
||||||
|
self.db.ping(reconnect=True, attempts=2)
|
||||||
|
except Error as e:
|
||||||
|
logger.exception(e)
|
||||||
|
self.db = get_mysql()
|
||||||
|
return self.db.cursor(**kwargs)
|
||||||
|
|
||||||
|
def commit(self):
|
||||||
|
self.db.commit()
|
66
src/home/database/sensors.py
Normal file
66
src/home/database/sensors.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from time import time
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Tuple, List
|
||||||
|
from .clickhouse import get_clickhouse
|
||||||
|
from ..api.types import TemperatureSensorLocation
|
||||||
|
|
||||||
|
|
||||||
|
def get_temperature_table(sensor: TemperatureSensorLocation) -> str:
|
||||||
|
if sensor == TemperatureSensorLocation.DIANA:
|
||||||
|
return 'temp_diana'
|
||||||
|
|
||||||
|
elif sensor == TemperatureSensorLocation.STREET:
|
||||||
|
return 'temp_street'
|
||||||
|
|
||||||
|
elif sensor == TemperatureSensorLocation.BIG_HOUSE_1:
|
||||||
|
return 'temp'
|
||||||
|
|
||||||
|
elif sensor == TemperatureSensorLocation.BIG_HOUSE_2:
|
||||||
|
return 'temp_roof'
|
||||||
|
|
||||||
|
elif sensor == TemperatureSensorLocation.SPB1:
|
||||||
|
return 'temp_spb1'
|
||||||
|
|
||||||
|
|
||||||
|
class SensorsDatabase:
|
||||||
|
def __init__(self):
|
||||||
|
self.db = get_clickhouse('home')
|
||||||
|
|
||||||
|
def add_temperature(self,
|
||||||
|
home_id: int,
|
||||||
|
client_time: int,
|
||||||
|
sensor: TemperatureSensorLocation,
|
||||||
|
temp: int,
|
||||||
|
rh: int):
|
||||||
|
table = get_temperature_table(sensor)
|
||||||
|
sql = """INSERT INTO """ + table + """ (
|
||||||
|
ClientTime,
|
||||||
|
ReceivedTime,
|
||||||
|
HomeID,
|
||||||
|
Temperature,
|
||||||
|
RelativeHumidity
|
||||||
|
) VALUES"""
|
||||||
|
self.db.execute(sql, [[
|
||||||
|
client_time,
|
||||||
|
int(time()),
|
||||||
|
home_id,
|
||||||
|
temp,
|
||||||
|
rh
|
||||||
|
]])
|
||||||
|
|
||||||
|
def get_temperature_recordings(self,
|
||||||
|
sensor: TemperatureSensorLocation,
|
||||||
|
time_range: Tuple[datetime, datetime],
|
||||||
|
home_id=1) -> List[tuple]:
|
||||||
|
table = get_temperature_table(sensor)
|
||||||
|
sql = f"""SELECT ClientTime, Temperature, RelativeHumidity
|
||||||
|
FROM {table}
|
||||||
|
WHERE ClientTime >= %(from)s AND ClientTime <= %(to)s
|
||||||
|
ORDER BY ClientTime"""
|
||||||
|
dt_from, dt_to = time_range
|
||||||
|
|
||||||
|
data = self.db.execute(sql, {
|
||||||
|
'from': dt_from,
|
||||||
|
'to': dt_to
|
||||||
|
})
|
||||||
|
return [(date, temp/100, humidity/100) for date, temp, humidity in data]
|
46
src/home/database/simple_state.py
Normal file
46
src/home/database/simple_state.py
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
import os
|
||||||
|
import json
|
||||||
|
import atexit
|
||||||
|
|
||||||
|
|
||||||
|
class SimpleState:
|
||||||
|
def __init__(self,
|
||||||
|
file: str,
|
||||||
|
default: dict = None,
|
||||||
|
**kwargs):
|
||||||
|
if default is None:
|
||||||
|
default = {}
|
||||||
|
elif type(default) is not dict:
|
||||||
|
raise TypeError('default must be dictionary')
|
||||||
|
|
||||||
|
if not os.path.exists(file):
|
||||||
|
self._data = default
|
||||||
|
else:
|
||||||
|
with open(file, 'r') as f:
|
||||||
|
self._data = json.loads(f.read())
|
||||||
|
|
||||||
|
self._file = file
|
||||||
|
atexit.register(self.__cleanup)
|
||||||
|
|
||||||
|
def __cleanup(self):
|
||||||
|
if hasattr(self, '_file'):
|
||||||
|
with open(self._file, 'w') as f:
|
||||||
|
f.write(json.dumps(self._data))
|
||||||
|
atexit.unregister(self.__cleanup)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
if 'open' in __builtins__:
|
||||||
|
self.__cleanup()
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
return self._data[key]
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
self._data[key] = value
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return key in self._data
|
||||||
|
|
||||||
|
def __delitem__(self, key):
|
||||||
|
if key in self._data:
|
||||||
|
del self._data[key]
|
8
src/home/inverter/__init__.py
Normal file
8
src/home/inverter/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from .monitor import (
|
||||||
|
ChargingEvent,
|
||||||
|
InverterMonitor,
|
||||||
|
BatteryState,
|
||||||
|
BatteryPowerDirection
|
||||||
|
)
|
||||||
|
from .inverter_wrapper import wrapper_instance
|
||||||
|
from .util import beautify_table
|
48
src/home/inverter/inverter_wrapper.py
Normal file
48
src/home/inverter/inverter_wrapper.py
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from threading import Lock
|
||||||
|
from inverterd import (
|
||||||
|
Format,
|
||||||
|
Client as InverterClient,
|
||||||
|
InverterError
|
||||||
|
)
|
||||||
|
|
||||||
|
_lock = Lock()
|
||||||
|
|
||||||
|
|
||||||
|
class InverterClientWrapper:
|
||||||
|
def __init__(self):
|
||||||
|
self._inverter = None
|
||||||
|
self._host = None
|
||||||
|
self._port = None
|
||||||
|
|
||||||
|
def init(self, host: str, port: int):
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self.create()
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
self._inverter = InverterClient(host=self._host, port=self._port)
|
||||||
|
self._inverter.connect()
|
||||||
|
|
||||||
|
def exec(self, command: str, arguments: tuple = (), format=Format.JSON):
|
||||||
|
with _lock:
|
||||||
|
try:
|
||||||
|
self._inverter.format(format)
|
||||||
|
response = self._inverter.exec(command, arguments)
|
||||||
|
if format == Format.JSON:
|
||||||
|
response = json.loads(response)
|
||||||
|
return response
|
||||||
|
except InverterError as e:
|
||||||
|
raise e
|
||||||
|
except Exception as e:
|
||||||
|
# silently try to reconnect
|
||||||
|
try:
|
||||||
|
self.create()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
raise e
|
||||||
|
|
||||||
|
|
||||||
|
wrapper_instance = InverterClientWrapper()
|
||||||
|
|
448
src/home/inverter/monitor.py
Normal file
448
src/home/inverter/monitor.py
Normal file
@ -0,0 +1,448 @@
|
|||||||
|
import logging
|
||||||
|
import time
|
||||||
|
|
||||||
|
from enum import Enum, auto
|
||||||
|
from threading import Thread
|
||||||
|
from typing import Callable, Optional
|
||||||
|
from .inverter_wrapper import wrapper_instance as inverter
|
||||||
|
from inverterd import InverterError
|
||||||
|
from ..util import Stopwatch, StopwatchError
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class BatteryPowerDirection(Enum):
|
||||||
|
DISCHARGING = auto()
|
||||||
|
CHARGING = auto()
|
||||||
|
DO_NOTHING = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class ChargingEvent(Enum):
|
||||||
|
AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR = auto()
|
||||||
|
AC_NOT_CHARGING = auto()
|
||||||
|
AC_CHARGING_STARTED = auto()
|
||||||
|
AC_DISCONNECTED = auto()
|
||||||
|
AC_CURRENT_CHANGED = auto()
|
||||||
|
AC_MOSTLY_CHARGED = auto()
|
||||||
|
AC_CHARGING_FINISHED = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class ChargingState(Enum):
|
||||||
|
NOT_CHARGING = auto()
|
||||||
|
AC_BUT_SOLAR = auto()
|
||||||
|
AC_WAITING = auto()
|
||||||
|
AC_OK = auto()
|
||||||
|
AC_DONE = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentChangeDirection(Enum):
|
||||||
|
UP = auto()
|
||||||
|
DOWN = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class BatteryState(Enum):
|
||||||
|
NORMAL = auto()
|
||||||
|
LOW = auto()
|
||||||
|
CRITICAL = auto()
|
||||||
|
|
||||||
|
|
||||||
|
def _pd_from_string(pd: str) -> BatteryPowerDirection:
|
||||||
|
if pd == 'Discharge':
|
||||||
|
return BatteryPowerDirection.DISCHARGING
|
||||||
|
elif pd == 'Charge':
|
||||||
|
return BatteryPowerDirection.CHARGING
|
||||||
|
elif pd == 'Do nothing':
|
||||||
|
return BatteryPowerDirection.DO_NOTHING
|
||||||
|
else:
|
||||||
|
raise ValueError(f'invalid power direction: {pd}')
|
||||||
|
|
||||||
|
|
||||||
|
class MonitorConfig:
|
||||||
|
def __getattr__(self, item):
|
||||||
|
return config['monitor'][item]
|
||||||
|
|
||||||
|
|
||||||
|
cfg = MonitorConfig()
|
||||||
|
|
||||||
|
|
||||||
|
class InverterMonitor(Thread):
|
||||||
|
charging_event_handler: Optional[Callable]
|
||||||
|
battery_event_handler: Optional[Callable]
|
||||||
|
error_handler: Optional[Callable]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setName('InverterMonitor')
|
||||||
|
|
||||||
|
self.interrupted = False
|
||||||
|
self.min_allowed_current = 0
|
||||||
|
|
||||||
|
# Event handlers for the bot.
|
||||||
|
self.charging_event_handler = None
|
||||||
|
self.battery_event_handler = None
|
||||||
|
self.error_handler = None
|
||||||
|
|
||||||
|
# Currents list, defined in the bot config.
|
||||||
|
self.currents = cfg.gen_currents
|
||||||
|
self.currents.sort()
|
||||||
|
|
||||||
|
# We start charging at lowest possible current, then increase it once per minute (or so) to the maximum level.
|
||||||
|
# This is done so that the load on the generator increases smoothly, not abruptly. Generator will thank us.
|
||||||
|
self.current_change_direction = CurrentChangeDirection.UP
|
||||||
|
self.next_current_enter_time = 0
|
||||||
|
self.active_current_idx = -1
|
||||||
|
|
||||||
|
self.battery_state = BatteryState.NORMAL
|
||||||
|
self.charging_state = ChargingState.NOT_CHARGING
|
||||||
|
|
||||||
|
# 'Mostly-charged' means that we've already lowered the charging current to the level
|
||||||
|
# at which batteries are charging pretty slow. So instead of burning gasoline and shaking the air,
|
||||||
|
# we can just turn the generator off at this point.
|
||||||
|
self.mostly_charged = False
|
||||||
|
|
||||||
|
# The stopwatch is used to measure how long does the battery voltage exceeds the float voltage level.
|
||||||
|
# We don't want to damage our batteries, right?
|
||||||
|
self.floating_stopwatch = Stopwatch()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_current(self) -> Optional[int]:
|
||||||
|
try:
|
||||||
|
if self.active_current_idx < 0:
|
||||||
|
return None
|
||||||
|
return self.currents[self.active_current_idx]
|
||||||
|
except IndexError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
# Check allowed currents and validate the config.
|
||||||
|
allowed_currents = list(inverter.exec('get-allowed-ac-charging-currents')['data'])
|
||||||
|
allowed_currents.sort()
|
||||||
|
|
||||||
|
for a in self.currents:
|
||||||
|
if a not in allowed_currents:
|
||||||
|
raise ValueError(f'invalid value {a} in gen_currents list')
|
||||||
|
|
||||||
|
self.min_allowed_current = min(allowed_currents)
|
||||||
|
|
||||||
|
# Read data and run implemented programs every 2 seconds.
|
||||||
|
while not self.interrupted:
|
||||||
|
try:
|
||||||
|
response = inverter.exec('get-status')
|
||||||
|
if response['result'] != 'ok':
|
||||||
|
logger.error('get-status failed:', response)
|
||||||
|
else:
|
||||||
|
gs = response['data']
|
||||||
|
|
||||||
|
ac = gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0
|
||||||
|
solar = gs['pv1_input_power']['value'] > 0
|
||||||
|
v = float(gs['battery_voltage']['value'])
|
||||||
|
load_watts = int(gs['ac_output_active_power']['value'])
|
||||||
|
pd = _pd_from_string(gs['battery_power_direction'])
|
||||||
|
|
||||||
|
logger.debug(f'got status: ac={ac}, solar={solar}, v={v}, pd={pd}')
|
||||||
|
|
||||||
|
self.gen_charging_program(ac, solar, v, pd)
|
||||||
|
|
||||||
|
if not ac or pd != BatteryPowerDirection.CHARGING:
|
||||||
|
# if AC is disconnected or not charging, run the low voltage checking program
|
||||||
|
self.low_voltage_program(v, load_watts)
|
||||||
|
|
||||||
|
elif self.battery_state != BatteryState.NORMAL:
|
||||||
|
# AC is connected and the battery is charging, assume battery level is normal
|
||||||
|
self.battery_state = BatteryState.NORMAL
|
||||||
|
|
||||||
|
except InverterError as e:
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def gen_charging_program(self,
|
||||||
|
ac: bool, # whether AC is connected
|
||||||
|
solar: bool, # whether MPPT is active
|
||||||
|
v: float, # current battery voltage
|
||||||
|
pd: BatteryPowerDirection # current power direction
|
||||||
|
):
|
||||||
|
if self.charging_state == ChargingState.NOT_CHARGING:
|
||||||
|
if ac and solar:
|
||||||
|
# Not charging because MPPT is active (solar line is connected).
|
||||||
|
# Notify users about it and change the current state.
|
||||||
|
self.charging_state = ChargingState.AC_BUT_SOLAR
|
||||||
|
self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR)
|
||||||
|
logger.info('entering AC_BUT_SOLAR state')
|
||||||
|
elif ac:
|
||||||
|
# Not charging, but AC is connected and ready to use.
|
||||||
|
# Start the charging program.
|
||||||
|
self.gen_start(pd)
|
||||||
|
|
||||||
|
elif self.charging_state == ChargingState.AC_BUT_SOLAR:
|
||||||
|
if not ac:
|
||||||
|
# AC charger has been disconnected. Since the state is AC_BUT_SOLAR,
|
||||||
|
# charging probably never even started. Stop the charging program.
|
||||||
|
self.gen_stop(ChargingState.NOT_CHARGING)
|
||||||
|
elif not solar:
|
||||||
|
# MPPT has been disconnected, and, since AC is still connected, we can
|
||||||
|
# try to start the charging program.
|
||||||
|
self.gen_start(pd)
|
||||||
|
|
||||||
|
elif self.charging_state in (ChargingState.AC_OK, ChargingState.AC_WAITING):
|
||||||
|
if not ac:
|
||||||
|
# Charging was in progress, but AC has been suddenly disconnected.
|
||||||
|
# Sad, but what can we do? Stop the charging program and return.
|
||||||
|
self.gen_stop(ChargingState.NOT_CHARGING)
|
||||||
|
return
|
||||||
|
|
||||||
|
if solar:
|
||||||
|
# Charging was in progress, but MPPT has been detected. Inverter doesn't charge
|
||||||
|
# batteries from AC when MPPT is active, so we have to pause our program.
|
||||||
|
self.charging_state = ChargingState.AC_BUT_SOLAR
|
||||||
|
self.charging_event_handler(ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR)
|
||||||
|
try:
|
||||||
|
self.floating_stopwatch.pause()
|
||||||
|
except StopwatchError:
|
||||||
|
msg = 'gen_charging_program: floating_stopwatch.pause() failed at (1)'
|
||||||
|
logger.warning(msg)
|
||||||
|
self.error_handler(msg)
|
||||||
|
logger.info('solar power connected during charging, entering AC_BUT_SOLAR state')
|
||||||
|
|
||||||
|
# No surprises at this point, just check the values and make decisions based on them.
|
||||||
|
# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
|
||||||
|
|
||||||
|
# We've reached the 'mostly-charged' point, the voltage level is not float,
|
||||||
|
# but inverter decided to stop charging (or somebody used a kettle, lol).
|
||||||
|
# Anyway, assume that charging is complete, stop the program, notify users and return.
|
||||||
|
if self.mostly_charged and v > (cfg.gen_floating_v - 1) and pd != BatteryPowerDirection.CHARGING:
|
||||||
|
self.gen_stop(ChargingState.AC_DONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Monitor inverter power direction and notify users when it changes.
|
||||||
|
state = ChargingState.AC_OK if pd == BatteryPowerDirection.CHARGING else ChargingState.AC_WAITING
|
||||||
|
if state != self.charging_state:
|
||||||
|
self.charging_state = state
|
||||||
|
|
||||||
|
evt = ChargingEvent.AC_CHARGING_STARTED if state == ChargingState.AC_OK else ChargingEvent.AC_NOT_CHARGING
|
||||||
|
self.charging_event_handler(evt)
|
||||||
|
|
||||||
|
if self.floating_stopwatch.get_elapsed_time() >= cfg.gen_floating_time_max:
|
||||||
|
# We've been at a bulk voltage level too long, so we have to stop charging.
|
||||||
|
# Set the minimum current possible.
|
||||||
|
|
||||||
|
if self.current_change_direction == CurrentChangeDirection.UP:
|
||||||
|
# This shouldn't happen, obviously an error.
|
||||||
|
msg = 'gen_charging_program:'
|
||||||
|
msg += ' been at bulk voltage level too long, but current change direction is still \'up\'!'
|
||||||
|
msg += ' This is obviously an error, please fix it'
|
||||||
|
logger.warning(msg)
|
||||||
|
self.error_handler(msg)
|
||||||
|
|
||||||
|
self.gen_next_current(current=self.min_allowed_current)
|
||||||
|
|
||||||
|
elif self.active_current is not None:
|
||||||
|
# If voltage is greater than float voltage, keep the stopwatch ticking
|
||||||
|
if v > cfg.gen_floating_v and self.floating_stopwatch.is_paused():
|
||||||
|
try:
|
||||||
|
self.floating_stopwatch.go()
|
||||||
|
except StopwatchError:
|
||||||
|
msg = 'gen_charging_program: floating_stopwatch.go() failed at (2)'
|
||||||
|
logger.warning(msg)
|
||||||
|
self.error_handler(msg)
|
||||||
|
# Otherwise, pause it
|
||||||
|
elif v <= cfg.gen_floating_v and not self.floating_stopwatch.is_paused():
|
||||||
|
try:
|
||||||
|
self.floating_stopwatch.pause()
|
||||||
|
except StopwatchError:
|
||||||
|
msg = 'gen_charging_program: floating_stopwatch.pause() failed at (3)'
|
||||||
|
logger.warning(msg)
|
||||||
|
self.error_handler(msg)
|
||||||
|
|
||||||
|
# Charging current monitoring
|
||||||
|
if self.current_change_direction == CurrentChangeDirection.UP:
|
||||||
|
# Generator is warming up in this code path
|
||||||
|
|
||||||
|
if self.next_current_enter_time != 0 and pd != BatteryPowerDirection.CHARGING:
|
||||||
|
# Generator was warming up and charging, but stopped (pd has changed).
|
||||||
|
# Resetting to the minimum possible pd
|
||||||
|
logger.info(f'gen_charging_program (warming path): was charging but power direction suddeny changed. resetting to minimum current')
|
||||||
|
self.next_current_enter_time = 0
|
||||||
|
self.gen_next_current(current=self.min_allowed_current)
|
||||||
|
|
||||||
|
elif self.next_current_enter_time == 0 and pd == BatteryPowerDirection.CHARGING:
|
||||||
|
self.next_current_enter_time = time.time() + cfg.gen_raise_intervals[self.active_current_idx]
|
||||||
|
logger.info(f'gen_charging_program (warming path): set next_current_enter_time to {self.next_current_enter_time}')
|
||||||
|
|
||||||
|
elif self.next_current_enter_time != 0 and time.time() >= self.next_current_enter_time:
|
||||||
|
logger.info('gen_charging_program (warming path): hit next_current_enter_time, calling gen_next_current()')
|
||||||
|
self.gen_next_current()
|
||||||
|
else:
|
||||||
|
# Gradually lower the current level, based on how close
|
||||||
|
# battery voltage has come to the bulk level.
|
||||||
|
if self.active_current >= 30:
|
||||||
|
upper_bound = cfg.gen_cur30_v_limit
|
||||||
|
elif self.active_current == 20:
|
||||||
|
upper_bound = cfg.gen_cur20_v_limit
|
||||||
|
else:
|
||||||
|
upper_bound = cfg.gen_cur10_v_limit
|
||||||
|
|
||||||
|
# Voltage is high enough already and it's close to bulk level; we hit the upper bound,
|
||||||
|
# so let's lower the current
|
||||||
|
if v >= upper_bound:
|
||||||
|
self.gen_next_current()
|
||||||
|
|
||||||
|
elif self.charging_state == ChargingState.AC_DONE:
|
||||||
|
# We've already finished charging, but AC was connected. Not that it's disconnected,
|
||||||
|
# set the appropriate state and notify users.
|
||||||
|
if not ac:
|
||||||
|
self.gen_stop(ChargingState.NOT_CHARGING)
|
||||||
|
|
||||||
|
def gen_start(self, pd: BatteryPowerDirection):
|
||||||
|
if pd == BatteryPowerDirection.CHARGING:
|
||||||
|
self.charging_state = ChargingState.AC_OK
|
||||||
|
self.charging_event_handler(ChargingEvent.AC_CHARGING_STARTED)
|
||||||
|
logger.info('AC line connected and charging, entering AC_OK state')
|
||||||
|
|
||||||
|
# Continue the stopwatch, if needed
|
||||||
|
try:
|
||||||
|
self.floating_stopwatch.go()
|
||||||
|
except StopwatchError:
|
||||||
|
msg = 'floating_stopwatch.go() failed at ac_charging_start(), AC_OK path'
|
||||||
|
logger.warning(msg)
|
||||||
|
self.error_handler(msg)
|
||||||
|
else:
|
||||||
|
self.charging_state = ChargingState.AC_WAITING
|
||||||
|
self.charging_event_handler(ChargingEvent.AC_NOT_CHARGING)
|
||||||
|
logger.info('AC line connected but not charging yet, entering AC_WAITING state')
|
||||||
|
|
||||||
|
# Pause the stopwatch, if needed
|
||||||
|
try:
|
||||||
|
if not self.floating_stopwatch.is_paused():
|
||||||
|
self.floating_stopwatch.pause()
|
||||||
|
except StopwatchError:
|
||||||
|
msg = 'floating_stopwatch.pause() failed at ac_charging_start(), AC_WAITING path'
|
||||||
|
logger.warning(msg)
|
||||||
|
self.error_handler(msg)
|
||||||
|
|
||||||
|
# idx == -1 means haven't started our program yet.
|
||||||
|
if self.active_current_idx == -1:
|
||||||
|
self.gen_next_current()
|
||||||
|
# self.set_hw_charging_current(self.min_allowed_current)
|
||||||
|
|
||||||
|
def gen_stop(self, reason: ChargingState):
|
||||||
|
self.charging_state = reason
|
||||||
|
|
||||||
|
if reason == ChargingState.AC_DONE:
|
||||||
|
event = ChargingEvent.AC_CHARGING_FINISHED
|
||||||
|
elif reason == ChargingState.NOT_CHARGING:
|
||||||
|
event = ChargingEvent.AC_DISCONNECTED
|
||||||
|
else:
|
||||||
|
raise ValueError(f'ac_charging_stop: unexpected reason {reason}')
|
||||||
|
|
||||||
|
logger.info(f'charging is finished, entering {reason} state')
|
||||||
|
self.charging_event_handler(event)
|
||||||
|
|
||||||
|
# Let Mr. Proper do his job
|
||||||
|
if self.active_current_idx != -1:
|
||||||
|
self.next_current_enter_time = 0
|
||||||
|
self.mostly_charged = False
|
||||||
|
self.active_current_idx = -1
|
||||||
|
self.floating_stopwatch.reset()
|
||||||
|
|
||||||
|
def gen_next_current(self, current=None):
|
||||||
|
if current is None:
|
||||||
|
try:
|
||||||
|
current = self._next_current()
|
||||||
|
logger.debug(f'gen_next_current: ready to change charging current to {current} A')
|
||||||
|
except IndexError:
|
||||||
|
logger.debug('gen_next_current: was going to change charging current, but no currents left; finishing charging program')
|
||||||
|
self.gen_stop(ChargingState.AC_DONE)
|
||||||
|
return
|
||||||
|
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
idx = self.currents.index(current)
|
||||||
|
except ValueError:
|
||||||
|
msg = f'gen_next_current: got current={current} but it\'s not in the currents list'
|
||||||
|
logger.error(msg)
|
||||||
|
self.error_handler(msg)
|
||||||
|
return
|
||||||
|
self.active_current_idx = idx
|
||||||
|
|
||||||
|
if self.current_change_direction == CurrentChangeDirection.DOWN:
|
||||||
|
if current == self.currents[0]:
|
||||||
|
self.mostly_charged = True
|
||||||
|
self.gen_stop(ChargingState.AC_DONE)
|
||||||
|
|
||||||
|
elif current == self.currents[1] and not self.mostly_charged:
|
||||||
|
self.mostly_charged = True
|
||||||
|
self.charging_event_handler(ChargingEvent.AC_MOSTLY_CHARGED)
|
||||||
|
|
||||||
|
self.set_hw_charging_current(current)
|
||||||
|
|
||||||
|
def set_hw_charging_current(self, current: int):
|
||||||
|
try:
|
||||||
|
response = inverter.exec('set-max-ac-charging-current', (0, current))
|
||||||
|
if response['result'] != 'ok':
|
||||||
|
logger.error(f'failed to change AC charging current to {current} A')
|
||||||
|
raise InverterError('set-max-ac-charging-current: inverterd reported error')
|
||||||
|
else:
|
||||||
|
self.charging_event_handler(ChargingEvent.AC_CURRENT_CHANGED, current=current)
|
||||||
|
logger.info(f'changed AC charging current to {current} A')
|
||||||
|
except InverterError as e:
|
||||||
|
self.error_handler(f'failed to set charging current to {current} A (caught InverterError)')
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
def _next_current(self):
|
||||||
|
if self.current_change_direction == CurrentChangeDirection.UP:
|
||||||
|
self.active_current_idx += 1
|
||||||
|
if self.active_current_idx == len(self.currents)-1:
|
||||||
|
logger.info('_next_current: charging current power direction to DOWN')
|
||||||
|
self.current_change_direction = CurrentChangeDirection.DOWN
|
||||||
|
self.next_current_enter_time = 0
|
||||||
|
else:
|
||||||
|
if self.active_current_idx == 0:
|
||||||
|
raise IndexError('can\'t go lower')
|
||||||
|
self.active_current_idx -= 1
|
||||||
|
|
||||||
|
logger.info(f'_next_current: active_current_idx set to {self.active_current_idx}, returning current of {self.currents[self.active_current_idx]} A')
|
||||||
|
return self.currents[self.active_current_idx]
|
||||||
|
|
||||||
|
def low_voltage_program(self, v: float, load_watts: int):
|
||||||
|
crit_level = cfg.vcrit
|
||||||
|
low_level = cfg.vlow
|
||||||
|
|
||||||
|
if v <= crit_level:
|
||||||
|
state = BatteryState.CRITICAL
|
||||||
|
elif v <= low_level:
|
||||||
|
state = BatteryState.LOW
|
||||||
|
else:
|
||||||
|
state = BatteryState.NORMAL
|
||||||
|
|
||||||
|
if state != self.battery_state:
|
||||||
|
self.battery_state = state
|
||||||
|
self.battery_event_handler(state, v, load_watts)
|
||||||
|
|
||||||
|
def set_charging_event_handler(self, handler: Callable):
|
||||||
|
self.charging_event_handler = handler
|
||||||
|
|
||||||
|
def set_battery_event_handler(self, handler: Callable):
|
||||||
|
self.battery_event_handler = handler
|
||||||
|
|
||||||
|
def set_error_handler(self, handler: Callable):
|
||||||
|
self.error_handler = handler
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.interrupted = True
|
||||||
|
|
||||||
|
def dump_status(self) -> dict:
|
||||||
|
return {
|
||||||
|
'interrupted': self.interrupted,
|
||||||
|
'currents': self.currents,
|
||||||
|
'active_current': self.active_current,
|
||||||
|
'current_change_direction': self.current_change_direction.name,
|
||||||
|
'battery_state': self.battery_state.name,
|
||||||
|
'charging_state': self.charging_state.name,
|
||||||
|
'mostly_charged': self.mostly_charged,
|
||||||
|
'floating_stopwatch_paused': self.floating_stopwatch.is_paused(),
|
||||||
|
'floating_stopwatch_elapsed': self.floating_stopwatch.get_elapsed_time(),
|
||||||
|
'time_now': time.time(),
|
||||||
|
'next_current_enter_time': self.next_current_enter_time,
|
||||||
|
}
|
8
src/home/inverter/util.py
Normal file
8
src/home/inverter/util.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def beautify_table(s):
|
||||||
|
lines = s.split('\n')
|
||||||
|
lines = list(map(lambda line: re.sub(r'\s+', ' ', line), lines))
|
||||||
|
lines = list(map(lambda line: re.sub(r'(.*?): (.*)', r'<b>\1:</b> \2', line), lines))
|
||||||
|
return '\n'.join(lines)
|
2
src/home/mqtt/__init__.py
Normal file
2
src/home/mqtt/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from .mqtt import MQTTBase
|
||||||
|
from .util import poll_tick
|
2
src/home/mqtt/message/__init__.py
Normal file
2
src/home/mqtt/message/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from .inverter import Status, Generation
|
||||||
|
from .sensors import Temperature
|
86
src/home/mqtt/message/inverter.py
Normal file
86
src/home/mqtt/message/inverter.py
Normal file
@ -0,0 +1,86 @@
|
|||||||
|
import struct
|
||||||
|
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class Status:
|
||||||
|
# 46 bytes
|
||||||
|
format = 'IHHHHHHBHHHHHBHHHHHHHH'
|
||||||
|
|
||||||
|
def pack(self, time: int, data: dict) -> bytes:
|
||||||
|
bits = 0
|
||||||
|
bits |= (data['mppt1_charger_status'] & 0x3)
|
||||||
|
bits |= (data['mppt2_charger_status'] & 0x3) << 2
|
||||||
|
bits |= (data['battery_power_direction'] & 0x3) << 4
|
||||||
|
bits |= (data['dc_ac_power_direction'] & 0x3) << 6
|
||||||
|
bits |= (data['line_power_direction'] & 0x3) << 8
|
||||||
|
bits |= (data['load_connected'] & 0x1) << 10
|
||||||
|
|
||||||
|
return struct.pack(
|
||||||
|
self.format,
|
||||||
|
time,
|
||||||
|
int(data['grid_voltage'] * 10),
|
||||||
|
int(data['grid_freq'] * 10),
|
||||||
|
int(data['ac_output_voltage'] * 10),
|
||||||
|
int(data['ac_output_freq'] * 10),
|
||||||
|
data['ac_output_apparent_power'],
|
||||||
|
data['ac_output_active_power'],
|
||||||
|
data['output_load_percent'],
|
||||||
|
int(data['battery_voltage'] * 10),
|
||||||
|
int(data['battery_voltage_scc'] * 10),
|
||||||
|
int(data['battery_voltage_scc2'] * 10),
|
||||||
|
data['battery_discharging_current'],
|
||||||
|
data['battery_charging_current'],
|
||||||
|
data['battery_capacity'],
|
||||||
|
data['inverter_heat_sink_temp'],
|
||||||
|
data['mppt1_charger_temp'],
|
||||||
|
data['mppt2_charger_temp'],
|
||||||
|
data['pv1_input_power'],
|
||||||
|
data['pv2_input_power'],
|
||||||
|
int(data['pv1_input_voltage'] * 10),
|
||||||
|
int(data['pv2_input_voltage'] * 10),
|
||||||
|
bits
|
||||||
|
)
|
||||||
|
|
||||||
|
def unpack(self, buf: bytes) -> Tuple[int, dict]:
|
||||||
|
data = struct.unpack(self.format, buf)
|
||||||
|
return data[0], {
|
||||||
|
'grid_voltage': data[1] / 10,
|
||||||
|
'grid_freq': data[2] / 10,
|
||||||
|
'ac_output_voltage': data[3] / 10,
|
||||||
|
'ac_output_freq': data[4] / 10,
|
||||||
|
'ac_output_apparent_power': data[5],
|
||||||
|
'ac_output_active_power': data[6],
|
||||||
|
'output_load_percent': data[7],
|
||||||
|
'battery_voltage': data[8] / 10,
|
||||||
|
'battery_voltage_scc': data[9] / 10,
|
||||||
|
'battery_voltage_scc2': data[10] / 10,
|
||||||
|
'battery_discharging_current': data[11],
|
||||||
|
'battery_charging_current': data[12],
|
||||||
|
'battery_capacity': data[13],
|
||||||
|
'inverter_heat_sink_temp': data[14],
|
||||||
|
'mppt1_charger_temp': data[15],
|
||||||
|
'mppt2_charger_temp': data[16],
|
||||||
|
'pv1_input_power': data[17],
|
||||||
|
'pv2_input_power': data[18],
|
||||||
|
'pv1_input_voltage': data[19] / 10,
|
||||||
|
'pv2_input_voltage': data[20] / 10,
|
||||||
|
'mppt1_charger_status': data[21] & 0x03,
|
||||||
|
'mppt2_charger_status': (data[21] >> 2) & 0x03,
|
||||||
|
'battery_power_direction': (data[21] >> 4) & 0x03,
|
||||||
|
'dc_ac_power_direction': (data[21] >> 6) & 0x03,
|
||||||
|
'line_power_direction': (data[21] >> 8) & 0x03,
|
||||||
|
'load_connected': (data[21] >> 10) & 0x01,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Generation:
|
||||||
|
# 8 bytes
|
||||||
|
format = 'II'
|
||||||
|
|
||||||
|
def pack(self, time: int, wh: int) -> bytes:
|
||||||
|
return struct.pack(self.format, int(time), wh)
|
||||||
|
|
||||||
|
def unpack(self, buf: bytes) -> tuple:
|
||||||
|
data = struct.unpack(self.format, buf)
|
||||||
|
return tuple(data)
|
19
src/home/mqtt/message/sensors.py
Normal file
19
src/home/mqtt/message/sensors.py
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import struct
|
||||||
|
|
||||||
|
from typing import Tuple
|
||||||
|
|
||||||
|
|
||||||
|
class Temperature:
|
||||||
|
format = 'IhH'
|
||||||
|
|
||||||
|
def pack(self, time: int, temp: float, rh: float) -> bytes:
|
||||||
|
return struct.pack(
|
||||||
|
self.format,
|
||||||
|
time,
|
||||||
|
int(temp*100),
|
||||||
|
int(rh*100)
|
||||||
|
)
|
||||||
|
|
||||||
|
def unpack(self, buf: bytes) -> Tuple[int, float, float]:
|
||||||
|
data = struct.unpack(self.format, buf)
|
||||||
|
return data[0], data[1]/100, data[2]/100
|
61
src/home/mqtt/mqtt.py
Normal file
61
src/home/mqtt/mqtt.py
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import os.path
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import ssl
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from typing import Tuple
|
||||||
|
from ..config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def username_and_password() -> Tuple[str, str]:
|
||||||
|
username = config['mqtt']['username'] if 'username' in config['mqtt'] else None
|
||||||
|
password = config['mqtt']['password'] if 'password' in config['mqtt'] else None
|
||||||
|
return username, password
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTBase:
|
||||||
|
def __init__(self, clean_session=True):
|
||||||
|
self.client = mqtt.Client(client_id=config['mqtt']['client_id'],
|
||||||
|
protocol=mqtt.MQTTv311,
|
||||||
|
clean_session=clean_session)
|
||||||
|
self.client.on_connect = self.on_connect
|
||||||
|
self.client.on_disconnect = self.on_disconnect
|
||||||
|
self.client.on_message = self.on_message
|
||||||
|
|
||||||
|
self.home_id = 1
|
||||||
|
|
||||||
|
username, password = username_and_password()
|
||||||
|
if username and password:
|
||||||
|
self.client.username_pw_set(username, password)
|
||||||
|
|
||||||
|
def configure_tls(self):
|
||||||
|
ca_certs = os.path.realpath(os.path.join(
|
||||||
|
os.path.dirname(os.path.realpath(__file__)),
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'..',
|
||||||
|
'assets',
|
||||||
|
'mqtt_ca.crt'
|
||||||
|
))
|
||||||
|
self.client.tls_set(ca_certs=ca_certs, cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLSv1_2)
|
||||||
|
|
||||||
|
def connect_and_loop(self, loop_forever=True):
|
||||||
|
host = config['mqtt']['host']
|
||||||
|
port = config['mqtt']['port']
|
||||||
|
|
||||||
|
self.client.connect(host, port, 60)
|
||||||
|
if loop_forever:
|
||||||
|
self.client.loop_forever()
|
||||||
|
else:
|
||||||
|
self.client.loop_start()
|
||||||
|
|
||||||
|
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
|
||||||
|
logger.info("Connected with result code " + str(rc))
|
||||||
|
|
||||||
|
def on_disconnect(self, client: mqtt.Client, userdata, rc):
|
||||||
|
logger.info("Disconnected with result code " + str(rc))
|
||||||
|
|
||||||
|
def on_message(self, client: mqtt.Client, userdata, msg):
|
||||||
|
logger.info(msg.topic + ": " + str(msg.payload))
|
8
src/home/mqtt/util.py
Normal file
8
src/home/mqtt/util.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def poll_tick(freq):
|
||||||
|
t = time.time()
|
||||||
|
while True:
|
||||||
|
t += freq
|
||||||
|
yield max(t - time.time(), 0)
|
16
src/home/relay/__init__.py
Normal file
16
src/home/relay/__init__.py
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
__all__ = ['RelayClient', 'RelayServer']
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
_map = {
|
||||||
|
'RelayClient': '.client',
|
||||||
|
'RelayServer': '.server'
|
||||||
|
}
|
||||||
|
|
||||||
|
if name in __all__:
|
||||||
|
module = importlib.import_module(_map[name], __name__)
|
||||||
|
return getattr(module, name)
|
||||||
|
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
2
src/home/relay/__init__.pyi
Normal file
2
src/home/relay/__init__.pyi
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from .client import RelayClient as RelayClient
|
||||||
|
from .server import RelayServer as RelayServer
|
39
src/home/relay/client.py
Normal file
39
src/home/relay/client.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import socket
|
||||||
|
|
||||||
|
|
||||||
|
class RelayClient:
|
||||||
|
def __init__(self, port=8307, host='127.0.0.1'):
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
self.sock.connect((self._host, self._port))
|
||||||
|
|
||||||
|
def _write(self, line):
|
||||||
|
self.sock.sendall((line+'\r\n').encode())
|
||||||
|
|
||||||
|
def _read(self):
|
||||||
|
buf = bytearray()
|
||||||
|
while True:
|
||||||
|
buf.extend(self.sock.recv(256))
|
||||||
|
if b'\r\n' in buf:
|
||||||
|
break
|
||||||
|
|
||||||
|
response = buf.decode().strip()
|
||||||
|
return response
|
||||||
|
|
||||||
|
def on(self):
|
||||||
|
self._write('on')
|
||||||
|
return self._read()
|
||||||
|
|
||||||
|
def off(self):
|
||||||
|
self._write('off')
|
||||||
|
return self._read()
|
||||||
|
|
||||||
|
def status(self):
|
||||||
|
self._write('get')
|
||||||
|
return self._read()
|
82
src/home/relay/server.py
Normal file
82
src/home/relay/server.py
Normal file
@ -0,0 +1,82 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from pyA20.gpio import gpio
|
||||||
|
from pyA20.gpio import port as gpioport
|
||||||
|
from ..util import Addr
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RelayServer:
|
||||||
|
OFF = 1
|
||||||
|
ON = 0
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
pinname: str,
|
||||||
|
addr: Addr):
|
||||||
|
if not hasattr(gpioport, pinname):
|
||||||
|
raise ValueError(f'invalid pin {pinname}')
|
||||||
|
|
||||||
|
self.pin = getattr(gpioport, pinname)
|
||||||
|
self.addr = addr
|
||||||
|
|
||||||
|
gpio.init()
|
||||||
|
gpio.setcfg(self.pin, gpio.OUTPUT)
|
||||||
|
|
||||||
|
self.lock = asyncio.Lock()
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
asyncio.run(self.run_server())
|
||||||
|
|
||||||
|
async def relay_set(self, value):
|
||||||
|
async with self.lock:
|
||||||
|
gpio.output(self.pin, value)
|
||||||
|
|
||||||
|
async def relay_get(self):
|
||||||
|
async with self.lock:
|
||||||
|
return int(gpio.input(self.pin)) == RelayServer.ON
|
||||||
|
|
||||||
|
async def handle_client(self, reader, writer):
|
||||||
|
request = None
|
||||||
|
while request != 'quit':
|
||||||
|
try:
|
||||||
|
request = await reader.read(255)
|
||||||
|
if request == b'\x04':
|
||||||
|
break
|
||||||
|
request = request.decode('utf-8').strip()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
data = 'unknown'
|
||||||
|
if request == 'on':
|
||||||
|
await self.relay_set(RelayServer.ON)
|
||||||
|
logger.debug('set on')
|
||||||
|
data = 'ok'
|
||||||
|
|
||||||
|
elif request == 'off':
|
||||||
|
await self.relay_set(RelayServer.OFF)
|
||||||
|
logger.debug('set off')
|
||||||
|
data = 'ok'
|
||||||
|
|
||||||
|
elif request == 'get':
|
||||||
|
status = await self.relay_get()
|
||||||
|
data = 'on' if status is True else 'off'
|
||||||
|
|
||||||
|
writer.write((data + '\r\n').encode('utf-8'))
|
||||||
|
try:
|
||||||
|
await writer.drain()
|
||||||
|
except ConnectionError:
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
writer.close()
|
||||||
|
except ConnectionError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def run_server(self):
|
||||||
|
host, port = self.addr
|
||||||
|
server = await asyncio.start_server(self.handle_client, host, port)
|
||||||
|
async with server:
|
||||||
|
logger.info('Server started.')
|
||||||
|
await server.serve_forever()
|
8
src/home/sound/__init__.py
Normal file
8
src/home/sound/__init__.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from .node_client import SoundNodeClient
|
||||||
|
from .record import (
|
||||||
|
RecordStatus,
|
||||||
|
RecordingNotFoundError,
|
||||||
|
Recorder,
|
||||||
|
)
|
||||||
|
from .storage import RecordStorage, RecordFile
|
||||||
|
from .record_client import RecordClient
|
91
src/home/sound/amixer.py
Normal file
91
src/home/sound/amixer.py
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import subprocess
|
||||||
|
|
||||||
|
from ..config import config
|
||||||
|
from threading import Lock
|
||||||
|
from typing import Union
|
||||||
|
|
||||||
|
|
||||||
|
_lock = Lock()
|
||||||
|
_default_step = 5
|
||||||
|
|
||||||
|
|
||||||
|
def has_control(s: str) -> bool:
|
||||||
|
for control in config['amixer']['controls']:
|
||||||
|
if control['name'] == s:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_caps(s: str) -> list[str]:
|
||||||
|
for control in config['amixer']['controls']:
|
||||||
|
if control['name'] == s:
|
||||||
|
return control['caps']
|
||||||
|
raise KeyError(f'control {s} not found')
|
||||||
|
|
||||||
|
|
||||||
|
def get_all() -> list:
|
||||||
|
controls = []
|
||||||
|
for control in config['amixer']['controls']:
|
||||||
|
controls.append({
|
||||||
|
'name': control['name'],
|
||||||
|
'info': get(control['name']),
|
||||||
|
'caps': control['caps']
|
||||||
|
})
|
||||||
|
return controls
|
||||||
|
|
||||||
|
|
||||||
|
def get(control: str):
|
||||||
|
return call('get', control)
|
||||||
|
|
||||||
|
|
||||||
|
def mute(control):
|
||||||
|
return call('set', control, 'mute')
|
||||||
|
|
||||||
|
|
||||||
|
def unmute(control):
|
||||||
|
return call('set', control, 'unmute')
|
||||||
|
|
||||||
|
|
||||||
|
def cap(control):
|
||||||
|
return call('set', control, 'cap')
|
||||||
|
|
||||||
|
|
||||||
|
def nocap(control):
|
||||||
|
return call('set', control, 'nocap')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_step() -> int:
|
||||||
|
if 'step' in config['amixer']:
|
||||||
|
return int(config['amixer']['step'])
|
||||||
|
|
||||||
|
return _default_step
|
||||||
|
|
||||||
|
|
||||||
|
def incr(control, step=None):
|
||||||
|
if step is None:
|
||||||
|
step = _get_default_step()
|
||||||
|
return call('set', control, f'{step}%+')
|
||||||
|
|
||||||
|
|
||||||
|
def decr(control, step=None):
|
||||||
|
if step is None:
|
||||||
|
step = _get_default_step()
|
||||||
|
return call('set', control, f'{step}%-')
|
||||||
|
|
||||||
|
|
||||||
|
def call(*args, return_code=False) -> Union[int, str]:
|
||||||
|
with _lock:
|
||||||
|
result = subprocess.run([config['amixer']['bin'], *args],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE)
|
||||||
|
if return_code:
|
||||||
|
return result.returncode
|
||||||
|
|
||||||
|
if result.returncode != 0:
|
||||||
|
raise AmixerError(result.stderr.decode().strip())
|
||||||
|
|
||||||
|
return result.stdout.decode().strip()
|
||||||
|
|
||||||
|
|
||||||
|
class AmixerError(OSError):
|
||||||
|
pass
|
109
src/home/sound/node_client.py
Normal file
109
src/home/sound/node_client.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
from ..util import Addr
|
||||||
|
from ..api.errors import ApiResponseError
|
||||||
|
from typing import Optional, Union
|
||||||
|
from .record import RecordFile
|
||||||
|
|
||||||
|
|
||||||
|
class SoundNodeClient:
|
||||||
|
def __init__(self, addr: Addr):
|
||||||
|
self.endpoint = f'http://{addr[0]}:{addr[1]}'
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def amixer_get_all(self):
|
||||||
|
return self._call('amixer/get-all/')
|
||||||
|
|
||||||
|
def amixer_get(self, control: str):
|
||||||
|
return self._call(f'amixer/get/{control}/')
|
||||||
|
|
||||||
|
def amixer_incr(self, control: str, step: Optional[int] = None):
|
||||||
|
params = {'step': step} if step is not None else None
|
||||||
|
return self._call(f'amixer/incr/{control}/', params=params)
|
||||||
|
|
||||||
|
def amixer_decr(self, control: str, step: Optional[int] = None):
|
||||||
|
params = {'step': step} if step is not None else None
|
||||||
|
return self._call(f'amixer/decr/{control}/', params=params)
|
||||||
|
|
||||||
|
def amixer_mute(self, control: str):
|
||||||
|
return self._call(f'amixer/mute/{control}/')
|
||||||
|
|
||||||
|
def amixer_unmute(self, control: str):
|
||||||
|
return self._call(f'amixer/unmute/{control}/')
|
||||||
|
|
||||||
|
def amixer_cap(self, control: str):
|
||||||
|
return self._call(f'amixer/cap/{control}/')
|
||||||
|
|
||||||
|
def amixer_nocap(self, control: str):
|
||||||
|
return self._call(f'amixer/nocap/{control}/')
|
||||||
|
|
||||||
|
def record(self, duration: int):
|
||||||
|
return self._call('record/', params={"duration": duration})
|
||||||
|
|
||||||
|
def record_info(self, record_id: int):
|
||||||
|
return self._call(f'record/info/{record_id}/')
|
||||||
|
|
||||||
|
def record_forget(self, record_id: int):
|
||||||
|
return self._call(f'record/forget/{record_id}/')
|
||||||
|
|
||||||
|
def record_download(self, record_id: int, output: str):
|
||||||
|
return self._call(f'record/download/{record_id}/', save_to=output)
|
||||||
|
|
||||||
|
def storage_list(self, extended=False, as_objects=False) -> Union[list[str], list[dict], list[RecordFile]]:
|
||||||
|
r = self._call('storage/list/', params={'extended': int(extended)})
|
||||||
|
files = r['files']
|
||||||
|
if as_objects:
|
||||||
|
return self.record_list_from_serialized(files)
|
||||||
|
return files
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def record_list_from_serialized(files: Union[list[str], list[dict]]):
|
||||||
|
new_files = []
|
||||||
|
for f in files:
|
||||||
|
kwargs = {'remote': True}
|
||||||
|
if isinstance(f, dict):
|
||||||
|
name = f['filename']
|
||||||
|
kwargs['remote_filesize'] = f['filesize']
|
||||||
|
else:
|
||||||
|
name = f
|
||||||
|
item = RecordFile(name, **kwargs)
|
||||||
|
new_files.append(item)
|
||||||
|
return new_files
|
||||||
|
|
||||||
|
def storage_delete(self, file_id: str):
|
||||||
|
return self._call('storage/delete/', params={'file_id': file_id})
|
||||||
|
|
||||||
|
def storage_download(self, file_id: str, output: str):
|
||||||
|
return self._call('storage/download/', params={'file_id': file_id}, save_to=output)
|
||||||
|
|
||||||
|
def _call(self,
|
||||||
|
method: str,
|
||||||
|
params: dict = None,
|
||||||
|
save_to: Optional[str] = None):
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if isinstance(params, dict):
|
||||||
|
kwargs['params'] = params
|
||||||
|
if save_to:
|
||||||
|
kwargs['stream'] = True
|
||||||
|
|
||||||
|
url = f'{self.endpoint}/{method}'
|
||||||
|
self.logger.debug(f'calling {url}, kwargs: {kwargs}')
|
||||||
|
|
||||||
|
r = requests.get(url, **kwargs)
|
||||||
|
if r.status_code != 200:
|
||||||
|
response = r.json()
|
||||||
|
raise ApiResponseError(status_code=r.status_code,
|
||||||
|
error_type=response['error'],
|
||||||
|
error_message=response['message'] or None,
|
||||||
|
error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None)
|
||||||
|
|
||||||
|
if save_to:
|
||||||
|
r.raise_for_status()
|
||||||
|
with open(save_to, 'wb') as f:
|
||||||
|
shutil.copyfileobj(r.raw, f)
|
||||||
|
return True
|
||||||
|
|
||||||
|
return r.json()['response']
|
400
src/home/sound/record.py
Normal file
400
src/home/sound/record.py
Normal file
@ -0,0 +1,400 @@
|
|||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import subprocess
|
||||||
|
import signal
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Optional
|
||||||
|
from ..config import config
|
||||||
|
from ..util import find_child_processes
|
||||||
|
from .storage import RecordFile, RecordStorage
|
||||||
|
|
||||||
|
|
||||||
|
_history_item_timeout = 7200
|
||||||
|
_history_cleanup_freq = 3600
|
||||||
|
|
||||||
|
|
||||||
|
class RecordStatus(Enum):
|
||||||
|
WAITING = auto()
|
||||||
|
RECORDING = auto()
|
||||||
|
FINISHED = auto()
|
||||||
|
ERROR = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class RecordHistoryItem:
|
||||||
|
id: int
|
||||||
|
request_time: float
|
||||||
|
start_time: float
|
||||||
|
stop_time: float
|
||||||
|
relations: list[int]
|
||||||
|
status: RecordStatus
|
||||||
|
error: Optional[Exception]
|
||||||
|
file: Optional[RecordFile]
|
||||||
|
creation_time: float
|
||||||
|
|
||||||
|
def __init__(self, id):
|
||||||
|
self.id = id
|
||||||
|
self.request_time = 0
|
||||||
|
self.start_time = 0
|
||||||
|
self.stop_time = 0
|
||||||
|
self.relations = []
|
||||||
|
self.status = RecordStatus.WAITING
|
||||||
|
self.file = None
|
||||||
|
self.error = None
|
||||||
|
self.creation_time = time.time()
|
||||||
|
|
||||||
|
def add_relation(self, related_id: int):
|
||||||
|
self.relations.append(related_id)
|
||||||
|
|
||||||
|
def mark_started(self, start_time: float):
|
||||||
|
self.start_time = start_time
|
||||||
|
self.status = RecordStatus.RECORDING
|
||||||
|
|
||||||
|
def mark_finished(self, end_time: float, file: RecordFile):
|
||||||
|
self.stop_time = end_time
|
||||||
|
self.file = file
|
||||||
|
self.status = RecordStatus.FINISHED
|
||||||
|
|
||||||
|
def mark_failed(self, error: Exception):
|
||||||
|
self.status = RecordStatus.ERROR
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
def as_dict(self) -> dict:
|
||||||
|
data = {
|
||||||
|
'id': self.id,
|
||||||
|
'request_time': self.request_time,
|
||||||
|
'status': self.status.value,
|
||||||
|
'relations': self.relations,
|
||||||
|
'start_time': self.start_time,
|
||||||
|
'stop_time': self.stop_time,
|
||||||
|
}
|
||||||
|
if self.error:
|
||||||
|
data['error'] = str(self.error)
|
||||||
|
if self.file:
|
||||||
|
data['file'] = self.file.__dict__()
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
class RecordingNotFoundError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class RecordHistory:
|
||||||
|
history: dict[int, RecordHistoryItem]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.history = {}
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def add(self, record_id: int):
|
||||||
|
self.logger.debug(f'add: record_id={record_id}')
|
||||||
|
|
||||||
|
r = RecordHistoryItem(record_id)
|
||||||
|
r.request_time = time.time()
|
||||||
|
|
||||||
|
self.history[record_id] = r
|
||||||
|
|
||||||
|
def delete(self, record_id: int):
|
||||||
|
self.logger.debug(f'delete: record_id={record_id}')
|
||||||
|
del self.history[record_id]
|
||||||
|
|
||||||
|
def cleanup(self):
|
||||||
|
del_ids = []
|
||||||
|
for rid, item in self.history.items():
|
||||||
|
if item.creation_time < time.time()-_history_item_timeout:
|
||||||
|
del_ids.append(rid)
|
||||||
|
for rid in del_ids:
|
||||||
|
self.delete(rid)
|
||||||
|
|
||||||
|
def __getitem__(self, key):
|
||||||
|
if key not in self.history:
|
||||||
|
raise RecordingNotFoundError()
|
||||||
|
|
||||||
|
return self.history[key]
|
||||||
|
|
||||||
|
def __setitem__(self, key, value):
|
||||||
|
raise NotImplementedError('setting history item this way is prohibited')
|
||||||
|
|
||||||
|
def __contains__(self, key):
|
||||||
|
return key in self.history
|
||||||
|
|
||||||
|
|
||||||
|
class Recording:
|
||||||
|
start_time: float
|
||||||
|
stop_time: float
|
||||||
|
duration: int
|
||||||
|
record_id: int
|
||||||
|
arecord_pid: Optional[int]
|
||||||
|
process: Optional[subprocess.Popen]
|
||||||
|
|
||||||
|
g_record_id = 1
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.start_time = 0
|
||||||
|
self.stop_time = 0
|
||||||
|
self.duration = 0
|
||||||
|
self.process = None
|
||||||
|
self.arecord_pid = None
|
||||||
|
self.record_id = Recording.next_id()
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def is_started(self) -> bool:
|
||||||
|
return self.start_time > 0 and self.stop_time > 0
|
||||||
|
|
||||||
|
def is_waiting(self):
|
||||||
|
return self.duration > 0
|
||||||
|
|
||||||
|
def ask_for(self, duration) -> int:
|
||||||
|
overtime = 0
|
||||||
|
orig_duration = duration
|
||||||
|
|
||||||
|
if self.is_started():
|
||||||
|
already_passed = time.time() - self.start_time
|
||||||
|
max_duration = Recorder.get_max_record_time() - already_passed
|
||||||
|
self.logger.debug(f'ask_for({orig_duration}): recording is in progress, already passed {already_passed}s, max_duration set to {max_duration}')
|
||||||
|
else:
|
||||||
|
max_duration = Recorder.get_max_record_time()
|
||||||
|
|
||||||
|
if duration > max_duration:
|
||||||
|
overtime = duration - max_duration
|
||||||
|
duration = max_duration
|
||||||
|
|
||||||
|
self.logger.debug(f'ask_for({orig_duration}): requested duration ({orig_duration}) is greater than max ({max_duration}), overtime is {overtime}')
|
||||||
|
|
||||||
|
self.duration += duration
|
||||||
|
if self.is_started():
|
||||||
|
til_end = self.stop_time - time.time()
|
||||||
|
if til_end < 0:
|
||||||
|
til_end = 0
|
||||||
|
|
||||||
|
_prev_stop_time = self.stop_time
|
||||||
|
_to_add = duration - til_end
|
||||||
|
if _to_add < 0:
|
||||||
|
_to_add = 0
|
||||||
|
|
||||||
|
self.stop_time += _to_add
|
||||||
|
self.logger.debug(f'ask_for({orig_duration}): adding {_to_add} to stop_time (before: {_prev_stop_time}, after: {self.stop_time})')
|
||||||
|
|
||||||
|
return overtime
|
||||||
|
|
||||||
|
def start(self, output: str):
|
||||||
|
assert self.start_time == 0 and self.stop_time == 0, "already started?!"
|
||||||
|
assert self.process is None, "self.process is not None, what the hell?"
|
||||||
|
|
||||||
|
cur = time.time()
|
||||||
|
self.start_time = cur
|
||||||
|
self.stop_time = cur + self.duration
|
||||||
|
|
||||||
|
arecord = config['arecord']['bin']
|
||||||
|
lame = config['lame']['bin']
|
||||||
|
b = config['lame']['bitrate']
|
||||||
|
|
||||||
|
cmd = f'{arecord} -f S16 -r 44100 -t raw 2>/dev/null | {lame} -r -s 44.1 -b {b} -m m - {output} >/dev/null 2>/dev/null'
|
||||||
|
self.logger.debug(f'start: running `{cmd}`')
|
||||||
|
self.process = subprocess.Popen(cmd, shell=True, stdin=None, stdout=None, stderr=None, close_fds=True)
|
||||||
|
|
||||||
|
sh_pid = self.process.pid
|
||||||
|
self.logger.debug(f'start: started, pid of shell is {sh_pid}')
|
||||||
|
|
||||||
|
arecord_pid = self.find_arecord_pid(sh_pid)
|
||||||
|
if arecord_pid is not None:
|
||||||
|
self.arecord_pid = arecord_pid
|
||||||
|
self.logger.debug(f'start: pid of arecord is {arecord_pid}')
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self.process:
|
||||||
|
if self.arecord_pid is None:
|
||||||
|
self.arecord_pid = self.find_arecord_pid(self.process.pid)
|
||||||
|
|
||||||
|
if self.arecord_pid is not None:
|
||||||
|
os.kill(self.arecord_pid, signal.SIGINT)
|
||||||
|
timeout = config['node']['process_wait_timeout']
|
||||||
|
|
||||||
|
self.logger.debug(f'stop: sent SIGINT to {self.arecord_pid}. now waiting up to {timeout} seconds...')
|
||||||
|
try:
|
||||||
|
self.process.wait(timeout=timeout)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.logger.warning(f'stop: wait({timeout}): timeout expired, calling terminate()')
|
||||||
|
self.process.terminate()
|
||||||
|
else:
|
||||||
|
self.logger.warning('stop: pid of arecord is unknown, calling terminate()')
|
||||||
|
self.process.terminate()
|
||||||
|
|
||||||
|
rc = self.process.returncode
|
||||||
|
self.logger.debug(f'stop: rc={rc}')
|
||||||
|
|
||||||
|
self.process = None
|
||||||
|
self.arecord_pid = 0
|
||||||
|
|
||||||
|
self.duration = 0
|
||||||
|
self.start_time = 0
|
||||||
|
self.stop_time = 0
|
||||||
|
|
||||||
|
def find_arecord_pid(self, sh_pid: int):
|
||||||
|
try:
|
||||||
|
children = find_child_processes(sh_pid)
|
||||||
|
except OSError as exc:
|
||||||
|
self.logger.warning(f'failed to find child process of {sh_pid}: ' + str(exc))
|
||||||
|
return None
|
||||||
|
|
||||||
|
for child in children:
|
||||||
|
if 'arecord' in child.cmd:
|
||||||
|
return child.pid
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def next_id() -> int:
|
||||||
|
cur_id = Recording.g_record_id
|
||||||
|
Recording.g_record_id += 1
|
||||||
|
return cur_id
|
||||||
|
|
||||||
|
def increment_id(self):
|
||||||
|
self.record_id = Recording.next_id()
|
||||||
|
|
||||||
|
|
||||||
|
class Recorder:
|
||||||
|
interrupted: bool
|
||||||
|
lock: threading.Lock
|
||||||
|
history_lock: threading.Lock
|
||||||
|
recording: Optional[Recording]
|
||||||
|
overtime: int
|
||||||
|
history: RecordHistory
|
||||||
|
next_history_cleanup_time: float
|
||||||
|
storage: RecordStorage
|
||||||
|
|
||||||
|
def __init__(self, storage: RecordStorage):
|
||||||
|
self.storage = storage
|
||||||
|
self.recording = Recording()
|
||||||
|
self.interrupted = False
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.history_lock = threading.Lock()
|
||||||
|
self.overtime = 0
|
||||||
|
self.history = RecordHistory()
|
||||||
|
self.next_history_cleanup_time = 0
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def start_thread(self):
|
||||||
|
t = threading.Thread(target=self.loop)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
def loop(self) -> None:
|
||||||
|
tempname = os.path.join(self.storage.root, 'temp.mp3')
|
||||||
|
|
||||||
|
while not self.interrupted:
|
||||||
|
cur = time.time()
|
||||||
|
stopped = False
|
||||||
|
cur_record_id = None
|
||||||
|
|
||||||
|
if self.next_history_cleanup_time == 0:
|
||||||
|
self.next_history_cleanup_time = time.time() + _history_cleanup_freq
|
||||||
|
elif self.next_history_cleanup_time <= time.time():
|
||||||
|
self.logger.debug('loop: calling history.cleanup()')
|
||||||
|
try:
|
||||||
|
self.history.cleanup()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.error('loop: error while history.cleanup(): ' + str(e))
|
||||||
|
self.next_history_cleanup_time = time.time() + _history_cleanup_freq
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
cur_record_id = self.recording.record_id
|
||||||
|
# self.logger.debug(f'cur_record_id={cur_record_id}')
|
||||||
|
|
||||||
|
if not self.recording.is_started():
|
||||||
|
if self.recording.is_waiting():
|
||||||
|
try:
|
||||||
|
if os.path.exists(tempname):
|
||||||
|
self.logger.warning(f'loop: going to start new recording, but {tempname} still exists, unlinking..')
|
||||||
|
try:
|
||||||
|
os.unlink(tempname)
|
||||||
|
except OSError as e:
|
||||||
|
self.logger.exception(e)
|
||||||
|
self.recording.start(tempname)
|
||||||
|
with self.history_lock:
|
||||||
|
self.history[cur_record_id].mark_started(self.recording.start_time)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception(exc)
|
||||||
|
|
||||||
|
# there should not be any errors, but still..
|
||||||
|
try:
|
||||||
|
self.recording.stop()
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception(exc)
|
||||||
|
|
||||||
|
with self.history_lock:
|
||||||
|
self.history[cur_record_id].mark_failed(exc)
|
||||||
|
|
||||||
|
self.logger.debug(f'loop: start exc path: calling increment_id()')
|
||||||
|
self.recording.increment_id()
|
||||||
|
else:
|
||||||
|
if cur >= self.recording.stop_time:
|
||||||
|
try:
|
||||||
|
start_time = self.recording.start_time
|
||||||
|
stop_time = self.recording.stop_time
|
||||||
|
self.recording.stop()
|
||||||
|
|
||||||
|
saved_name = self.storage.save(tempname,
|
||||||
|
record_id=cur_record_id,
|
||||||
|
start_time=int(start_time),
|
||||||
|
stop_time=int(stop_time))
|
||||||
|
|
||||||
|
with self.history_lock:
|
||||||
|
self.history[cur_record_id].mark_finished(stop_time, saved_name)
|
||||||
|
except Exception as exc:
|
||||||
|
self.logger.exception(exc)
|
||||||
|
with self.history_lock:
|
||||||
|
self.history[cur_record_id].mark_failed(exc)
|
||||||
|
finally:
|
||||||
|
self.logger.debug(f'loop: stop exc final path: calling increment_id()')
|
||||||
|
self.recording.increment_id()
|
||||||
|
|
||||||
|
stopped = True
|
||||||
|
|
||||||
|
if stopped and self.overtime > 0:
|
||||||
|
self.logger.info(f'recording {cur_record_id} is stopped, but we\'ve got overtime ({self.overtime})')
|
||||||
|
_overtime = self.overtime
|
||||||
|
self.overtime = 0
|
||||||
|
|
||||||
|
related_id = self.record(_overtime)
|
||||||
|
self.logger.info(f'enqueued another record with id {related_id}')
|
||||||
|
|
||||||
|
if cur_record_id is not None:
|
||||||
|
with self.history_lock:
|
||||||
|
self.history[cur_record_id].add_relation(related_id)
|
||||||
|
|
||||||
|
time.sleep(0.2)
|
||||||
|
|
||||||
|
def record(self, duration: int) -> int:
|
||||||
|
self.logger.debug(f'record: duration={duration}')
|
||||||
|
with self.lock:
|
||||||
|
overtime = self.recording.ask_for(duration)
|
||||||
|
self.logger.debug(f'overtime={overtime}')
|
||||||
|
|
||||||
|
if overtime > self.overtime:
|
||||||
|
self.overtime = overtime
|
||||||
|
|
||||||
|
if not self.recording.is_started():
|
||||||
|
with self.history_lock:
|
||||||
|
self.history.add(self.recording.record_id)
|
||||||
|
|
||||||
|
return self.recording.record_id
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.interrupted = True
|
||||||
|
|
||||||
|
def get_info(self, record_id: int) -> RecordHistoryItem:
|
||||||
|
with self.history_lock:
|
||||||
|
return self.history[record_id]
|
||||||
|
|
||||||
|
def forget(self, record_id: int):
|
||||||
|
with self.history_lock:
|
||||||
|
self.logger.info(f'forget: removing record {record_id} from history')
|
||||||
|
self.history.delete(record_id)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_max_record_time() -> int:
|
||||||
|
return config['node']['record_max_time']
|
||||||
|
|
142
src/home/sound/record_client.py
Normal file
142
src/home/sound/record_client.py
Normal file
@ -0,0 +1,142 @@
|
|||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
from tempfile import gettempdir
|
||||||
|
from .record import RecordStatus
|
||||||
|
from .node_client import SoundNodeClient
|
||||||
|
from ..util import Addr
|
||||||
|
from typing import Optional, Callable
|
||||||
|
|
||||||
|
|
||||||
|
class RecordClient:
|
||||||
|
interrupted: bool
|
||||||
|
logger: logging.Logger
|
||||||
|
clients: dict[str, SoundNodeClient]
|
||||||
|
awaiting: dict[str, dict[int, Optional[dict]]]
|
||||||
|
error_handler: Optional[Callable]
|
||||||
|
finished_handler: Optional[Callable]
|
||||||
|
download_on_finish: bool
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
nodes: dict[str, Addr],
|
||||||
|
error_handler: Optional[Callable] = None,
|
||||||
|
finished_handler: Optional[Callable] = None,
|
||||||
|
download_on_finish=False):
|
||||||
|
self.interrupted = False
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
self.clients = {}
|
||||||
|
self.awaiting = {}
|
||||||
|
self.download_on_finish = download_on_finish
|
||||||
|
|
||||||
|
self.error_handler = error_handler
|
||||||
|
self.finished_handler = finished_handler
|
||||||
|
|
||||||
|
self.awaiting_lock = threading.Lock()
|
||||||
|
|
||||||
|
for node, addr in nodes.items():
|
||||||
|
self.clients[node] = SoundNodeClient(addr)
|
||||||
|
self.awaiting[node] = {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
t = threading.Thread(target=self.loop)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
except (KeyboardInterrupt, SystemExit) as exc:
|
||||||
|
self.stop()
|
||||||
|
self.logger.exception(exc)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.interrupted = True
|
||||||
|
|
||||||
|
def loop(self):
|
||||||
|
while not self.interrupted:
|
||||||
|
# self.logger.debug('loop: tick')
|
||||||
|
|
||||||
|
for node in self.awaiting.keys():
|
||||||
|
with self.awaiting_lock:
|
||||||
|
record_ids = list(self.awaiting[node].keys())
|
||||||
|
if not record_ids:
|
||||||
|
continue
|
||||||
|
|
||||||
|
self.logger.debug(f'loop: node `{node}` awaiting list: {record_ids}')
|
||||||
|
|
||||||
|
cl = self.getclient(node)
|
||||||
|
del_ids = []
|
||||||
|
for rid in record_ids:
|
||||||
|
info = cl.record_info(rid)
|
||||||
|
|
||||||
|
if info['relations']:
|
||||||
|
for relid in info['relations']:
|
||||||
|
self.wait_for_record(node, relid, self.awaiting[node][rid], is_relative=True)
|
||||||
|
|
||||||
|
status = RecordStatus(info['status'])
|
||||||
|
if status in (RecordStatus.FINISHED, RecordStatus.ERROR):
|
||||||
|
if status == RecordStatus.FINISHED:
|
||||||
|
if self.download_on_finish:
|
||||||
|
local_fn = self.download(node, rid, info['file']['fileid'])
|
||||||
|
else:
|
||||||
|
local_fn = None
|
||||||
|
self._report_finished(info, local_fn, self.awaiting[node][rid])
|
||||||
|
else:
|
||||||
|
self._report_error(info, self.awaiting[node][rid])
|
||||||
|
del_ids.append(rid)
|
||||||
|
self.logger.debug(f'record {rid}: status {status}')
|
||||||
|
|
||||||
|
if del_ids:
|
||||||
|
self.logger.debug(f'deleting {del_ids} from {node}\'s awaiting list')
|
||||||
|
with self.awaiting_lock:
|
||||||
|
for del_id in del_ids:
|
||||||
|
del self.awaiting[node][del_id]
|
||||||
|
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
self.logger.info('loop ended')
|
||||||
|
|
||||||
|
def getclient(self, node: str):
|
||||||
|
return self.clients[node]
|
||||||
|
|
||||||
|
def record(self,
|
||||||
|
node: str,
|
||||||
|
duration: int,
|
||||||
|
userdata: Optional[dict] = None) -> int:
|
||||||
|
self.logger.debug(f'record: node={node}, duration={duration}, userdata={userdata}')
|
||||||
|
|
||||||
|
cl = self.getclient(node)
|
||||||
|
record_id = cl.record(duration)['id']
|
||||||
|
self.logger.debug(f'record: request sent, record_id={record_id}')
|
||||||
|
|
||||||
|
self.wait_for_record(node, record_id, userdata)
|
||||||
|
return record_id
|
||||||
|
|
||||||
|
def wait_for_record(self,
|
||||||
|
node: str,
|
||||||
|
record_id: int,
|
||||||
|
userdata: Optional[dict] = None,
|
||||||
|
is_relative=False):
|
||||||
|
with self.awaiting_lock:
|
||||||
|
if record_id not in self.awaiting[node]:
|
||||||
|
msg = f'wait_for_record: adding {record_id} to {node}'
|
||||||
|
if is_relative:
|
||||||
|
msg += ' (by relation)'
|
||||||
|
self.logger.debug(msg)
|
||||||
|
|
||||||
|
self.awaiting[node][record_id] = userdata
|
||||||
|
|
||||||
|
def download(self, node: str, record_id: int, fileid: str):
|
||||||
|
dst = os.path.join(gettempdir(), f'{node}_{fileid}.mp3')
|
||||||
|
cl = self.getclient(node)
|
||||||
|
cl.record_download(record_id, dst)
|
||||||
|
return dst
|
||||||
|
|
||||||
|
def forget(self, node: str, rid: int):
|
||||||
|
self.getclient(node).record_forget(rid)
|
||||||
|
|
||||||
|
def _report_finished(self, *args):
|
||||||
|
if self.finished_handler:
|
||||||
|
self.finished_handler(*args)
|
||||||
|
|
||||||
|
def _report_error(self, *args):
|
||||||
|
if self.error_handler:
|
||||||
|
self.error_handler(*args)
|
155
src/home/sound/storage.py
Normal file
155
src/home/sound/storage.py
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from typing import Optional, Union
|
||||||
|
from datetime import datetime
|
||||||
|
from ..util import strgen
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordFile:
|
||||||
|
start_time: Optional[datetime]
|
||||||
|
stop_time: Optional[datetime]
|
||||||
|
record_id: Optional[int]
|
||||||
|
name: str
|
||||||
|
file_id: Optional[str]
|
||||||
|
remote: bool
|
||||||
|
remote_filesize: int
|
||||||
|
storage_root: str
|
||||||
|
|
||||||
|
human_date_dmt = '%d.%m.%y'
|
||||||
|
human_time_fmt = '%H:%M:%S'
|
||||||
|
|
||||||
|
def __init__(self, filename: str, remote=False, remote_filesize=None, storage_root='/'):
|
||||||
|
self.name = filename
|
||||||
|
self.storage_root = storage_root
|
||||||
|
|
||||||
|
self.remote = remote
|
||||||
|
self.remote_filesize = remote_filesize
|
||||||
|
|
||||||
|
m = re.match(r'^(\d{6}-\d{6})_(\d{6}-\d{6})_id(\d+)(_\w+)?\.mp3$', filename)
|
||||||
|
if m:
|
||||||
|
self.start_time = datetime.strptime(m.group(1), RecordStorage.time_fmt)
|
||||||
|
self.stop_time = datetime.strptime(m.group(2), RecordStorage.time_fmt)
|
||||||
|
self.record_id = int(m.group(3))
|
||||||
|
self.file_id = (m.group(1) + '_' + m.group(2)).replace('-', '_')
|
||||||
|
else:
|
||||||
|
logger.warning(f'unexpected filename: {filename}')
|
||||||
|
self.start_time = None
|
||||||
|
self.stop_time = None
|
||||||
|
self.record_id = None
|
||||||
|
self.file_id = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def path(self):
|
||||||
|
if self.remote:
|
||||||
|
return RuntimeError('remote recording, can\'t get real path')
|
||||||
|
|
||||||
|
return os.path.realpath(os.path.join(
|
||||||
|
self.storage_root, self.name
|
||||||
|
))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_humantime(self) -> str:
|
||||||
|
if self.start_time is None:
|
||||||
|
return '?'
|
||||||
|
fmt = f'{RecordFile.human_date_dmt} {RecordFile.human_time_fmt}'
|
||||||
|
return self.start_time.strftime(fmt)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stop_humantime(self) -> str:
|
||||||
|
if self.stop_time is None:
|
||||||
|
return '?'
|
||||||
|
fmt = RecordFile.human_time_fmt
|
||||||
|
if self.start_time.date() != self.stop_time.date():
|
||||||
|
fmt = f'{RecordFile.human_date_dmt} {fmt}'
|
||||||
|
return self.stop_time.strftime(fmt)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def start_unixtime(self) -> int:
|
||||||
|
if self.start_time is None:
|
||||||
|
return 0
|
||||||
|
return int(self.start_time.timestamp())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def stop_unixtime(self) -> int:
|
||||||
|
if self.stop_time is None:
|
||||||
|
return 0
|
||||||
|
return int(self.stop_time.timestamp())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def filesize(self):
|
||||||
|
if self.remote:
|
||||||
|
if self.remote_filesize is None:
|
||||||
|
raise RuntimeError('file is remote and remote_filesize is not set')
|
||||||
|
return self.remote_filesize
|
||||||
|
return os.path.getsize(self.path)
|
||||||
|
|
||||||
|
def __dict__(self) -> dict:
|
||||||
|
return {
|
||||||
|
'start_unixtime': self.start_unixtime,
|
||||||
|
'stop_unixtime': self.stop_unixtime,
|
||||||
|
'filename': self.name,
|
||||||
|
'filesize': self.filesize,
|
||||||
|
'fileid': self.file_id,
|
||||||
|
'record_id': self.record_id or 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class RecordStorage:
|
||||||
|
time_fmt = '%d%m%y-%H%M%S'
|
||||||
|
|
||||||
|
def __init__(self, root: str):
|
||||||
|
self.root = root
|
||||||
|
|
||||||
|
def getfiles(self, as_objects=False) -> Union[list[str], list[RecordFile]]:
|
||||||
|
files = []
|
||||||
|
for name in os.listdir(self.root):
|
||||||
|
path = os.path.join(self.root, name)
|
||||||
|
if os.path.isfile(path) and name.endswith('.mp3'):
|
||||||
|
files.append(name if not as_objects else RecordFile(name, storage_root=self.root))
|
||||||
|
return files
|
||||||
|
|
||||||
|
def find(self, file_id: str) -> Optional[RecordFile]:
|
||||||
|
for name in os.listdir(self.root):
|
||||||
|
if os.path.isfile(os.path.join(self.root, name)) and name.endswith('.mp3'):
|
||||||
|
item = RecordFile(name, storage_root=self.root)
|
||||||
|
if item.file_id == file_id:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
def purge(self):
|
||||||
|
files = self.getfiles()
|
||||||
|
if files:
|
||||||
|
logger = logging.getLogger(self.__name__)
|
||||||
|
for f in files:
|
||||||
|
try:
|
||||||
|
path = os.path.join(self.root, f)
|
||||||
|
logger.debug(f'purge: deleting {path}')
|
||||||
|
os.unlink(path)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
def delete(self, file: RecordFile):
|
||||||
|
os.unlink(file.path)
|
||||||
|
|
||||||
|
def save(self,
|
||||||
|
fn: str,
|
||||||
|
record_id: int,
|
||||||
|
start_time: int,
|
||||||
|
stop_time: int) -> RecordFile:
|
||||||
|
|
||||||
|
start_time_s = datetime.fromtimestamp(start_time).strftime(self.time_fmt)
|
||||||
|
stop_time_s = datetime.fromtimestamp(stop_time).strftime(self.time_fmt)
|
||||||
|
|
||||||
|
dst_fn = f'{start_time_s}_{stop_time_s}_id{record_id}'
|
||||||
|
if os.path.exists(os.path.join(self.root, dst_fn)):
|
||||||
|
dst_fn += strgen(4)
|
||||||
|
dst_fn += '.mp3'
|
||||||
|
dst_path = os.path.join(self.root, dst_fn)
|
||||||
|
|
||||||
|
shutil.move(fn, dst_path)
|
||||||
|
return RecordFile(dst_fn, storage_root=self.root)
|
22
src/home/soundsensor/__init__.py
Normal file
22
src/home/soundsensor/__init__.py
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import importlib
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
'SoundSensorNode',
|
||||||
|
'SoundSensorHitHandler',
|
||||||
|
'SoundSensorServer',
|
||||||
|
'SoundSensorServerGuardClient'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def __getattr__(name):
|
||||||
|
if name in __all__:
|
||||||
|
if name == 'SoundSensorNode':
|
||||||
|
file = 'node'
|
||||||
|
elif name == 'SoundSensorServerGuardClient':
|
||||||
|
file = 'server_client'
|
||||||
|
else:
|
||||||
|
file = 'server'
|
||||||
|
module = importlib.import_module(f'.{file}', __name__)
|
||||||
|
return getattr(module, name)
|
||||||
|
|
||||||
|
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
8
src/home/soundsensor/__init__.pyi
Normal file
8
src/home/soundsensor/__init__.pyi
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
from .server import (
|
||||||
|
SoundSensorHitHandler as SoundSensorHitHandler,
|
||||||
|
SoundSensorServer as SoundSensorServer,
|
||||||
|
)
|
||||||
|
from .server_client import (
|
||||||
|
SoundSensorServerGuardClient as SoundSensorServerGuardClient
|
||||||
|
)
|
||||||
|
from .node import SoundSensorNode as SoundSensorNode
|
73
src/home/soundsensor/node.py
Normal file
73
src/home/soundsensor/node.py
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from time import sleep
|
||||||
|
from ..util import stringify, send_datagram, Addr
|
||||||
|
|
||||||
|
from pyA20.gpio import gpio
|
||||||
|
from pyA20.gpio import port as gpioport
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundSensorNode:
|
||||||
|
def __init__(self,
|
||||||
|
name: str,
|
||||||
|
pinname: str,
|
||||||
|
server_addr: Optional[Addr],
|
||||||
|
delay=0.005):
|
||||||
|
|
||||||
|
if not hasattr(gpioport, pinname):
|
||||||
|
raise ValueError(f'invalid pin {pinname}')
|
||||||
|
|
||||||
|
self.pin = getattr(gpioport, pinname)
|
||||||
|
self.name = name
|
||||||
|
self.delay = delay
|
||||||
|
|
||||||
|
self.server_addr = server_addr
|
||||||
|
|
||||||
|
self.hits = 0
|
||||||
|
self.hitlock = threading.Lock()
|
||||||
|
|
||||||
|
self.interrupted = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
try:
|
||||||
|
t = threading.Thread(target=self.sensor_reader)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
while True:
|
||||||
|
with self.hitlock:
|
||||||
|
hits = self.hits
|
||||||
|
self.hits = 0
|
||||||
|
|
||||||
|
if hits > 0:
|
||||||
|
try:
|
||||||
|
if self.server_addr is not None:
|
||||||
|
send_datagram(stringify([self.name, hits]), self.server_addr)
|
||||||
|
else:
|
||||||
|
logger.debug(f'server reporting disabled, skipping reporting {hits} hits')
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
sleep(1)
|
||||||
|
|
||||||
|
except (KeyboardInterrupt, SystemExit) as e:
|
||||||
|
self.interrupted = True
|
||||||
|
logger.info(str(e))
|
||||||
|
|
||||||
|
def sensor_reader(self):
|
||||||
|
gpio.init()
|
||||||
|
gpio.setcfg(self.pin, gpio.INPUT)
|
||||||
|
gpio.pullup(self.pin, gpio.PULLUP)
|
||||||
|
|
||||||
|
while not self.interrupted:
|
||||||
|
state = gpio.input(self.pin)
|
||||||
|
sleep(self.delay)
|
||||||
|
|
||||||
|
if not state:
|
||||||
|
with self.hitlock:
|
||||||
|
logger.debug('got a hit')
|
||||||
|
self.hits += 1
|
125
src/home/soundsensor/server.py
Normal file
125
src/home/soundsensor/server.py
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from ..config import config
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.web_exceptions import (
|
||||||
|
HTTPNotFound
|
||||||
|
)
|
||||||
|
|
||||||
|
from typing import Type
|
||||||
|
from ..util import Addr, stringify, format_tb
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundSensorHitHandler(asyncio.DatagramProtocol):
|
||||||
|
def datagram_received(self, data, addr):
|
||||||
|
try:
|
||||||
|
data = json.loads(data)
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
logger.error('failed to parse json datagram')
|
||||||
|
logger.exception(e)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
name, hits = data
|
||||||
|
except (ValueError, IndexError) as e:
|
||||||
|
logger.error('failed to unpack data')
|
||||||
|
logger.exception(e)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.handler(name, hits)
|
||||||
|
|
||||||
|
def handler(self, name: str, hits: int):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class SoundSensorServer:
|
||||||
|
def __init__(self,
|
||||||
|
addr: Addr,
|
||||||
|
handler_impl: Type[SoundSensorHitHandler]):
|
||||||
|
self.addr = addr
|
||||||
|
self.impl = handler_impl
|
||||||
|
|
||||||
|
self._recording_lock = threading.Lock()
|
||||||
|
self._recording_enabled = True
|
||||||
|
|
||||||
|
if self.guard_control_enabled():
|
||||||
|
if 'guard_recording_default' in config['server']:
|
||||||
|
self._recording_enabled = config['server']['guard_recording_default']
|
||||||
|
|
||||||
|
def guard_control_enabled(self) -> bool:
|
||||||
|
return 'guard_control' in config['server'] and config['server']['guard_control'] is True
|
||||||
|
|
||||||
|
def set_recording(self, enabled: bool):
|
||||||
|
with self._recording_lock:
|
||||||
|
self._recording_enabled = enabled
|
||||||
|
|
||||||
|
def is_recording_enabled(self) -> bool:
|
||||||
|
with self._recording_lock:
|
||||||
|
return self._recording_enabled
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
if self.guard_control_enabled():
|
||||||
|
t = threading.Thread(target=self.run_guard_server)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
loop = asyncio.get_event_loop()
|
||||||
|
t = loop.create_datagram_endpoint(self.impl, local_addr=self.addr)
|
||||||
|
loop.run_until_complete(t)
|
||||||
|
loop.run_forever()
|
||||||
|
|
||||||
|
def run_guard_server(self):
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
def ok(data=None):
|
||||||
|
if data is None:
|
||||||
|
data = 1
|
||||||
|
response = {'response': data}
|
||||||
|
return web.json_response(response, dumps=stringify)
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def errors_handler_middleware(request, handler):
|
||||||
|
try:
|
||||||
|
response = await handler(request)
|
||||||
|
return response
|
||||||
|
except HTTPNotFound:
|
||||||
|
return web.json_response({'error': 'not found'}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
data = {
|
||||||
|
'error': exc.__class__.__name__,
|
||||||
|
'message': exc.message if hasattr(exc, 'message') else str(exc)
|
||||||
|
}
|
||||||
|
tb = format_tb(exc)
|
||||||
|
if tb:
|
||||||
|
data['stacktrace'] = tb
|
||||||
|
|
||||||
|
return web.json_response(data, status=500)
|
||||||
|
|
||||||
|
@routes.post('/guard/enable')
|
||||||
|
async def guard_enable(request):
|
||||||
|
self.set_recording(True)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
@routes.post('/guard/disable')
|
||||||
|
async def guard_disable(request):
|
||||||
|
self.set_recording(False)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
@routes.get('/guard/status')
|
||||||
|
async def guard_status(request):
|
||||||
|
return ok({'enabled': self.is_recording_enabled()})
|
||||||
|
|
||||||
|
asyncio.set_event_loop(asyncio.new_event_loop()) # need to create new event loop in new thread
|
||||||
|
app = web.Application()
|
||||||
|
app.add_routes(routes)
|
||||||
|
app.middlewares.append(errors_handler_middleware)
|
||||||
|
|
||||||
|
web.run_app(app,
|
||||||
|
host=self.addr[0],
|
||||||
|
port=self.addr[1],
|
||||||
|
handle_signals=False) # handle_signals=True doesn't work in separate thread
|
38
src/home/soundsensor/server_client.py
Normal file
38
src/home/soundsensor/server_client.py
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
import requests
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from ..util import Addr
|
||||||
|
from ..api.errors import ApiResponseError
|
||||||
|
|
||||||
|
|
||||||
|
class SoundSensorServerGuardClient:
|
||||||
|
def __init__(self, addr: Addr):
|
||||||
|
self.endpoint = f'http://{addr[0]}:{addr[1]}'
|
||||||
|
self.logger = logging.getLogger(self.__class__.__name__)
|
||||||
|
|
||||||
|
def guard_enable(self):
|
||||||
|
return self._call('guard/enable', is_post=True)
|
||||||
|
|
||||||
|
def guard_disable(self):
|
||||||
|
return self._call('guard/disable', is_post=True)
|
||||||
|
|
||||||
|
def guard_status(self):
|
||||||
|
return self._call('guard/status')
|
||||||
|
|
||||||
|
def _call(self,
|
||||||
|
method: str,
|
||||||
|
is_post=False):
|
||||||
|
|
||||||
|
url = f'{self.endpoint}/{method}'
|
||||||
|
self.logger.debug(f'calling {url}')
|
||||||
|
|
||||||
|
r = requests.get(url) if not is_post else requests.post(url)
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
response = r.json()
|
||||||
|
raise ApiResponseError(status_code=r.status_code,
|
||||||
|
error_type=response['error'],
|
||||||
|
error_message=response['message'] or None,
|
||||||
|
error_stacktrace=response['stacktrace'] if 'stacktrace' in response else None)
|
||||||
|
|
||||||
|
return r.json()['response']
|
213
src/home/util.py
Normal file
213
src/home/util.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import time
|
||||||
|
import requests
|
||||||
|
import subprocess
|
||||||
|
import traceback
|
||||||
|
import logging
|
||||||
|
import string
|
||||||
|
import random
|
||||||
|
|
||||||
|
from .config import config
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Tuple, Optional
|
||||||
|
|
||||||
|
Addr = Tuple[str, int] # network address type (host, port)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# https://stackoverflow.com/questions/312443/how-do-you-split-a-list-into-evenly-sized-chunks
|
||||||
|
def chunks(lst, n):
|
||||||
|
"""Yield successive n-sized chunks from lst."""
|
||||||
|
for i in range(0, len(lst), n):
|
||||||
|
yield lst[i:i + n]
|
||||||
|
|
||||||
|
|
||||||
|
def json_serial(obj):
|
||||||
|
"""JSON serializer for datetime objects"""
|
||||||
|
if isinstance(obj, datetime):
|
||||||
|
return obj.timestamp()
|
||||||
|
raise TypeError("Type %s not serializable" % type(obj))
|
||||||
|
|
||||||
|
|
||||||
|
def stringify(v) -> str:
|
||||||
|
return json.dumps(v, separators=(',', ':'), default=json_serial)
|
||||||
|
|
||||||
|
|
||||||
|
def ipv4_valid(ip: str) -> bool:
|
||||||
|
try:
|
||||||
|
socket.inet_aton(ip)
|
||||||
|
return True
|
||||||
|
except socket.error:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def parse_addr(addr: str) -> Addr:
|
||||||
|
if addr.count(':') != 1:
|
||||||
|
raise ValueError('invalid host:port format')
|
||||||
|
|
||||||
|
host, port = addr.split(':')
|
||||||
|
if not ipv4_valid(host):
|
||||||
|
raise ValueError('invalid ipv4 address')
|
||||||
|
|
||||||
|
port = int(port)
|
||||||
|
if not 0 <= port <= 65535:
|
||||||
|
raise ValueError('invalid port')
|
||||||
|
|
||||||
|
return host, port
|
||||||
|
|
||||||
|
|
||||||
|
def strgen(n: int):
|
||||||
|
return ''.join(random.choices(string.ascii_letters + string.digits, k=n))
|
||||||
|
|
||||||
|
|
||||||
|
class MySimpleSocketClient:
|
||||||
|
host: str
|
||||||
|
port: int
|
||||||
|
|
||||||
|
def __init__(self, host: str, port: int):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self.sock.connect((self.host, self.port))
|
||||||
|
self.sock.settimeout(5)
|
||||||
|
|
||||||
|
def __del__(self):
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
def write(self, line: str) -> None:
|
||||||
|
self.sock.sendall((line + '\r\n').encode())
|
||||||
|
|
||||||
|
def read(self) -> str:
|
||||||
|
buf = bytearray()
|
||||||
|
while True:
|
||||||
|
buf.extend(self.sock.recv(256))
|
||||||
|
if b'\r\n' in buf:
|
||||||
|
break
|
||||||
|
|
||||||
|
response = buf.decode().strip()
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
def send_datagram(message: str, addr: Addr) -> None:
|
||||||
|
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||||
|
sock.sendto(message.encode(), addr)
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram(text: str,
|
||||||
|
parse_mode: str = None,
|
||||||
|
disable_web_page_preview: bool = False,
|
||||||
|
):
|
||||||
|
data = {
|
||||||
|
'chat_id': config['telegram']['chat_id'],
|
||||||
|
'text': text
|
||||||
|
}
|
||||||
|
|
||||||
|
if parse_mode is not None:
|
||||||
|
data['parse_mode'] = parse_mode
|
||||||
|
elif 'parse_mode' in config['telegram']:
|
||||||
|
data['parse_mode'] = config['telegram']['parse_mode']
|
||||||
|
|
||||||
|
if disable_web_page_preview or 'disable_web_page_preview' in config['telegram']:
|
||||||
|
data['disable_web_page_preview'] = 1
|
||||||
|
|
||||||
|
r = requests.post('https://api.telegram.org/bot%s/sendMessage' % config['telegram']['token'], data=data)
|
||||||
|
|
||||||
|
if r.status_code != 200:
|
||||||
|
logger.error(r.text)
|
||||||
|
raise RuntimeError("telegram returned %d" % r.status_code)
|
||||||
|
|
||||||
|
|
||||||
|
def format_tb(exc) -> Optional[list[str]]:
|
||||||
|
tb = traceback.format_tb(exc.__traceback__)
|
||||||
|
if not tb:
|
||||||
|
return None
|
||||||
|
|
||||||
|
tb = list(map(lambda s: s.strip(), tb))
|
||||||
|
tb.reverse()
|
||||||
|
if tb[0][-1:] == ':':
|
||||||
|
tb[0] = tb[0][:-1]
|
||||||
|
|
||||||
|
return tb
|
||||||
|
|
||||||
|
|
||||||
|
class ChildProcessInfo:
|
||||||
|
pid: int
|
||||||
|
cmd: str
|
||||||
|
|
||||||
|
def __init__(self,
|
||||||
|
pid: int,
|
||||||
|
cmd: str):
|
||||||
|
self.pid = pid
|
||||||
|
self.cmd = cmd
|
||||||
|
|
||||||
|
|
||||||
|
def find_child_processes(ppid: int) -> list[ChildProcessInfo]:
|
||||||
|
p = subprocess.run(['pgrep', '-P', str(ppid), '--list-full'], capture_output=True)
|
||||||
|
if p.returncode != 0:
|
||||||
|
raise OSError(f'pgrep returned {p.returncode}')
|
||||||
|
|
||||||
|
children = []
|
||||||
|
|
||||||
|
lines = p.stdout.decode().strip().split('\n')
|
||||||
|
for line in lines:
|
||||||
|
try:
|
||||||
|
space_idx = line.index(' ')
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
continue
|
||||||
|
|
||||||
|
pid = int(line[0:space_idx])
|
||||||
|
cmd = line[space_idx+1:]
|
||||||
|
|
||||||
|
children.append(ChildProcessInfo(pid, cmd))
|
||||||
|
|
||||||
|
return children
|
||||||
|
|
||||||
|
|
||||||
|
class Stopwatch:
|
||||||
|
elapsed: float
|
||||||
|
time_started: Optional[float]
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.elapsed = 0
|
||||||
|
self.time_started = None
|
||||||
|
|
||||||
|
def go(self):
|
||||||
|
if self.time_started is not None:
|
||||||
|
raise StopwatchError('stopwatch was already started')
|
||||||
|
|
||||||
|
self.time_started = time.time()
|
||||||
|
|
||||||
|
def pause(self):
|
||||||
|
if self.time_started is None:
|
||||||
|
raise StopwatchError('stopwatch was paused')
|
||||||
|
|
||||||
|
self.elapsed += time.time() - self.time_started
|
||||||
|
self.time_started = None
|
||||||
|
|
||||||
|
def get_elapsed_time(self):
|
||||||
|
elapsed = self.elapsed
|
||||||
|
if self.time_started is not None:
|
||||||
|
elapsed += time.time() - self.time_started
|
||||||
|
return elapsed
|
||||||
|
|
||||||
|
def reset(self):
|
||||||
|
self.time_started = None
|
||||||
|
self.elapsed = 0
|
||||||
|
|
||||||
|
def is_paused(self):
|
||||||
|
return self.time_started is None
|
||||||
|
|
||||||
|
|
||||||
|
class StopwatchError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def filesize_fmt(num, suffix="B") -> str:
|
||||||
|
for unit in ["", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"]:
|
||||||
|
if abs(num) < 1024.0:
|
||||||
|
return f"{num:3.1f} {unit}{suffix}"
|
||||||
|
num /= 1024.0
|
||||||
|
return f"{num:.1f} Yi{suffix}"
|
1
src/home/web_api/__init__.py
Normal file
1
src/home/web_api/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .web_api import get_app
|
213
src/home/web_api/web_api.py
Normal file
213
src/home/web_api/web_api.py
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import logging
|
||||||
|
import json
|
||||||
|
import os.path
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
from werkzeug.exceptions import HTTPException
|
||||||
|
from flask import Flask, request, Response
|
||||||
|
|
||||||
|
from ..config import config, is_development_mode
|
||||||
|
from ..database import BotsDatabase, SensorsDatabase
|
||||||
|
from ..util import stringify, format_tb
|
||||||
|
from ..api.types import BotType, TemperatureSensorLocation, SoundSensorLocation
|
||||||
|
from ..sound import RecordStorage
|
||||||
|
|
||||||
|
db: Optional[BotsDatabase] = None
|
||||||
|
sensors_db: Optional[SensorsDatabase] = None
|
||||||
|
app = Flask(__name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class AuthError(Exception):
|
||||||
|
def __init__(self, message: str):
|
||||||
|
super().__init__()
|
||||||
|
self.message = message
|
||||||
|
|
||||||
|
|
||||||
|
# api methods
|
||||||
|
# -----------
|
||||||
|
|
||||||
|
@app.route("/")
|
||||||
|
def hello():
|
||||||
|
message = "nothing here, keep lurking"
|
||||||
|
if is_development_mode():
|
||||||
|
message += ' (dev mode)'
|
||||||
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/sensors/data/', methods=['GET'])
|
||||||
|
def sensors_data():
|
||||||
|
hours = request.args.get('hours', type=int, default=1)
|
||||||
|
sensor = TemperatureSensorLocation(request.args.get('sensor', type=int))
|
||||||
|
|
||||||
|
if hours < 1 or hours > 24:
|
||||||
|
raise ValueError('invalid hours value')
|
||||||
|
|
||||||
|
dt_to = datetime.now()
|
||||||
|
dt_from = dt_to - timedelta(hours=hours)
|
||||||
|
|
||||||
|
data = sensors_db.get_temperature_recordings(sensor, (dt_from, dt_to))
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/sound_sensors/hits/', methods=['GET'])
|
||||||
|
def get_sound_sensors_hits():
|
||||||
|
location = SoundSensorLocation(request.args.get('location', type=int))
|
||||||
|
|
||||||
|
after = request.args.get('after', type=int)
|
||||||
|
kwargs = {}
|
||||||
|
if after is None:
|
||||||
|
last = request.args.get('last', type=int)
|
||||||
|
if last is None:
|
||||||
|
raise ValueError('you must pass `after` or `last` params')
|
||||||
|
else:
|
||||||
|
if not 0 < last < 100:
|
||||||
|
raise ValueError('invalid last value: must be between 0 and 100')
|
||||||
|
kwargs['last'] = last
|
||||||
|
else:
|
||||||
|
kwargs['after'] = datetime.fromtimestamp(after)
|
||||||
|
|
||||||
|
data = db.get_sound_hits(location, **kwargs)
|
||||||
|
return ok(data)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/sound_sensors/hits/', methods=['POST'])
|
||||||
|
def post_sound_sensors_hits():
|
||||||
|
hits = []
|
||||||
|
for hit, count in json.loads(request.form.get('hits', type=str)):
|
||||||
|
if not hasattr(SoundSensorLocation, hit.upper()):
|
||||||
|
raise ValueError('invalid sensor location')
|
||||||
|
if count < 1:
|
||||||
|
raise ValueError(f'invalid count: {count}')
|
||||||
|
hits.append((SoundSensorLocation[hit.upper()], count))
|
||||||
|
|
||||||
|
db.add_sound_hits(hits, datetime.now())
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/logs/bot-request/', methods=['POST'])
|
||||||
|
def log_bot_request():
|
||||||
|
user_id = request.form.get('user_id', type=int, default=0)
|
||||||
|
message = request.form.get('message', type=str, default='')
|
||||||
|
bot = BotType(request.form.get('bot', type=int))
|
||||||
|
|
||||||
|
# validate message
|
||||||
|
if message.strip() == '':
|
||||||
|
raise ValueError('message can\'t be empty')
|
||||||
|
|
||||||
|
# add record to the database
|
||||||
|
db.add_request(bot, user_id, message)
|
||||||
|
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/logs/openwrt/', methods=['POST'])
|
||||||
|
def log_openwrt():
|
||||||
|
logs = request.form.get('logs', type=str, default='')
|
||||||
|
|
||||||
|
# validate it
|
||||||
|
logs = json.loads(logs)
|
||||||
|
assert type(logs) is list, "invalid json data (list expected)"
|
||||||
|
|
||||||
|
lines = []
|
||||||
|
for line in logs:
|
||||||
|
assert type(line) is list, "invalid line type (list expected)"
|
||||||
|
assert len(line) == 2, f"expected 2 items in line, got {len(line)}"
|
||||||
|
assert type(line[0]) is int, "invalid line[0] type (int expected)"
|
||||||
|
assert type(line[1]) is str, "invalid line[1] type (str expected)"
|
||||||
|
|
||||||
|
lines.append((
|
||||||
|
datetime.fromtimestamp(line[0]),
|
||||||
|
line[1]
|
||||||
|
))
|
||||||
|
|
||||||
|
db.add_openwrt_logs(lines)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/recordings/list/', methods=['GET'])
|
||||||
|
def recordings_list():
|
||||||
|
extended = request.args.get('extended', type=bool, default=False)
|
||||||
|
node = request.args.get('node', type=str)
|
||||||
|
|
||||||
|
root = os.path.join(config['recordings']['directory'], node)
|
||||||
|
if not os.path.isdir(root):
|
||||||
|
raise ValueError(f'invalid node {node}: no such directory')
|
||||||
|
|
||||||
|
storage = RecordStorage(root)
|
||||||
|
files = storage.getfiles(as_objects=extended)
|
||||||
|
if extended:
|
||||||
|
files = list(map(lambda file: file.__dict__(), files))
|
||||||
|
|
||||||
|
return ok(files)
|
||||||
|
|
||||||
|
|
||||||
|
# internal functions
|
||||||
|
# ------------------
|
||||||
|
|
||||||
|
def ok(data=None) -> Response:
|
||||||
|
response = {'result': 'ok'}
|
||||||
|
if data is not None:
|
||||||
|
response['data'] = data
|
||||||
|
return Response(stringify(response),
|
||||||
|
mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
|
def err(e) -> Response:
|
||||||
|
error = {
|
||||||
|
'type': e.__class__.__name__,
|
||||||
|
'message': e.message if hasattr(e, 'message') else str(e)
|
||||||
|
}
|
||||||
|
if is_development_mode():
|
||||||
|
tb = format_tb(e)
|
||||||
|
if tb:
|
||||||
|
error['stacktrace'] = tb
|
||||||
|
data = {
|
||||||
|
'result': 'error',
|
||||||
|
'error': error
|
||||||
|
}
|
||||||
|
return Response(stringify(data), mimetype='application/json')
|
||||||
|
|
||||||
|
|
||||||
|
def get_token() -> Optional[str]:
|
||||||
|
name = 'X-Token'
|
||||||
|
if name in request.headers:
|
||||||
|
return request.headers[name]
|
||||||
|
|
||||||
|
token = request.args.get('token', default='', type=str)
|
||||||
|
if token != '':
|
||||||
|
return token
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@app.errorhandler(Exception)
|
||||||
|
def handle_exception(e):
|
||||||
|
if isinstance(e, HTTPException):
|
||||||
|
return e
|
||||||
|
return err(e), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.before_request
|
||||||
|
def validate_token() -> None:
|
||||||
|
if request.path.startswith('/api/') and not is_development_mode():
|
||||||
|
token = get_token()
|
||||||
|
if not token:
|
||||||
|
raise AuthError(f'token is missing')
|
||||||
|
|
||||||
|
if token != config['api']['token']:
|
||||||
|
raise AuthError('invalid token')
|
||||||
|
|
||||||
|
|
||||||
|
def get_app():
|
||||||
|
global db, sensors_db
|
||||||
|
|
||||||
|
config.load('web_api')
|
||||||
|
app.config.from_mapping(**config['flask'])
|
||||||
|
|
||||||
|
db = BotsDatabase()
|
||||||
|
sensors_db = SensorsDatabase()
|
||||||
|
|
||||||
|
return app
|
467
src/inverter_bot.py
Executable file
467
src/inverter_bot.py
Executable file
@ -0,0 +1,467 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
|
from inverterd import Format, InverterError
|
||||||
|
from html import escape
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
from home.config import config
|
||||||
|
from home.bot import Wrapper, Context, text_filter, command_usage
|
||||||
|
from home.inverter import (
|
||||||
|
wrapper_instance as inverter,
|
||||||
|
beautify_table,
|
||||||
|
|
||||||
|
InverterMonitor,
|
||||||
|
ChargingEvent,
|
||||||
|
BatteryState,
|
||||||
|
)
|
||||||
|
from home.api.types import BotType
|
||||||
|
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from telegram.ext import MessageHandler, CommandHandler, CallbackQueryHandler
|
||||||
|
|
||||||
|
monitor: Optional[InverterMonitor] = None
|
||||||
|
bot: Optional[Wrapper] = None
|
||||||
|
LT = escape('<=')
|
||||||
|
flags_map = {
|
||||||
|
'buzzer': 'BUZZ',
|
||||||
|
'overload_bypass': 'OLBP',
|
||||||
|
'escape_to_default_screen_after_1min_timeout': 'LCDE',
|
||||||
|
'overload_restart': 'OLRS',
|
||||||
|
'over_temp_restart': 'OTRS',
|
||||||
|
'backlight_on': 'BLON',
|
||||||
|
'alarm_on_on_primary_source_interrupt': 'ALRM',
|
||||||
|
'fault_code_record': 'FTCR',
|
||||||
|
}
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_charging(event: ChargingEvent, **kwargs) -> None:
|
||||||
|
args = []
|
||||||
|
if event == ChargingEvent.AC_CHARGING_STARTED:
|
||||||
|
key = 'started'
|
||||||
|
elif event == ChargingEvent.AC_CHARGING_FINISHED:
|
||||||
|
key = 'finished'
|
||||||
|
elif event == ChargingEvent.AC_DISCONNECTED:
|
||||||
|
key = 'disconnected'
|
||||||
|
elif event == ChargingEvent.AC_NOT_CHARGING:
|
||||||
|
key = 'not_charging'
|
||||||
|
elif event == ChargingEvent.AC_CURRENT_CHANGED:
|
||||||
|
key = 'current_changed'
|
||||||
|
args.append(kwargs['current'])
|
||||||
|
elif event == ChargingEvent.AC_CHARGING_UNAVAILABLE_BECAUSE_SOLAR:
|
||||||
|
key = 'na_solar'
|
||||||
|
elif event == ChargingEvent.AC_MOSTLY_CHARGED:
|
||||||
|
key = 'mostly_charged'
|
||||||
|
else:
|
||||||
|
logger.error('unknown charging event:', event)
|
||||||
|
return
|
||||||
|
|
||||||
|
bot.notify_all(
|
||||||
|
lambda lang: bot.lang.get(f'chrg_evt_{key}', lang, *args)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None:
|
||||||
|
if state == BatteryState.NORMAL:
|
||||||
|
emoji = '✅'
|
||||||
|
elif state == BatteryState.LOW:
|
||||||
|
emoji = '⚠️'
|
||||||
|
elif state == BatteryState.CRITICAL:
|
||||||
|
emoji = '‼️'
|
||||||
|
else:
|
||||||
|
logger.error('unknown battery state:', state)
|
||||||
|
return
|
||||||
|
|
||||||
|
bot.notify_all(
|
||||||
|
lambda lang: bot.lang.get('battery_level_changed', lang,
|
||||||
|
emoji, bot.lang.get(f'bat_state_{state.name.lower()}', lang), v, load_watts)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_error(error: str) -> None:
|
||||||
|
bot.notify_all(
|
||||||
|
lambda lang: bot.lang.get('error_message', lang, error)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def full_status(ctx: Context) -> None:
|
||||||
|
status = inverter.exec('get-status', format=Format.TABLE)
|
||||||
|
ctx.reply(beautify_table(status))
|
||||||
|
|
||||||
|
|
||||||
|
def full_rated(ctx: Context) -> None:
|
||||||
|
rated = inverter.exec('get-rated', format=Format.TABLE)
|
||||||
|
ctx.reply(beautify_table(rated))
|
||||||
|
|
||||||
|
|
||||||
|
def full_errors(ctx: Context) -> None:
|
||||||
|
errors = inverter.exec('get-errors', format=Format.TABLE)
|
||||||
|
ctx.reply(beautify_table(errors))
|
||||||
|
|
||||||
|
|
||||||
|
def flags(ctx: Context) -> None:
|
||||||
|
flags = inverter.exec('get-flags')['data']
|
||||||
|
text, markup = build_flags_keyboard(flags, ctx)
|
||||||
|
ctx.reply(text, markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
def build_flags_keyboard(flags: dict, ctx: Context) -> Tuple[str, InlineKeyboardMarkup]:
|
||||||
|
keyboard = []
|
||||||
|
for k, v in flags.items():
|
||||||
|
label = ('✅' if v else '❌') + ' ' + ctx.lang(f'flag_{k}')
|
||||||
|
proto_flag = flags_map[k]
|
||||||
|
keyboard.append([InlineKeyboardButton(label, callback_data=f'flag_{proto_flag}')])
|
||||||
|
|
||||||
|
return ctx.lang('flags_press_button'), InlineKeyboardMarkup(keyboard)
|
||||||
|
|
||||||
|
|
||||||
|
def status(ctx: Context) -> None:
|
||||||
|
gs = inverter.exec('get-status')['data']
|
||||||
|
|
||||||
|
# render response
|
||||||
|
power_direction = gs['battery_power_direction'].lower()
|
||||||
|
power_direction = re.sub(r'ge$', 'ging', power_direction)
|
||||||
|
|
||||||
|
charging_rate = ''
|
||||||
|
chrg_at = ctx.lang('charging_at')
|
||||||
|
|
||||||
|
if power_direction == 'charging':
|
||||||
|
charging_rate = f'{chrg_at}%s %s' % (
|
||||||
|
gs['battery_charging_current']['value'], gs['battery_charging_current']['unit'])
|
||||||
|
pd_label = ctx.lang('pd_charging')
|
||||||
|
elif power_direction == 'discharging':
|
||||||
|
charging_rate = f'{chrg_at}%s %s' % (
|
||||||
|
gs['battery_discharging_current']['value'], gs['battery_discharging_current']['unit'])
|
||||||
|
pd_label = ctx.lang('pd_discharging')
|
||||||
|
else:
|
||||||
|
pd_label = ctx.lang('pd_nothing')
|
||||||
|
|
||||||
|
html = f'<b>{ctx.lang("battery")}:</b> %s %s' % (gs['battery_voltage']['value'], gs['battery_voltage']['unit'])
|
||||||
|
html += ' (%s%s)' % (pd_label, charging_rate)
|
||||||
|
|
||||||
|
html += f'\n<b>{ctx.lang("load")}:</b> %s %s' % (gs['ac_output_active_power']['value'], gs['ac_output_active_power']['unit'])
|
||||||
|
html += ' (%s%%)' % (gs['output_load_percent']['value'])
|
||||||
|
|
||||||
|
if gs['pv1_input_power']['value'] > 0:
|
||||||
|
html += f'\n<b>{ctx.lang("gen_input_power")}:</b> %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit'])
|
||||||
|
|
||||||
|
if gs['grid_voltage']['value'] > 0 or gs['grid_freq']['value'] > 0:
|
||||||
|
html += f'\n<b>{ctx.lang("generator")}:</b> %s %s' % (gs['grid_voltage']['unit'], gs['grid_voltage']['value'])
|
||||||
|
html += ', %s %s' % (gs['grid_freq']['value'], gs['grid_freq']['unit'])
|
||||||
|
|
||||||
|
# send response
|
||||||
|
ctx.reply(html)
|
||||||
|
|
||||||
|
|
||||||
|
def generation(ctx: Context) -> None:
|
||||||
|
today = datetime.date.today()
|
||||||
|
yday = today - datetime.timedelta(days=1)
|
||||||
|
yday2 = today - datetime.timedelta(days=2)
|
||||||
|
|
||||||
|
gs = inverter.exec('get-status')['data']
|
||||||
|
|
||||||
|
gen_today = inverter.exec('get-day-generated', (today.year, today.month, today.day))['data']
|
||||||
|
gen_yday = None
|
||||||
|
gen_yday2 = None
|
||||||
|
|
||||||
|
if yday.month == today.month:
|
||||||
|
gen_yday = inverter.exec('get-day-generated', (yday.year, yday.month, yday.day))['data']
|
||||||
|
|
||||||
|
if yday2.month == today.month:
|
||||||
|
gen_yday2 = inverter.exec('get-day-generated', (yday2.year, yday2.month, yday2.day))['data']
|
||||||
|
|
||||||
|
# render response
|
||||||
|
html = f'<b>{ctx.lang("gen_input_power")}:</b> %s %s' % (gs['pv1_input_power']['value'], gs['pv1_input_power']['unit'])
|
||||||
|
html += ' (%s %s)' % (gs['pv1_input_voltage']['value'], gs['pv1_input_voltage']['unit'])
|
||||||
|
|
||||||
|
html += f'\n<b>{ctx.lang("gen_today")}:</b> %s Wh' % (gen_today['wh'])
|
||||||
|
|
||||||
|
if gen_yday is not None:
|
||||||
|
html += f'\n<b>{ctx.lang("gen_yday1")}:</b> %s Wh' % (gen_yday['wh'])
|
||||||
|
|
||||||
|
if gen_yday2 is not None:
|
||||||
|
html += f'\n<b>{ctx.lang("gen_yday2")}:</b> %s Wh' % (gen_yday2['wh'])
|
||||||
|
|
||||||
|
# send response
|
||||||
|
ctx.reply(html)
|
||||||
|
|
||||||
|
|
||||||
|
def setgencc(ctx: Context) -> None:
|
||||||
|
allowed_values = inverter.exec('get-allowed-ac-charging-currents')['data']
|
||||||
|
|
||||||
|
try:
|
||||||
|
current = int(ctx.args[0])
|
||||||
|
if current not in allowed_values:
|
||||||
|
raise ValueError(f'invalid value {current}')
|
||||||
|
|
||||||
|
response = inverter.exec('set-max-ac-charging-current', (0, current))
|
||||||
|
ctx.reply('OK' if response['result'] == 'ok' else 'ERROR')
|
||||||
|
|
||||||
|
# TODO notify monitor
|
||||||
|
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
ctx.reply(command_usage('setgencc', {
|
||||||
|
'A': ctx.lang('setgencc_a', ', '.join(map(lambda x: str(x), allowed_values)))
|
||||||
|
}, language=ctx.user_lang))
|
||||||
|
|
||||||
|
|
||||||
|
def setgenct(ctx: Context) -> None:
|
||||||
|
try:
|
||||||
|
cv = float(ctx.args[0])
|
||||||
|
dv = float(ctx.args[1])
|
||||||
|
|
||||||
|
if 44 <= cv <= 51 and 48 <= dv <= 58:
|
||||||
|
response = inverter.exec('set-charging-thresholds', (cv, dv))
|
||||||
|
ctx.reply('OK' if response['result'] == 'ok' else 'ERROR')
|
||||||
|
else:
|
||||||
|
raise ValueError('invalid values')
|
||||||
|
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
ctx.reply(command_usage('setgenct', {
|
||||||
|
'CV': ctx.lang('setgenct_cv'),
|
||||||
|
'DV': ctx.lang('setgenct_dv')
|
||||||
|
}, language=ctx.user_lang))
|
||||||
|
|
||||||
|
|
||||||
|
def setbatuv(ctx: Context) -> None:
|
||||||
|
try:
|
||||||
|
v = float(ctx.args[0])
|
||||||
|
|
||||||
|
if 40.0 <= v <= 48.0:
|
||||||
|
response = inverter.exec('set-battery-cut-off-voltage', (v,))
|
||||||
|
ctx.reply('OK' if response['result'] == 'ok' else 'ERROR')
|
||||||
|
else:
|
||||||
|
raise ValueError('invalid voltage')
|
||||||
|
|
||||||
|
except (IndexError, ValueError):
|
||||||
|
ctx.reply(command_usage('setbatuv', {
|
||||||
|
'V': ctx.lang('setbatuv_v')
|
||||||
|
}, language=ctx.user_lang))
|
||||||
|
|
||||||
|
|
||||||
|
def monstatus(ctx: Context) -> None:
|
||||||
|
msg = ''
|
||||||
|
st = monitor.dump_status()
|
||||||
|
for k, v in st.items():
|
||||||
|
msg += k + ': ' + str(v) + '\n'
|
||||||
|
ctx.reply(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def monsetcur(ctx: Context) -> None:
|
||||||
|
ctx.reply('not implemented yet')
|
||||||
|
|
||||||
|
|
||||||
|
def calcw(ctx: Context) -> None:
|
||||||
|
ctx.reply('not implemented yet')
|
||||||
|
|
||||||
|
|
||||||
|
def calcwadv(ctx: Context) -> None:
|
||||||
|
ctx.reply('not implemented yet')
|
||||||
|
|
||||||
|
|
||||||
|
def button_callback(ctx: Context) -> None:
|
||||||
|
query = ctx.callback_query
|
||||||
|
|
||||||
|
if query.data.startswith('flag_'):
|
||||||
|
flag = query.data[5:]
|
||||||
|
found = False
|
||||||
|
json_key = None
|
||||||
|
for k, v in flags_map.items():
|
||||||
|
if v == flag:
|
||||||
|
found = True
|
||||||
|
json_key = k
|
||||||
|
break
|
||||||
|
if not found:
|
||||||
|
query.answer(ctx.lang('flags_invalid'))
|
||||||
|
return
|
||||||
|
|
||||||
|
flags = inverter.exec('get-flags')['data']
|
||||||
|
cur_flag_value = flags[json_key]
|
||||||
|
target_flag_value = '0' if cur_flag_value else '1'
|
||||||
|
|
||||||
|
# set flag
|
||||||
|
response = inverter.exec('set-flag', (flag, target_flag_value))
|
||||||
|
|
||||||
|
# notify user
|
||||||
|
query.answer(ctx.lang('done') if response['result'] == 'ok' else ctx.lang('flags_fail'))
|
||||||
|
|
||||||
|
# edit message
|
||||||
|
flags[json_key] = not cur_flag_value
|
||||||
|
text, markup = build_flags_keyboard(flags, ctx)
|
||||||
|
query.edit_message_text(text, reply_markup=markup)
|
||||||
|
|
||||||
|
else:
|
||||||
|
query.answer(ctx.lang('unexpected_callback_data'))
|
||||||
|
|
||||||
|
|
||||||
|
class InverterBot(Wrapper):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.lang.ru(
|
||||||
|
status='Статус',
|
||||||
|
generation='Генерация',
|
||||||
|
battery="АКБ",
|
||||||
|
load="Нагрузка",
|
||||||
|
generator="Генератор",
|
||||||
|
done="Готово",
|
||||||
|
unexpected_callback_data="Ошибка: неверные данные",
|
||||||
|
|
||||||
|
flags_press_button='Нажмите кнопку для переключения настройки',
|
||||||
|
flags_fail='Не удалось установить настройку',
|
||||||
|
flags_invalid='Неизвестная настройка',
|
||||||
|
|
||||||
|
# generation
|
||||||
|
gen_today='Сегодня',
|
||||||
|
gen_yday1='Вчера',
|
||||||
|
gen_yday2='Позавчера',
|
||||||
|
gen_input_power='Зарядная мощность',
|
||||||
|
|
||||||
|
# status
|
||||||
|
charging_at=', ',
|
||||||
|
pd_charging='заряжается',
|
||||||
|
pd_discharging='разряжается',
|
||||||
|
pd_nothing='не используется',
|
||||||
|
|
||||||
|
# flags
|
||||||
|
flag_buzzer='Звуковой сигнал',
|
||||||
|
flag_overload_bypass='Разрешить перегрузку',
|
||||||
|
flag_escape_to_default_screen_after_1min_timeout='Возврат на главный экран через 1 минуту',
|
||||||
|
flag_overload_restart='Перезапуск при перегрузке',
|
||||||
|
flag_over_temp_restart='Перезапуск при перегреве',
|
||||||
|
flag_backlight_on='Подсветка экрана',
|
||||||
|
flag_alarm_on_on_primary_source_interrupt='Сигнал при разрыве основного источника питания',
|
||||||
|
flag_fault_code_record='Запись кодов ошибок',
|
||||||
|
|
||||||
|
# commands
|
||||||
|
setbatuv_v=f'напряжение, 40.0 {LT} V {LT} 48.0',
|
||||||
|
setgenct_cv=f'напряжение включения заряда, 44 {LT} CV {LT} 51',
|
||||||
|
setgenct_dv=f'напряжение отключения заряда, 48 {LT} DV {LT} 58',
|
||||||
|
setgencc_a='максимальный ток заряда, допустимые значения: %s',
|
||||||
|
|
||||||
|
# monitor
|
||||||
|
chrg_evt_started='✅ Начали заряжать от генератора.',
|
||||||
|
chrg_evt_finished='✅ Зарядили. Генератор пора выключать.',
|
||||||
|
chrg_evt_disconnected='ℹ️ Генератор отключен.',
|
||||||
|
chrg_evt_current_changed='ℹ️ Ток заряда от генератора установлен в %d A.',
|
||||||
|
chrg_evt_not_charging='ℹ️ Генератор подключен, но не заряжает.',
|
||||||
|
chrg_evt_na_solar='⛔️ Генератор подключен, но аккумуляторы не заряжаются из-за подключенных панелей.',
|
||||||
|
chrg_evt_mostly_charged='✅ Аккумуляторы более-менее заряжены, генератор пора выключать.',
|
||||||
|
battery_level_changed='Уровень заряда АКБ: <b>%s %s</b> (<b>%0.1f V</b> при нагрузке <b>%d W</b>)',
|
||||||
|
error_message='<b>Ошибка:</b> %s.',
|
||||||
|
|
||||||
|
bat_state_normal='Нормальный',
|
||||||
|
bat_state_low='Низкий',
|
||||||
|
bat_state_critical='Критический',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.lang.en(
|
||||||
|
status='Status',
|
||||||
|
generation='Generation',
|
||||||
|
battery="Battery",
|
||||||
|
load="Load",
|
||||||
|
generator="Generator",
|
||||||
|
done="Done",
|
||||||
|
unexpected_callback_data="Unexpected callback data",
|
||||||
|
|
||||||
|
flags_press_button='Press a button to toggle a flag.',
|
||||||
|
flags_fail='Failed to toggle flag',
|
||||||
|
flags_invalid='Invalid flag',
|
||||||
|
|
||||||
|
# generation
|
||||||
|
gen_today='Today',
|
||||||
|
gen_yday1='Yesterday',
|
||||||
|
gen_yday2='The day before yesterday',
|
||||||
|
gen_input_power='Input power',
|
||||||
|
|
||||||
|
# status
|
||||||
|
charging_at=' @ ',
|
||||||
|
pd_charging='charging',
|
||||||
|
pd_discharging='discharging',
|
||||||
|
pd_nothing='not used',
|
||||||
|
|
||||||
|
# flags
|
||||||
|
flag_buzzer='Buzzer',
|
||||||
|
flag_overload_bypass='Overload bypass',
|
||||||
|
flag_escape_to_default_screen_after_1min_timeout='Reset to default LCD page after 1min timeout',
|
||||||
|
flag_overload_restart='Restart on overload',
|
||||||
|
flag_over_temp_restart='Restart on overtemp',
|
||||||
|
flag_backlight_on='LCD backlight',
|
||||||
|
flag_alarm_on_on_primary_source_interrupt='Beep on primary source interruption',
|
||||||
|
flag_fault_code_record='Fault code recording',
|
||||||
|
|
||||||
|
# commands
|
||||||
|
setbatuv_v=f'floating point number, 40.0 {LT} V {LT} 48.0',
|
||||||
|
setgenct_cv=f'charging voltage, 44 {LT} CV {LT} 51',
|
||||||
|
setgenct_dv=f'discharging voltage, 48 {LT} DV {LT} 58',
|
||||||
|
setgencc_a='max charging current, allowed values: %s',
|
||||||
|
|
||||||
|
# monitor
|
||||||
|
chrg_evt_started='✅ Started charging from AC.',
|
||||||
|
chrg_evt_finished='✅ Finished charging, it\'s time to stop the generator.',
|
||||||
|
chrg_evt_disconnected='ℹ️ AC disconnected.',
|
||||||
|
chrg_evt_current_changed='ℹ️ AC charging current set to %d A.',
|
||||||
|
chrg_evt_not_charging='ℹ️ AC connected but not charging.',
|
||||||
|
chrg_evt_na_solar='⛔️ AC connected, but battery won\'t be charged due to active solar power line.',
|
||||||
|
chrg_evt_mostly_charged='✅ The battery is mostly charged now. The generator can be turned off.',
|
||||||
|
battery_level_changed='Battery level: <b>%s</b> (<b>%0.1f V</b> under <b>%d W</b> load)',
|
||||||
|
error_message='<b>Error:</b> %s.',
|
||||||
|
|
||||||
|
bat_state_normal='Normal',
|
||||||
|
bat_state_low='Low',
|
||||||
|
bat_state_critical='Critical',
|
||||||
|
)
|
||||||
|
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('status')), self.wrap(status)))
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('generation')), self.wrap(generation)))
|
||||||
|
|
||||||
|
self.add_handler(CommandHandler('setgencc', self.wrap(setgencc)))
|
||||||
|
self.add_handler(CommandHandler('setgenct', self.wrap(setgenct)))
|
||||||
|
self.add_handler(CommandHandler('setbatuv', self.wrap(setbatuv)))
|
||||||
|
self.add_handler(CommandHandler('monstatus', self.wrap(monstatus)))
|
||||||
|
self.add_handler(CommandHandler('monsetcur', self.wrap(monsetcur)))
|
||||||
|
self.add_handler(CommandHandler('calcw', self.wrap(calcw)))
|
||||||
|
self.add_handler(CommandHandler('calcwadv', self.wrap(calcwadv)))
|
||||||
|
|
||||||
|
self.add_handler(CommandHandler('flags', self.wrap(flags)))
|
||||||
|
self.add_handler(CommandHandler('status', self.wrap(full_status)))
|
||||||
|
self.add_handler(CommandHandler('config', self.wrap(full_rated)))
|
||||||
|
self.add_handler(CommandHandler('errors', self.wrap(full_errors)))
|
||||||
|
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(button_callback)))
|
||||||
|
|
||||||
|
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
|
||||||
|
button = [
|
||||||
|
[ctx.lang('status'), ctx.lang('generation')]
|
||||||
|
]
|
||||||
|
return ReplyKeyboardMarkup(button, one_time_keyboard=False)
|
||||||
|
|
||||||
|
def exception_handler(self, e: Exception, ctx: Context) -> Optional[bool]:
|
||||||
|
if isinstance(e, InverterError):
|
||||||
|
try:
|
||||||
|
err = json.loads(str(e))['message']
|
||||||
|
except json.decoder.JSONDecodeError:
|
||||||
|
err = str(e)
|
||||||
|
err = re.sub(r'((?:.*)?error:) (.*)', r'<b>\1</b> \2', err)
|
||||||
|
ctx.reply(err)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('inverter_bot')
|
||||||
|
|
||||||
|
inverter.init(host=config['inverter']['ip'], port=config['inverter']['port'])
|
||||||
|
|
||||||
|
monitor = InverterMonitor()
|
||||||
|
monitor.set_charging_event_handler(monitor_charging)
|
||||||
|
monitor.set_battery_event_handler(monitor_battery)
|
||||||
|
monitor.set_error_handler(monitor_error)
|
||||||
|
monitor.start()
|
||||||
|
|
||||||
|
bot = InverterBot()
|
||||||
|
bot.enable_logging(BotType.INVERTER)
|
||||||
|
bot.run()
|
||||||
|
|
||||||
|
monitor.stop()
|
77
src/inverter_mqtt_receiver.py
Executable file
77
src/inverter_mqtt_receiver.py
Executable file
@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import re
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from home.mqtt import MQTTBase
|
||||||
|
from home.mqtt.message import Status, Generation
|
||||||
|
from home.database import InverterDatabase
|
||||||
|
from home.config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTReceiver(MQTTBase):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(clean_session=False)
|
||||||
|
self.database = InverterDatabase()
|
||||||
|
|
||||||
|
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
|
||||||
|
super().on_connect(client, userdata, flags, rc)
|
||||||
|
logger.info("subscribing to home/#")
|
||||||
|
client.subscribe('home/#', qos=1)
|
||||||
|
|
||||||
|
def on_message(self, client: mqtt.Client, userdata, msg):
|
||||||
|
try:
|
||||||
|
match = re.match(r'home/(\d+)/(status|gen)', msg.topic)
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
|
||||||
|
home_id, what = int(match.group(1)), match.group(2)
|
||||||
|
if what == 'gen':
|
||||||
|
packer = Generation()
|
||||||
|
client_time, watts = packer.unpack(msg.payload)
|
||||||
|
self.database.add_generation(home_id, client_time, watts)
|
||||||
|
|
||||||
|
elif what == 'status':
|
||||||
|
packer = Status()
|
||||||
|
client_time, data = packer.unpack(msg.payload)
|
||||||
|
self.database.add_status(home_id,
|
||||||
|
client_time,
|
||||||
|
grid_voltage=int(data['grid_voltage']*10),
|
||||||
|
grid_freq=int(data['grid_freq'] * 10),
|
||||||
|
ac_output_voltage=int(data['ac_output_voltage'] * 10),
|
||||||
|
ac_output_freq=int(data['ac_output_freq'] * 10),
|
||||||
|
ac_output_apparent_power=data['ac_output_apparent_power'],
|
||||||
|
ac_output_active_power=data['ac_output_active_power'],
|
||||||
|
output_load_percent=data['output_load_percent'],
|
||||||
|
battery_voltage=int(data['battery_voltage'] * 10),
|
||||||
|
battery_voltage_scc=int(data['battery_voltage_scc'] * 10),
|
||||||
|
battery_voltage_scc2=int(data['battery_voltage_scc2'] * 10),
|
||||||
|
battery_discharging_current=data['battery_discharging_current'],
|
||||||
|
battery_charging_current=data['battery_charging_current'],
|
||||||
|
battery_capacity=data['battery_capacity'],
|
||||||
|
inverter_heat_sink_temp=data['inverter_heat_sink_temp'],
|
||||||
|
mppt1_charger_temp=data['mppt1_charger_temp'],
|
||||||
|
mppt2_charger_temp=data['mppt2_charger_temp'],
|
||||||
|
pv1_input_power=data['pv1_input_power'],
|
||||||
|
pv2_input_power=data['pv2_input_power'],
|
||||||
|
pv1_input_voltage=int(data['pv1_input_voltage'] * 10),
|
||||||
|
pv2_input_voltage=int(data['pv2_input_voltage'] * 10),
|
||||||
|
mppt1_charger_status=data['mppt1_charger_status'],
|
||||||
|
mppt2_charger_status=data['mppt2_charger_status'],
|
||||||
|
battery_power_direction=data['battery_power_direction'],
|
||||||
|
dc_ac_power_direction=data['dc_ac_power_direction'],
|
||||||
|
line_power_direction=data['line_power_direction'],
|
||||||
|
load_connected=data['load_connected'])
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('inverter_mqtt_receiver')
|
||||||
|
|
||||||
|
server = MQTTReceiver()
|
||||||
|
server.connect_and_loop()
|
||||||
|
|
78
src/inverter_mqtt_sender.py
Executable file
78
src/inverter_mqtt_sender.py
Executable file
@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import datetime
|
||||||
|
import json
|
||||||
|
import inverterd
|
||||||
|
|
||||||
|
from home.config import config
|
||||||
|
from home.mqtt import MQTTBase, poll_tick
|
||||||
|
from home.mqtt.message import Status, Generation
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTClient(MQTTBase):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.inverter = inverterd.Client()
|
||||||
|
self.inverter.connect()
|
||||||
|
self.inverter.format(inverterd.Format.SIMPLE_JSON)
|
||||||
|
|
||||||
|
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
|
||||||
|
super().on_connect(client, userdata, flags, rc)
|
||||||
|
|
||||||
|
def poll_inverter(self):
|
||||||
|
freq = int(config['mqtt']['inverter']['poll_freq'])
|
||||||
|
gen_freq = int(config['mqtt']['inverter']['generation_poll_freq'])
|
||||||
|
|
||||||
|
g = poll_tick(freq)
|
||||||
|
gen_prev = 0
|
||||||
|
while True:
|
||||||
|
time.sleep(next(g))
|
||||||
|
|
||||||
|
# read status
|
||||||
|
now = time.time()
|
||||||
|
try:
|
||||||
|
raw = self.inverter.exec('get-status')
|
||||||
|
except inverterd.InverterError as e:
|
||||||
|
logger.error(f'inverter error: {str(e)}')
|
||||||
|
# TODO send to server
|
||||||
|
continue
|
||||||
|
|
||||||
|
data = json.loads(raw)['data']
|
||||||
|
|
||||||
|
packer = Status()
|
||||||
|
self.client.publish(f'home/{self.home_id}/status',
|
||||||
|
payload=packer.pack(round(now), data),
|
||||||
|
qos=1)
|
||||||
|
|
||||||
|
# read today's generation stat
|
||||||
|
now = time.time()
|
||||||
|
if gen_prev == 0 or now - gen_prev >= gen_freq:
|
||||||
|
gen_prev = now
|
||||||
|
today = datetime.date.today()
|
||||||
|
try:
|
||||||
|
raw = self.inverter.exec('get-day-generated', (today.year, today.month, today.day))
|
||||||
|
except inverterd.InverterError as e:
|
||||||
|
logger.error(f'inverter error: {str(e)}')
|
||||||
|
# TODO send to server
|
||||||
|
continue
|
||||||
|
|
||||||
|
# print('raw:', raw, type(raw))
|
||||||
|
data = json.loads(raw)['data']
|
||||||
|
packer = Generation()
|
||||||
|
self.client.publish(f'home/{self.home_id}/gen',
|
||||||
|
payload=packer.pack(round(now), data['wh']),
|
||||||
|
qos=1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('inverter_mqtt_sender')
|
||||||
|
|
||||||
|
client = MQTTClient()
|
||||||
|
client.configure_tls()
|
||||||
|
client.connect_and_loop(loop_forever=False)
|
||||||
|
client.poll_inverter()
|
65
src/openwrt_log_analyzer.py
Normal file
65
src/openwrt_log_analyzer.py
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from home.config import config
|
||||||
|
from home.database import BotsDatabase, SimpleState
|
||||||
|
from home.util import send_telegram
|
||||||
|
|
||||||
|
"""
|
||||||
|
config.toml example:
|
||||||
|
|
||||||
|
[simple_state]
|
||||||
|
file = "/home/user/.config/openwrt_log_analyzer/state.txt"
|
||||||
|
|
||||||
|
[mysql]
|
||||||
|
host = "localhost"
|
||||||
|
database = ".."
|
||||||
|
user = ".."
|
||||||
|
password = ".."
|
||||||
|
|
||||||
|
[devices]
|
||||||
|
Device1 = "00:00:00:00:00:00"
|
||||||
|
Device2 = "01:01:01:01:01:01"
|
||||||
|
|
||||||
|
[telegram]
|
||||||
|
chat_id = ".."
|
||||||
|
token = ".."
|
||||||
|
parse_mode = "HTML"
|
||||||
|
|
||||||
|
[openwrt_log_analyzer]
|
||||||
|
limit = 10
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def main(mac: str, title: str) -> int:
|
||||||
|
db = BotsDatabase()
|
||||||
|
|
||||||
|
data = db.get_openwrt_logs(filter_text=mac,
|
||||||
|
min_id=state['last_id'],
|
||||||
|
limit=config['openwrt_log_analyzer']['limit'])
|
||||||
|
if not data:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
max_id = 0
|
||||||
|
for log in data:
|
||||||
|
if log.id > max_id:
|
||||||
|
max_id = log.id
|
||||||
|
|
||||||
|
text = '\n'.join(map(lambda s: str(s), data))
|
||||||
|
send_telegram(f'<b>{title}</b>\n\n' + text)
|
||||||
|
|
||||||
|
return max_id
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('openwrt_log_analyzer')
|
||||||
|
|
||||||
|
state = SimpleState(file=config['simple_state']['file'],
|
||||||
|
default={'last_id': 0})
|
||||||
|
|
||||||
|
max_last_id = 0
|
||||||
|
for name, mac in config['devices'].items():
|
||||||
|
last_id = main(mac, title=name)
|
||||||
|
if last_id > max_last_id:
|
||||||
|
max_last_id = last_id
|
||||||
|
|
||||||
|
if max_last_id:
|
||||||
|
state['last_id'] = max_last_id
|
74
src/openwrt_logger.py
Executable file
74
src/openwrt_logger.py
Executable file
@ -0,0 +1,74 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from home.config import config
|
||||||
|
from home.database import SimpleState
|
||||||
|
from home.api import WebAPIClient
|
||||||
|
|
||||||
|
log_file = '/var/log/openwrt.log'
|
||||||
|
|
||||||
|
f"""
|
||||||
|
This script is supposed to be run by cron every 5 minutes or so.
|
||||||
|
It looks for new lines in {log_file} and sends them to remote server.
|
||||||
|
|
||||||
|
OpenWRT must have remote logging enabled (UDP; IP of host this script is launched on; port 514)
|
||||||
|
|
||||||
|
/etc/rsyslog.conf contains following (assuming 192.168.1.1 is the router IP):
|
||||||
|
|
||||||
|
$ModLoad imudp
|
||||||
|
$UDPServerRun 514
|
||||||
|
:fromhost-ip, isequal, "192.168.1.1" /var/log/openwrt.log
|
||||||
|
& ~
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_line(line: str) -> tuple[int, str]:
|
||||||
|
space_pos = line.index(' ')
|
||||||
|
|
||||||
|
date = line[:space_pos]
|
||||||
|
rest = line[space_pos+1:]
|
||||||
|
|
||||||
|
return (
|
||||||
|
int(datetime.strptime(date, "%Y-%m-%dT%H:%M:%S%z").timestamp()),
|
||||||
|
rest
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('openwrt_logger')
|
||||||
|
|
||||||
|
state = SimpleState(file=config['simple_state']['file'],
|
||||||
|
default={'seek': 0, 'size': 0})
|
||||||
|
|
||||||
|
fsize = os.path.getsize(log_file)
|
||||||
|
if fsize < state['size']:
|
||||||
|
state['seek'] = 0
|
||||||
|
|
||||||
|
with open(log_file, 'r') as f:
|
||||||
|
if state['seek']:
|
||||||
|
# jump to the latest read position
|
||||||
|
f.seek(state['seek'])
|
||||||
|
|
||||||
|
# read till the end of the file
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
# save new position
|
||||||
|
state['seek'] = f.tell()
|
||||||
|
state['size'] = fsize
|
||||||
|
|
||||||
|
lines: list[tuple[int, str]] = []
|
||||||
|
|
||||||
|
if content != '':
|
||||||
|
for line in content.strip().split('\n'):
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
lines.append(parse_line(line))
|
||||||
|
except ValueError:
|
||||||
|
lines.append((0, line))
|
||||||
|
|
||||||
|
api = WebAPIClient()
|
||||||
|
api.log_openwrt(lines)
|
124
src/pump_bot.py
Executable file
124
src/pump_bot.py
Executable file
@ -0,0 +1,124 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from typing import Optional
|
||||||
|
from home.config import config
|
||||||
|
from home.bot import Wrapper, Context, text_filter, user_any_name
|
||||||
|
from home.relay import RelayClient
|
||||||
|
from home.api.types import BotType
|
||||||
|
from telegram import ReplyKeyboardMarkup, User
|
||||||
|
from telegram.ext import MessageHandler
|
||||||
|
from enum import Enum
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
bot: Optional[Wrapper] = None
|
||||||
|
|
||||||
|
|
||||||
|
class UserAction(Enum):
|
||||||
|
ON = 'on'
|
||||||
|
OFF = 'off'
|
||||||
|
|
||||||
|
|
||||||
|
def get_relay() -> RelayClient:
|
||||||
|
relay = RelayClient(host=config['relay']['ip'], port=config['relay']['port'])
|
||||||
|
relay.connect()
|
||||||
|
return relay
|
||||||
|
|
||||||
|
|
||||||
|
def on(silent: bool, ctx: Context) -> None:
|
||||||
|
get_relay().on()
|
||||||
|
ctx.reply(ctx.lang('done'))
|
||||||
|
if not silent:
|
||||||
|
notify(ctx.user, UserAction.ON)
|
||||||
|
|
||||||
|
|
||||||
|
def off(silent: bool, ctx: Context) -> None:
|
||||||
|
get_relay().off()
|
||||||
|
ctx.reply(ctx.lang('done'))
|
||||||
|
if not silent:
|
||||||
|
notify(ctx.user, UserAction.OFF)
|
||||||
|
|
||||||
|
|
||||||
|
def status(ctx: Context) -> None:
|
||||||
|
ctx.reply(
|
||||||
|
ctx.lang('enabled') if get_relay().status() == 'on' else ctx.lang('disabled')
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def notify(user: User, action: UserAction) -> None:
|
||||||
|
def text_getter(lang: str):
|
||||||
|
action_name = bot.lang.get(f'user_action_{action.value}', lang)
|
||||||
|
user_name = user_any_name(user)
|
||||||
|
return 'ℹ ' + bot.lang.get('user_action_notification', lang,
|
||||||
|
user.id, user_name, action_name)
|
||||||
|
|
||||||
|
bot.notify_all(text_getter, exclude=(user.id,))
|
||||||
|
|
||||||
|
|
||||||
|
class PumpBot(Wrapper):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.lang.ru(
|
||||||
|
start_message="Выберите команду на клавиатуре",
|
||||||
|
unknown_command="Неизвестная команда",
|
||||||
|
|
||||||
|
enable="Включить",
|
||||||
|
enable_silently="Включить тихо",
|
||||||
|
enabled="Включен ✅",
|
||||||
|
|
||||||
|
disable="Выключить",
|
||||||
|
disable_silently="Выключить тихо",
|
||||||
|
disabled="Выключен ❌",
|
||||||
|
|
||||||
|
status="Статус",
|
||||||
|
done="Готово 👌",
|
||||||
|
user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> <b>%s</b> насос.',
|
||||||
|
user_action_on="включил",
|
||||||
|
user_action_off="выключил",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.lang.en(
|
||||||
|
start_message="Select command on the keyboard",
|
||||||
|
unknown_command="Unknown command",
|
||||||
|
|
||||||
|
enable="Turn ON",
|
||||||
|
enable_silently="Turn ON silently",
|
||||||
|
enabled="Turned ON ✅",
|
||||||
|
|
||||||
|
disable="Turn OFF",
|
||||||
|
disable_silently="Turn OFF silently",
|
||||||
|
disabled="Turned OFF ❌",
|
||||||
|
|
||||||
|
status="Status",
|
||||||
|
done="Done 👌",
|
||||||
|
user_action_notification='User <a href="tg://user?id=%d">%s</a> turned the pump <b>%s</b>.',
|
||||||
|
user_action_on="ON",
|
||||||
|
user_action_off="OFF",
|
||||||
|
)
|
||||||
|
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('enable')), self.wrap(partial(on, False))))
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('disable')), self.wrap(partial(off, False))))
|
||||||
|
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('enable_silently')), self.wrap(partial(on, True))))
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('disable_silently')), self.wrap(partial(off, True))))
|
||||||
|
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('status')), self.wrap(status)))
|
||||||
|
|
||||||
|
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
|
||||||
|
buttons = [
|
||||||
|
[ctx.lang('enable'), ctx.lang('disable')],
|
||||||
|
]
|
||||||
|
|
||||||
|
if ctx.user_id in config['bot']['silent_users']:
|
||||||
|
buttons.append([ctx.lang('enable_silently'), ctx.lang('disable_silently')])
|
||||||
|
|
||||||
|
buttons.append([ctx.lang('status')])
|
||||||
|
|
||||||
|
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('pump_bot')
|
||||||
|
|
||||||
|
bot = PumpBot()
|
||||||
|
bot.enable_logging(BotType.PUMP)
|
||||||
|
bot.run()
|
185
src/sensors_bot.py
Executable file
185
src/sensors_bot.py
Executable file
@ -0,0 +1,185 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import json
|
||||||
|
import socket
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import gc
|
||||||
|
|
||||||
|
from io import BytesIO
|
||||||
|
from typing import Optional
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import matplotlib.dates as mdates
|
||||||
|
import matplotlib.ticker as mticker
|
||||||
|
|
||||||
|
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton
|
||||||
|
from telegram.ext import MessageHandler, CallbackQueryHandler
|
||||||
|
|
||||||
|
from home.config import config
|
||||||
|
from home.bot import Wrapper, Context, text_filter
|
||||||
|
from home.util import chunks, MySimpleSocketClient
|
||||||
|
from home.api import WebAPIClient
|
||||||
|
from home.api.types import (
|
||||||
|
BotType,
|
||||||
|
TemperatureSensorLocation
|
||||||
|
)
|
||||||
|
|
||||||
|
bot: Optional[Wrapper] = None
|
||||||
|
plt.rcParams['font.size'] = 7
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
plot_hours = [3, 6, 12, 24]
|
||||||
|
|
||||||
|
|
||||||
|
def read_sensor(sensor: str, ctx: Context) -> None:
|
||||||
|
host = config['sensors'][sensor]['ip']
|
||||||
|
port = config['sensors'][sensor]['port']
|
||||||
|
|
||||||
|
try:
|
||||||
|
client = MySimpleSocketClient(host, port)
|
||||||
|
client.write('read')
|
||||||
|
data = json.loads(client.read())
|
||||||
|
except (socket.timeout, socket.error) as error:
|
||||||
|
return ctx.reply_exc(error)
|
||||||
|
|
||||||
|
temp = round(data['temp'], 2)
|
||||||
|
humidity = round(data['humidity'], 2)
|
||||||
|
|
||||||
|
text = ctx.lang('temperature') + f': <b>{temp} °C</b>\n'
|
||||||
|
text += ctx.lang('humidity') + f': <b>{humidity}%</b>'
|
||||||
|
|
||||||
|
buttons = list(map(
|
||||||
|
lambda h: InlineKeyboardButton(ctx.lang(f'plot_{h}h'), callback_data=f'plot/{sensor}/{h}'),
|
||||||
|
plot_hours
|
||||||
|
))
|
||||||
|
ctx.reply(text, markup=InlineKeyboardMarkup(chunks(buttons, 2)))
|
||||||
|
|
||||||
|
|
||||||
|
def callback_handler(ctx: Context) -> None:
|
||||||
|
query = ctx.callback_query
|
||||||
|
|
||||||
|
sensors_variants = '|'.join(config['sensors'].keys())
|
||||||
|
hour_variants = '|'.join(list(map(
|
||||||
|
lambda n: str(n),
|
||||||
|
plot_hours
|
||||||
|
)))
|
||||||
|
|
||||||
|
match = re.match(rf'plot/({sensors_variants})/({hour_variants})', query.data)
|
||||||
|
if not match:
|
||||||
|
query.answer(ctx.lang('unexpected_callback_data'))
|
||||||
|
return
|
||||||
|
|
||||||
|
query.answer(ctx.lang('loading'))
|
||||||
|
|
||||||
|
# retrieve data
|
||||||
|
sensor = TemperatureSensorLocation[match.group(1).upper()]
|
||||||
|
hours = int(match.group(2))
|
||||||
|
|
||||||
|
api = WebAPIClient()
|
||||||
|
data = api.get_sensors_data(sensor, hours)
|
||||||
|
|
||||||
|
title = ctx.lang(sensor.name.lower()) + ' (' + ctx.lang('n_hrs', hours) + ')'
|
||||||
|
plot = draw_plot(data, title,
|
||||||
|
ctx.lang('temperature'),
|
||||||
|
ctx.lang('humidity'))
|
||||||
|
bot.updater.bot.send_photo(ctx.user_id, plot)
|
||||||
|
|
||||||
|
gc.collect()
|
||||||
|
|
||||||
|
|
||||||
|
def draw_plot(data,
|
||||||
|
title: str,
|
||||||
|
label_temp: str,
|
||||||
|
label_hum: str) -> BytesIO:
|
||||||
|
tempval = []
|
||||||
|
humval = []
|
||||||
|
dates = []
|
||||||
|
for date, temp, humidity in data:
|
||||||
|
dates.append(date)
|
||||||
|
tempval.append(temp)
|
||||||
|
humval.append(humidity)
|
||||||
|
|
||||||
|
fig, axs = plt.subplots(2, 1)
|
||||||
|
df = mdates.DateFormatter('%H:%M')
|
||||||
|
|
||||||
|
axs[0].set_title(label_temp)
|
||||||
|
axs[0].plot(dates, tempval)
|
||||||
|
axs[0].xaxis.set_major_formatter(df)
|
||||||
|
axs[0].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.2f °C'))
|
||||||
|
|
||||||
|
fig.suptitle(title, fontsize=10)
|
||||||
|
|
||||||
|
axs[1].set_title(label_hum)
|
||||||
|
axs[1].plot(dates, humval)
|
||||||
|
axs[1].xaxis.set_major_formatter(df)
|
||||||
|
axs[1].yaxis.set_major_formatter(mticker.FormatStrFormatter('%2.1f %%'))
|
||||||
|
|
||||||
|
fig.autofmt_xdate()
|
||||||
|
|
||||||
|
# should be called after all axes have been added
|
||||||
|
fig.tight_layout()
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
fig.savefig(buf, format='png', dpi=160)
|
||||||
|
buf.seek(0)
|
||||||
|
|
||||||
|
plt.clf()
|
||||||
|
plt.close('all')
|
||||||
|
|
||||||
|
return buf
|
||||||
|
|
||||||
|
|
||||||
|
class SensorsBot(Wrapper):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.lang.ru(
|
||||||
|
start_message="Выберите датчик на клавиатуре",
|
||||||
|
unknown_command="Неизвестная команда",
|
||||||
|
temperature="Температура",
|
||||||
|
humidity="Влажность",
|
||||||
|
plot_3h="График за 3 часа",
|
||||||
|
plot_6h="График за 6 часов",
|
||||||
|
plot_12h="График за 12 часов",
|
||||||
|
plot_24h="График за 24 часа",
|
||||||
|
unexpected_callback_data="Ошибка: неверные данные",
|
||||||
|
loading="Загрузка...",
|
||||||
|
n_hrs="график за %d ч."
|
||||||
|
)
|
||||||
|
|
||||||
|
self.lang.en(
|
||||||
|
start_message="Select the sensor on the keyboard",
|
||||||
|
unknown_command="Unknown command",
|
||||||
|
temperature="Temperature",
|
||||||
|
humidity="Relative humidity",
|
||||||
|
plot_3h="Graph for 3 hours",
|
||||||
|
plot_6h="Graph for 6 hours",
|
||||||
|
plot_12h="Graph for 12 hours",
|
||||||
|
plot_24h="Graph for 24 hours",
|
||||||
|
unexpected_callback_data="Unexpected callback data",
|
||||||
|
loading="Loading...",
|
||||||
|
n_hrs="graph for %d hours"
|
||||||
|
)
|
||||||
|
|
||||||
|
for k, v in config['sensors'].items():
|
||||||
|
self.lang.set({k: v['label_ru']}, 'ru')
|
||||||
|
self.lang.set({k: v['label_en']}, 'en')
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all(k)), self.wrap(partial(read_sensor, k))))
|
||||||
|
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(callback_handler)))
|
||||||
|
|
||||||
|
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
|
||||||
|
buttons = []
|
||||||
|
for k in config['sensors'].keys():
|
||||||
|
buttons.append(ctx.lang(k))
|
||||||
|
buttons = chunks(buttons, 2)
|
||||||
|
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('sensors_bot')
|
||||||
|
|
||||||
|
bot = SensorsBot()
|
||||||
|
if 'api' in config:
|
||||||
|
bot.enable_logging(BotType.SENSORS)
|
||||||
|
bot.run()
|
56
src/sensors_mqtt_receiver.py
Executable file
56
src/sensors_mqtt_receiver.py
Executable file
@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
|
||||||
|
from home.mqtt import MQTTBase
|
||||||
|
from home.config import config
|
||||||
|
from home.mqtt.message import Temperature
|
||||||
|
from home.api.types import TemperatureSensorLocation
|
||||||
|
from home.database import SensorsDatabase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def get_sensor_type(sensor: str) -> TemperatureSensorLocation:
|
||||||
|
for item in TemperatureSensorLocation:
|
||||||
|
if sensor == item.name.lower():
|
||||||
|
return item
|
||||||
|
raise ValueError(f'unexpected sensor value: {sensor}')
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTServer(MQTTBase):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__(clean_session=False)
|
||||||
|
self.database = SensorsDatabase()
|
||||||
|
|
||||||
|
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
|
||||||
|
super().on_connect(client, userdata, flags, rc)
|
||||||
|
logger.info("subscribing to home/#")
|
||||||
|
client.subscribe('home/#', qos=1)
|
||||||
|
|
||||||
|
def on_message(self, client: mqtt.Client, userdata, msg):
|
||||||
|
try:
|
||||||
|
variants = '|'.join([s.name.lower() for s in TemperatureSensorLocation])
|
||||||
|
match = re.match(rf'home/(\d+)/si7021/({variants})', msg.topic)
|
||||||
|
if not match:
|
||||||
|
return
|
||||||
|
|
||||||
|
home_id = int(match.group(1))
|
||||||
|
sensor = get_sensor_type(match.group(2))
|
||||||
|
|
||||||
|
packer = Temperature()
|
||||||
|
client_time, temp, rh = packer.unpack(msg.payload)
|
||||||
|
|
||||||
|
self.database.add_temperature(home_id, client_time, sensor,
|
||||||
|
temp=int(temp*100),
|
||||||
|
rh=int(rh*100))
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('sensors_mqtt_receiver')
|
||||||
|
|
||||||
|
server = MQTTServer()
|
||||||
|
server.connect_and_loop()
|
59
src/sensors_mqtt_sender.py
Executable file
59
src/sensors_mqtt_sender.py
Executable file
@ -0,0 +1,59 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import json
|
||||||
|
|
||||||
|
from home.util import parse_addr, MySimpleSocketClient
|
||||||
|
from home.mqtt import MQTTBase, poll_tick
|
||||||
|
from home.mqtt.message import Temperature
|
||||||
|
from home.config import config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class MQTTClient(MQTTBase):
|
||||||
|
def on_connect(self, client: mqtt.Client, userdata, flags, rc):
|
||||||
|
super().on_connect(client, userdata, flags, rc)
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
freq = int(config['mqtt']['sensors']['poll_freq'])
|
||||||
|
logger.debug(f'freq={freq}')
|
||||||
|
|
||||||
|
g = poll_tick(freq)
|
||||||
|
while True:
|
||||||
|
time.sleep(next(g))
|
||||||
|
for k, v in config['mqtt']['sensors']['si7021'].items():
|
||||||
|
host, port = parse_addr(v['addr'])
|
||||||
|
self.publish_si7021(host, port, k)
|
||||||
|
|
||||||
|
def publish_si7021(self, host: str, port: int, name: str):
|
||||||
|
logging.debug(f"publish_si7021/{name}: {host}:{port}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
now = time.time()
|
||||||
|
socket = MySimpleSocketClient(host, port)
|
||||||
|
|
||||||
|
socket.write('read')
|
||||||
|
response = json.loads(socket.read().strip())
|
||||||
|
|
||||||
|
temp = response['temp']
|
||||||
|
humidity = response['humidity']
|
||||||
|
|
||||||
|
logging.debug(f'publish_si7021/{name}: temp={temp} humidity={humidity}')
|
||||||
|
|
||||||
|
packer = Temperature()
|
||||||
|
self.client.publish(f'home/{self.home_id}/si7021/{name}',
|
||||||
|
payload=packer.pack(round(now), temp, humidity),
|
||||||
|
qos=1)
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('sensors_mqtt_sender')
|
||||||
|
|
||||||
|
client = MQTTClient()
|
||||||
|
client.configure_tls()
|
||||||
|
client.connect_and_loop(loop_forever=False)
|
||||||
|
client.poll()
|
79
src/si7021d.py
Executable file
79
src/si7021d.py
Executable file
@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import smbus
|
||||||
|
import argparse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from home.config import config
|
||||||
|
from home.util import parse_addr
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
bus = None
|
||||||
|
lock = asyncio.Lock()
|
||||||
|
delay = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
async def si7021_read():
|
||||||
|
async with lock:
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# these are still blocking... meh
|
||||||
|
raw = bus.read_i2c_block_data(0x40, 0xE3, 2)
|
||||||
|
temp = 175.72 * (raw[0] << 8 | raw[1]) / 65536.0 - 46.85
|
||||||
|
|
||||||
|
raw = bus.read_i2c_block_data(0x40, 0xE5, 2)
|
||||||
|
rh = 125.0 * (raw[0] << 8 | raw[1]) / 65536.0 - 6.0
|
||||||
|
|
||||||
|
return rh, temp
|
||||||
|
|
||||||
|
|
||||||
|
async def handle_client(reader, writer):
|
||||||
|
request = None
|
||||||
|
while request != 'quit':
|
||||||
|
try:
|
||||||
|
request = await reader.read(255)
|
||||||
|
if request == b'\x04':
|
||||||
|
break
|
||||||
|
request = request.decode('utf-8').strip()
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
|
||||||
|
if request == 'read':
|
||||||
|
try:
|
||||||
|
rh, temp = await asyncio.wait_for(si7021_read(), timeout=3)
|
||||||
|
data = dict(humidity=rh, temp=temp)
|
||||||
|
except asyncio.TimeoutError as e:
|
||||||
|
logger.exception(e)
|
||||||
|
data = dict(error='i2c call timed out')
|
||||||
|
else:
|
||||||
|
data = dict(error='invalid request')
|
||||||
|
|
||||||
|
writer.write((json.dumps(data) + '\r\n').encode('utf-8'))
|
||||||
|
try:
|
||||||
|
await writer.drain()
|
||||||
|
except ConnectionResetError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
writer.close()
|
||||||
|
|
||||||
|
|
||||||
|
async def run_server(host, port):
|
||||||
|
server = await asyncio.start_server(handle_client, host, port)
|
||||||
|
async with server:
|
||||||
|
logger.info('Server started.')
|
||||||
|
await server.serve_forever()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load()
|
||||||
|
|
||||||
|
host, port = parse_addr(config['server']['listen'])
|
||||||
|
|
||||||
|
delay = float(config['smbus']['delay'])
|
||||||
|
bus = smbus.SMBus(int(config['smbus']['bus']))
|
||||||
|
|
||||||
|
try:
|
||||||
|
asyncio.run(run_server(host, port))
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logging.info('Exiting...')
|
783
src/sound_bot.py
Executable file
783
src/sound_bot.py
Executable file
@ -0,0 +1,783 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from enum import Enum
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from html import escape
|
||||||
|
from typing import Optional
|
||||||
|
from home.config import config
|
||||||
|
from home.bot import Wrapper, Context, text_filter, user_any_name
|
||||||
|
from home.api.types import BotType
|
||||||
|
from home.api.errors import ApiResponseError
|
||||||
|
from home.sound import SoundNodeClient, RecordClient, RecordFile
|
||||||
|
from home.soundsensor import SoundSensorServerGuardClient
|
||||||
|
from home.util import parse_addr, chunks, filesize_fmt
|
||||||
|
from home.api import WebAPIClient
|
||||||
|
from home.api.types import SoundSensorLocation
|
||||||
|
|
||||||
|
from telegram.error import TelegramError
|
||||||
|
from telegram import ReplyKeyboardMarkup, InlineKeyboardMarkup, InlineKeyboardButton, User
|
||||||
|
from telegram.ext import (
|
||||||
|
CallbackQueryHandler,
|
||||||
|
MessageHandler
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
RenderedContent = tuple[str, Optional[InlineKeyboardMarkup]]
|
||||||
|
record_client: Optional[RecordClient] = None
|
||||||
|
bot: Optional[Wrapper] = None
|
||||||
|
node_client_links: dict[str, SoundNodeClient] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def node_client(node: str) -> SoundNodeClient:
|
||||||
|
if node not in node_client_links:
|
||||||
|
node_client_links[node] = SoundNodeClient(parse_addr(config['nodes'][node]['addr']))
|
||||||
|
return node_client_links[node]
|
||||||
|
|
||||||
|
|
||||||
|
def node_exists(node: str) -> bool:
|
||||||
|
return node in config['nodes']
|
||||||
|
|
||||||
|
|
||||||
|
def sound_sensor_exists(node: str) -> bool:
|
||||||
|
return node in config['sound_sensors']
|
||||||
|
|
||||||
|
|
||||||
|
def interval_defined(interval: int) -> bool:
|
||||||
|
return interval in config['bot']['record_intervals']
|
||||||
|
|
||||||
|
|
||||||
|
def callback_unpack(ctx: Context) -> list[str]:
|
||||||
|
return ctx.callback_query.data[3:].split('/')
|
||||||
|
|
||||||
|
|
||||||
|
def manual_recording_allowed(user_id: int) -> bool:
|
||||||
|
return 'manual_record_allowlist' not in config['bot'] or user_id in config['bot']['manual_record_allowlist']
|
||||||
|
|
||||||
|
|
||||||
|
def guard_client() -> SoundSensorServerGuardClient:
|
||||||
|
return SoundSensorServerGuardClient(parse_addr(config['bot']['guard_server']))
|
||||||
|
|
||||||
|
|
||||||
|
# message renderers
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
class Renderer:
|
||||||
|
@classmethod
|
||||||
|
def places_markup(cls, ctx: Context, callback_prefix: str) -> InlineKeyboardMarkup:
|
||||||
|
buttons = []
|
||||||
|
for node, nodeconfig in config['nodes'].items():
|
||||||
|
buttons.append([InlineKeyboardButton(nodeconfig['label'][ctx.user_lang], callback_data=f'{callback_prefix}/{node}')])
|
||||||
|
return InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def back_button(cls,
|
||||||
|
ctx: Context,
|
||||||
|
buttons: list,
|
||||||
|
callback_data: str):
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(ctx.lang('back'), callback_data=callback_data)
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class SettingsRenderer(Renderer):
|
||||||
|
@classmethod
|
||||||
|
def index(cls, ctx: Context) -> RenderedContent:
|
||||||
|
html = f'<b>{ctx.lang("settings")}</b>\n\n'
|
||||||
|
html += ctx.lang('select_place')
|
||||||
|
return html, cls.places_markup(ctx, callback_prefix='s0')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def node(cls, ctx: Context,
|
||||||
|
controls: list[dict]) -> RenderedContent:
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
|
||||||
|
html = []
|
||||||
|
buttons = []
|
||||||
|
for control in controls:
|
||||||
|
html.append(f'<b>{control["name"]}</b>\n{escape(control["info"])}')
|
||||||
|
buttons.append([
|
||||||
|
InlineKeyboardButton(control['name'], callback_data=f's1/{node}/{control["name"]}')
|
||||||
|
])
|
||||||
|
|
||||||
|
html = "\n\n".join(html)
|
||||||
|
cls.back_button(ctx, buttons, callback_data='s0')
|
||||||
|
|
||||||
|
return html, InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def control(cls, ctx: Context, data) -> RenderedContent:
|
||||||
|
node, control, *rest = callback_unpack(ctx)
|
||||||
|
|
||||||
|
html = '<b>' + ctx.lang('control_state', control) + '</b>\n\n'
|
||||||
|
html += escape(data['info'])
|
||||||
|
buttons = []
|
||||||
|
callback_prefix = f's2/{node}/{control}'
|
||||||
|
for cap in data['caps']:
|
||||||
|
if cap == 'mute':
|
||||||
|
muted = 'dB] [off]' in data['info']
|
||||||
|
act = 'unmute' if muted else 'mute'
|
||||||
|
buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')])
|
||||||
|
|
||||||
|
elif cap == 'cap':
|
||||||
|
cap_dis = 'Capture [off]' in data['info']
|
||||||
|
act = 'cap' if cap_dis else 'nocap'
|
||||||
|
buttons.append([InlineKeyboardButton(act, callback_data=f'{callback_prefix}/{act}')])
|
||||||
|
|
||||||
|
elif cap == 'volume':
|
||||||
|
buttons.append(
|
||||||
|
list(map(lambda s: InlineKeyboardButton(ctx.lang(s), callback_data=f'{callback_prefix}/{s}'),
|
||||||
|
['decr', 'incr']))
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.back_button(ctx, buttons, callback_data=f's0/{node}')
|
||||||
|
|
||||||
|
return html, InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
|
||||||
|
class RecordRenderer(Renderer):
|
||||||
|
@classmethod
|
||||||
|
def index(cls, ctx: Context) -> RenderedContent:
|
||||||
|
html = f'<b>{ctx.lang("record")}</b>\n\n'
|
||||||
|
html += ctx.lang('select_place')
|
||||||
|
return html, cls.places_markup(ctx, callback_prefix='r0')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def node(cls, ctx: Context, durations: list[int]) -> RenderedContent:
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
|
||||||
|
html = ctx.lang('select_interval')
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
for s in durations:
|
||||||
|
if s >= 60:
|
||||||
|
m = int(s / 60)
|
||||||
|
label = ctx.lang('n_min', m)
|
||||||
|
else:
|
||||||
|
label = ctx.lang('n_sec', s)
|
||||||
|
buttons.append(InlineKeyboardButton(label, callback_data=f'r1/{node}/{s}'))
|
||||||
|
buttons = list(chunks(buttons, 3))
|
||||||
|
cls.back_button(ctx, buttons, callback_data=f'r0')
|
||||||
|
|
||||||
|
return html, InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def record_started(cls, ctx: Context, rid: int) -> RenderedContent:
|
||||||
|
node, *rest = callback_unpack(ctx)
|
||||||
|
|
||||||
|
place = config['nodes'][node]['label'][ctx.user_lang]
|
||||||
|
|
||||||
|
html = f'<b>{ctx.lang("record_started")}</b> (<i>{place}</i>, id={rid})'
|
||||||
|
return html, None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def record_done(cls, info: dict, node: str, uid: int) -> str:
|
||||||
|
ulang = bot.store.get_user_lang(uid)
|
||||||
|
|
||||||
|
def lang(key, *args):
|
||||||
|
return bot.lang.get(key, ulang, *args)
|
||||||
|
|
||||||
|
rid = info['id']
|
||||||
|
fmt = '%d.%m.%y %H:%M:%S'
|
||||||
|
start_time = datetime.fromtimestamp(int(info['start_time'])).strftime(fmt)
|
||||||
|
stop_time = datetime.fromtimestamp(int(info['stop_time'])).strftime(fmt)
|
||||||
|
|
||||||
|
place = config['nodes'][node]['label'][ulang]
|
||||||
|
|
||||||
|
html = f'<b>{lang("record_result")}</b> (<i>{place}</i>, id={rid})\n\n'
|
||||||
|
html += f'<b>{lang("beginning")}</b>: {start_time}\n'
|
||||||
|
html += f'<b>{lang("end")}</b>: {stop_time}'
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def record_error(cls, info: dict, node: str, uid: int) -> str:
|
||||||
|
ulang = bot.store.get_user_lang(uid)
|
||||||
|
|
||||||
|
def lang(key, *args):
|
||||||
|
return bot.lang.get(key, ulang, *args)
|
||||||
|
|
||||||
|
place = config['nodes'][node]['label'][ulang]
|
||||||
|
rid = info['id']
|
||||||
|
|
||||||
|
html = f'<b>{lang("record_error")}</b> (<i>{place}</i>, id={rid})'
|
||||||
|
if 'error' in info:
|
||||||
|
html += '\n'+str(info['error'])
|
||||||
|
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
class FilesRenderer(Renderer):
|
||||||
|
@classmethod
|
||||||
|
def index(cls, ctx: Context) -> RenderedContent:
|
||||||
|
html = f'<b>{ctx.lang("files")}</b>\n\n'
|
||||||
|
html += ctx.lang('select_place')
|
||||||
|
return html, cls.places_markup(ctx, callback_prefix='f0')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def filelist(cls, ctx: Context, files: list[RecordFile]) -> RenderedContent:
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
|
||||||
|
html_files = map(lambda file: cls.file(ctx, file, node), files)
|
||||||
|
html = '\n\n'.join(html_files)
|
||||||
|
|
||||||
|
buttons = []
|
||||||
|
cls.back_button(ctx, buttons, callback_data='f0')
|
||||||
|
|
||||||
|
return html, InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def file(cls, ctx: Context, file: RecordFile, node: str) -> str:
|
||||||
|
html = ctx.lang('file_line', file.start_humantime, file.stop_humantime, filesize_fmt(file.filesize))
|
||||||
|
if file.file_id is not None:
|
||||||
|
html += f'/audio_{node}_{file.file_id}'
|
||||||
|
return html
|
||||||
|
|
||||||
|
|
||||||
|
class RemoteFilesRenderer(FilesRenderer):
|
||||||
|
@classmethod
|
||||||
|
def index(cls, ctx: Context) -> RenderedContent:
|
||||||
|
html = f'<b>{ctx.lang("remote_files")}</b>\n\n'
|
||||||
|
html += ctx.lang('select_place')
|
||||||
|
return html, cls.places_markup(ctx, callback_prefix='g0')
|
||||||
|
|
||||||
|
|
||||||
|
class SoundSensorRenderer(Renderer):
|
||||||
|
@classmethod
|
||||||
|
def places_markup(cls, ctx: Context, callback_prefix: str) -> InlineKeyboardMarkup:
|
||||||
|
buttons = []
|
||||||
|
for sensor, sensor_label in config['sound_sensors'].items():
|
||||||
|
buttons.append(
|
||||||
|
[InlineKeyboardButton(sensor_label[ctx.user_lang], callback_data=f'{callback_prefix}/{sensor}')])
|
||||||
|
return InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def index(cls, ctx: Context) -> RenderedContent:
|
||||||
|
html = f'{ctx.lang("sound_sensors_info")}\n\n'
|
||||||
|
html += ctx.lang('select_place')
|
||||||
|
return html, cls.places_markup(ctx, callback_prefix='S0')
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def hits(cls, ctx: Context, data, is_last=False) -> RenderedContent:
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
buttons = []
|
||||||
|
|
||||||
|
if not data:
|
||||||
|
html = ctx.lang('sound_sensors_no_24h_data')
|
||||||
|
if not is_last:
|
||||||
|
buttons.append([InlineKeyboardButton(ctx.lang('sound_sensors_show_anything'), callback_data=f'S1/{node}')])
|
||||||
|
else:
|
||||||
|
html = ''
|
||||||
|
prev_date = None
|
||||||
|
for item in data:
|
||||||
|
item_date = item['time'].strftime('%d.%m.%y')
|
||||||
|
if prev_date is None or prev_date != item_date:
|
||||||
|
if html != '':
|
||||||
|
html += '\n\n'
|
||||||
|
html += f'<b>{item_date}</b>'
|
||||||
|
prev_date = item_date
|
||||||
|
html += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})'
|
||||||
|
cls.back_button(ctx, buttons, callback_data='S0')
|
||||||
|
return html, InlineKeyboardMarkup(buttons)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def hits_plain(cls, ctx: Context, data, is_last=False) -> bytes:
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
|
||||||
|
text = ''
|
||||||
|
prev_date = None
|
||||||
|
for item in data:
|
||||||
|
item_date = item['time'].strftime('%d.%m.%y')
|
||||||
|
if prev_date is None or prev_date != item_date:
|
||||||
|
if text != '':
|
||||||
|
text += '\n\n'
|
||||||
|
text += item_date
|
||||||
|
prev_date = item_date
|
||||||
|
text += '\n' + item['time'].strftime('%H:%M:%S') + f' (+{item["hits"]})'
|
||||||
|
|
||||||
|
return text.encode()
|
||||||
|
|
||||||
|
|
||||||
|
# settings handlers
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
def settings(ctx: Context):
|
||||||
|
text, markup = SettingsRenderer.index(ctx)
|
||||||
|
if not ctx.is_callback_context():
|
||||||
|
return ctx.reply(text, markup=markup)
|
||||||
|
else:
|
||||||
|
ctx.answer()
|
||||||
|
return ctx.edit(text, markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
def settings_place(ctx: Context) -> None:
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
if not node_exists(node):
|
||||||
|
ctx.answer(ctx.lang('invalid_location'))
|
||||||
|
return
|
||||||
|
|
||||||
|
cl = node_client(node)
|
||||||
|
controls = cl.amixer_get_all()
|
||||||
|
|
||||||
|
ctx.answer()
|
||||||
|
|
||||||
|
text, markup = SettingsRenderer.node(ctx, controls)
|
||||||
|
ctx.edit(text, markup)
|
||||||
|
|
||||||
|
|
||||||
|
def settings_place_control(ctx: Context) -> None:
|
||||||
|
node, control = callback_unpack(ctx)
|
||||||
|
if not node_exists(node):
|
||||||
|
ctx.answer(ctx.lang('invalid_location'))
|
||||||
|
return
|
||||||
|
|
||||||
|
cl = node_client(node)
|
||||||
|
control_data = cl.amixer_get(control)
|
||||||
|
|
||||||
|
ctx.answer()
|
||||||
|
|
||||||
|
text, markup = SettingsRenderer.control(ctx, control_data)
|
||||||
|
ctx.edit(text, markup)
|
||||||
|
|
||||||
|
|
||||||
|
def settings_place_control_action(ctx: Context) -> None:
|
||||||
|
node, control, action = callback_unpack(ctx)
|
||||||
|
if not node_exists(node):
|
||||||
|
return
|
||||||
|
|
||||||
|
cl = node_client(node)
|
||||||
|
if not hasattr(cl, f'amixer_{action}'):
|
||||||
|
ctx.answer(ctx.lang('invalid_action'))
|
||||||
|
return
|
||||||
|
|
||||||
|
func = getattr(cl, f'amixer_{action}')
|
||||||
|
control_data = func(control)
|
||||||
|
|
||||||
|
ctx.answer()
|
||||||
|
|
||||||
|
text, markup = SettingsRenderer.control(ctx, control_data)
|
||||||
|
ctx.edit(text, markup)
|
||||||
|
|
||||||
|
|
||||||
|
# recording handlers
|
||||||
|
# ------------------
|
||||||
|
|
||||||
|
def record(ctx: Context):
|
||||||
|
if not manual_recording_allowed(ctx.user_id):
|
||||||
|
return ctx.reply(ctx.lang('access_denied'))
|
||||||
|
|
||||||
|
text, markup = RecordRenderer.index(ctx)
|
||||||
|
if not ctx.is_callback_context():
|
||||||
|
return ctx.reply(text, markup=markup)
|
||||||
|
else:
|
||||||
|
ctx.answer()
|
||||||
|
return ctx.edit(text, markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
def record_place(ctx: Context) -> None:
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
if not node_exists(node):
|
||||||
|
ctx.answer(ctx.lang('invalid_location'))
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.answer()
|
||||||
|
|
||||||
|
text, markup = RecordRenderer.node(ctx, config['bot']['record_intervals'])
|
||||||
|
ctx.edit(text, markup)
|
||||||
|
|
||||||
|
|
||||||
|
def record_place_interval(ctx: Context) -> None:
|
||||||
|
node, interval = callback_unpack(ctx)
|
||||||
|
interval = int(interval)
|
||||||
|
if not node_exists(node):
|
||||||
|
ctx.answer(ctx.lang('invalid_location'))
|
||||||
|
return
|
||||||
|
if not interval_defined(interval):
|
||||||
|
ctx.answer(ctx.lang('invalid_interval'))
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
record_id = record_client.record(node, interval, {'user_id': ctx.user_id, 'node': node})
|
||||||
|
except ApiResponseError as e:
|
||||||
|
ctx.answer(e.error_message)
|
||||||
|
logger.error(e)
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.answer()
|
||||||
|
|
||||||
|
html, markup = RecordRenderer.record_started(ctx, record_id)
|
||||||
|
ctx.edit(html, markup)
|
||||||
|
|
||||||
|
|
||||||
|
# files handlers
|
||||||
|
# --------------
|
||||||
|
|
||||||
|
# def files(ctx: Context, remote=False):
|
||||||
|
# renderer = RemoteFilesRenderer if remote else FilesRenderer
|
||||||
|
# text, markup = renderer.index(ctx)
|
||||||
|
# if not ctx.is_callback_context():
|
||||||
|
# return ctx.reply(text, markup=markup)
|
||||||
|
# else:
|
||||||
|
# ctx.answer()
|
||||||
|
# return ctx.edit(text, markup=markup)
|
||||||
|
#
|
||||||
|
#
|
||||||
|
# def files_list(ctx: Context):
|
||||||
|
# node, = callback_unpack(ctx)
|
||||||
|
# if not node_exists(node):
|
||||||
|
# ctx.answer(ctx.lang('invalid_location'))
|
||||||
|
# return
|
||||||
|
#
|
||||||
|
# ctx.answer()
|
||||||
|
#
|
||||||
|
# cl = node_client(node)
|
||||||
|
# files = cl.storage_list(extended=True, as_objects=True)
|
||||||
|
#
|
||||||
|
# text, markup = FilesRenderer.filelist(ctx, files)
|
||||||
|
# ctx.edit(text, markup)
|
||||||
|
|
||||||
|
|
||||||
|
# sound sensor handlers
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
def sound_sensors(ctx: Context):
|
||||||
|
text, markup = SoundSensorRenderer.index(ctx)
|
||||||
|
if not ctx.is_callback_context():
|
||||||
|
return ctx.reply(text, markup=markup)
|
||||||
|
else:
|
||||||
|
ctx.answer()
|
||||||
|
return ctx.edit(text, markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
def sound_sensors_last_24h(ctx: Context):
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
if not sound_sensor_exists(node):
|
||||||
|
ctx.answer(ctx.lang('invalid location'))
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.answer()
|
||||||
|
|
||||||
|
cl = WebAPIClient()
|
||||||
|
data = cl.get_sound_sensor_hits(location=SoundSensorLocation[node.upper()],
|
||||||
|
after=datetime.now() - timedelta(hours=24))
|
||||||
|
|
||||||
|
text, markup = SoundSensorRenderer.hits(ctx, data)
|
||||||
|
if len(text) > 4096:
|
||||||
|
plain = SoundSensorRenderer.hits_plain(ctx, data)
|
||||||
|
bot.send_file(ctx.user_id, document=plain, filename='data.txt')
|
||||||
|
else:
|
||||||
|
ctx.edit(text, markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
def sound_sensors_last_anything(ctx: Context):
|
||||||
|
node, = callback_unpack(ctx)
|
||||||
|
if not sound_sensor_exists(node):
|
||||||
|
ctx.answer(ctx.lang('invalid location'))
|
||||||
|
return
|
||||||
|
|
||||||
|
ctx.answer()
|
||||||
|
|
||||||
|
cl = WebAPIClient()
|
||||||
|
data = cl.get_last_sound_sensor_hits(location=SoundSensorLocation[node.upper()],
|
||||||
|
last=20)
|
||||||
|
|
||||||
|
text, markup = SoundSensorRenderer.hits(ctx, data, is_last=True)
|
||||||
|
if len(text) > 4096:
|
||||||
|
plain = SoundSensorRenderer.hits_plain(ctx, data)
|
||||||
|
bot.send_file(ctx.user_id, document=plain, filename='data.txt')
|
||||||
|
else:
|
||||||
|
ctx.edit(text, markup=markup)
|
||||||
|
|
||||||
|
|
||||||
|
# guard enable/disable handlers
|
||||||
|
# -----------------------------
|
||||||
|
|
||||||
|
class GuardUserAction(Enum):
|
||||||
|
ENABLE = 'enable'
|
||||||
|
DISABLE = 'disable'
|
||||||
|
|
||||||
|
|
||||||
|
def guard_status(ctx: Context):
|
||||||
|
guard = guard_client()
|
||||||
|
resp = guard.guard_status()
|
||||||
|
|
||||||
|
key = 'enabled' if resp['enabled'] is True else 'disabled'
|
||||||
|
ctx.reply(ctx.lang(f'guard_status_{key}'))
|
||||||
|
|
||||||
|
|
||||||
|
def guard_enable(ctx: Context):
|
||||||
|
guard = guard_client()
|
||||||
|
guard.guard_enable()
|
||||||
|
ctx.reply(ctx.lang('done'))
|
||||||
|
|
||||||
|
_guard_notify(ctx.user, GuardUserAction.ENABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def guard_disable(ctx: Context):
|
||||||
|
guard = guard_client()
|
||||||
|
guard.guard_disable()
|
||||||
|
ctx.reply(ctx.lang('done'))
|
||||||
|
|
||||||
|
_guard_notify(ctx.user, GuardUserAction.DISABLE)
|
||||||
|
|
||||||
|
|
||||||
|
def _guard_notify(user: User, action: GuardUserAction):
|
||||||
|
def text_getter(lang: str):
|
||||||
|
action_name = bot.lang.get(f'guard_user_action_{action.value}', lang)
|
||||||
|
user_name = user_any_name(user)
|
||||||
|
return 'ℹ ' + bot.lang.get('guard_user_action_notification', lang,
|
||||||
|
user.id, user_name, action_name)
|
||||||
|
|
||||||
|
bot.notify_all(text_getter, exclude=(user.id,))
|
||||||
|
|
||||||
|
|
||||||
|
# record client callbacks
|
||||||
|
# -----------------------
|
||||||
|
|
||||||
|
def record_onerror(info: dict, userdata: dict):
|
||||||
|
uid = userdata['user_id']
|
||||||
|
node = userdata['node']
|
||||||
|
|
||||||
|
html = RecordRenderer.record_error(info, node, uid)
|
||||||
|
try:
|
||||||
|
bot.notify_user(userdata['user_id'], html)
|
||||||
|
except TelegramError as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
finally:
|
||||||
|
record_client.forget(node, info['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def record_onfinished(info: dict, fn: str, userdata: dict):
|
||||||
|
logger.info('record finished: ' + str(info))
|
||||||
|
|
||||||
|
uid = userdata['user_id']
|
||||||
|
node = userdata['node']
|
||||||
|
|
||||||
|
html = RecordRenderer.record_done(info, node, uid)
|
||||||
|
bot.notify_user(uid, html)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# sending audiofile to telegram
|
||||||
|
with open(fn, 'rb') as f:
|
||||||
|
bot.send_audio(uid, audio=f, filename='audio.mp3')
|
||||||
|
|
||||||
|
# deleting temp file
|
||||||
|
try:
|
||||||
|
os.unlink(fn)
|
||||||
|
except OSError as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
bot.notify_user(uid, exc)
|
||||||
|
|
||||||
|
# remove the recording from sound_node's history
|
||||||
|
record_client.forget(node, info['id'])
|
||||||
|
|
||||||
|
# remove file from storage
|
||||||
|
# node_client(node).storage_delete(info['file']['fileid'])
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(e)
|
||||||
|
|
||||||
|
|
||||||
|
class SoundBot(Wrapper):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
|
||||||
|
self.lang.ru(
|
||||||
|
start_message="Выберите команду на клавиатуре",
|
||||||
|
unknown_command="Неизвестная команда",
|
||||||
|
unexpected_callback_data="Ошибка: неверные данные",
|
||||||
|
settings="Настройки микшера",
|
||||||
|
record="Запись",
|
||||||
|
loading="Загрузка...",
|
||||||
|
select_place="Выберите место:",
|
||||||
|
invalid_location="Неверное место",
|
||||||
|
invalid_interval="Неверная длительность",
|
||||||
|
unsupported_action="Неподдерживаемое действие",
|
||||||
|
# select_control="Выберите контрол для изменения настроек:",
|
||||||
|
control_state="Состояние контрола %s",
|
||||||
|
incr="громкость +",
|
||||||
|
decr="громкость -",
|
||||||
|
back="◀️ Назад",
|
||||||
|
n_min="%d мин.",
|
||||||
|
n_sec="%d сек.",
|
||||||
|
select_interval="Выберите длительность:",
|
||||||
|
place="Место",
|
||||||
|
beginning="Начало",
|
||||||
|
end="Конец",
|
||||||
|
record_result="Результат записи",
|
||||||
|
record_started='Запись запущена!',
|
||||||
|
record_error="Ошибка записи",
|
||||||
|
files="Локальные файлы",
|
||||||
|
remote_files="Файлы на сервере",
|
||||||
|
file_line="— Запись с <b>%s</b> до <b>%s</b> <i>(%s)</i>",
|
||||||
|
access_denied="Доступ запрещён",
|
||||||
|
|
||||||
|
guard_disable="Снять с охраны",
|
||||||
|
guard_enable="Поставить на охрану",
|
||||||
|
guard_status="Статус охраны",
|
||||||
|
guard_user_action_notification='Пользователь <a href="tg://user?id=%d">%s</a> %s.',
|
||||||
|
guard_user_action_enable="включил охрану ✅",
|
||||||
|
guard_user_action_disable="выключил охрану ❌",
|
||||||
|
guard_status_enabled="Включена ✅",
|
||||||
|
guard_status_disabled="Выключена ❌",
|
||||||
|
|
||||||
|
done="Готово 👌",
|
||||||
|
|
||||||
|
sound_sensors="Датчики звука",
|
||||||
|
sound_sensors_info="Здесь можно получить информацию о последних срабатываниях датчиков звука.",
|
||||||
|
sound_sensors_no_24h_data="За последние 24 часа данных нет.",
|
||||||
|
sound_sensors_show_anything="Показать, что есть"
|
||||||
|
)
|
||||||
|
|
||||||
|
self.lang.en(
|
||||||
|
start_message="Select command on the keyboard",
|
||||||
|
unknown_command="Unknown command",
|
||||||
|
settings="Mixer settings",
|
||||||
|
record="Record",
|
||||||
|
unexpected_callback_data="Unexpected callback data",
|
||||||
|
loading="Loading...",
|
||||||
|
select_place="Select place:",
|
||||||
|
invalid_location="Invalid place",
|
||||||
|
invalid_interval="Invalid duration",
|
||||||
|
unsupported_action="Unsupported action",
|
||||||
|
# select_control="Select control to adjust its parameters:",
|
||||||
|
control_state="%s control state",
|
||||||
|
incr="vol +",
|
||||||
|
decr="vol -",
|
||||||
|
back="◀️ Back",
|
||||||
|
n_min="%d min.",
|
||||||
|
n_sec="%d s.",
|
||||||
|
select_interval="Select duration:",
|
||||||
|
place="Place",
|
||||||
|
beginning="Started",
|
||||||
|
end="Ended",
|
||||||
|
record_result="Result",
|
||||||
|
record_started='Recording started!',
|
||||||
|
record_error="Recording error",
|
||||||
|
files="Local files",
|
||||||
|
remote_files="Remote files",
|
||||||
|
file_line="— From <b>%s</b> to <b>%s</b> <i>(%s)</i>",
|
||||||
|
access_denied="Access denied",
|
||||||
|
|
||||||
|
guard_disable="Disable guard",
|
||||||
|
guard_enable="Enable guard",
|
||||||
|
guard_status="Guard status",
|
||||||
|
guard_user_action_notification='User <a href="tg://user?id=%d">%s</a> %s.',
|
||||||
|
guard_user_action_enable="turned the guard ON ✅",
|
||||||
|
guard_user_action_disable="turn the guard OFF ❌",
|
||||||
|
guard_status_enabled="Active ✅",
|
||||||
|
guard_status_disabled="Disabled ❌",
|
||||||
|
done="Done 👌",
|
||||||
|
|
||||||
|
sound_sensors="Sound sensors",
|
||||||
|
sound_sensors_info="Here you can get information about last sound sensors hits.",
|
||||||
|
sound_sensors_no_24h_data="No data for the last 24 hours.",
|
||||||
|
sound_sensors_show_anything="Show me at least something"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ------
|
||||||
|
# settings
|
||||||
|
# -------------
|
||||||
|
|
||||||
|
# list of nodes
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('settings')), self.wrap(settings)))
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(settings), pattern=r'^s0$'))
|
||||||
|
|
||||||
|
# list of controls
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(settings_place), pattern=r'^s0/.*'))
|
||||||
|
|
||||||
|
# list of available tunes for control
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(settings_place_control), pattern=r'^s1/.*'))
|
||||||
|
|
||||||
|
# tuning
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(settings_place_control_action), pattern=r'^s2/.*'))
|
||||||
|
|
||||||
|
# ------
|
||||||
|
# recording
|
||||||
|
# --------------
|
||||||
|
|
||||||
|
# list of nodes
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('record')), self.wrap(record)))
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(record), pattern=r'^r0$'))
|
||||||
|
|
||||||
|
# list of available intervals
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(record_place), pattern=r'^r0/.*'))
|
||||||
|
|
||||||
|
# do record!
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(record_place_interval), pattern=r'^r1/.*'))
|
||||||
|
|
||||||
|
# ---------
|
||||||
|
# sound sensors
|
||||||
|
# ------------------
|
||||||
|
|
||||||
|
# list of places
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('sound_sensors')), self.wrap(sound_sensors)))
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(sound_sensors), pattern=r'^S0$'))
|
||||||
|
|
||||||
|
# last 24h log
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(sound_sensors_last_24h), pattern=r'^S0/.*'))
|
||||||
|
|
||||||
|
# last _something_
|
||||||
|
self.add_handler(CallbackQueryHandler(self.wrap(sound_sensors_last_anything), pattern=r'^S1/.*'))
|
||||||
|
|
||||||
|
# -------------
|
||||||
|
# guard enable/disable
|
||||||
|
# -------------------------
|
||||||
|
if 'guard_server' in config['bot']:
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('guard_enable')), self.wrap(guard_enable)))
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('guard_disable')), self.wrap(guard_disable)))
|
||||||
|
self.add_handler(MessageHandler(text_filter(self.lang.all('guard_status')), self.wrap(guard_status)))
|
||||||
|
|
||||||
|
# --------
|
||||||
|
# local files
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
# list of nodes
|
||||||
|
# self.add_handler(MessageHandler(text_filter(self.lang.all('files')), self.wrap(partial(files, remote=False))))
|
||||||
|
# self.add_handler(CallbackQueryHandler(self.wrap(partial(files, remote=False)), pattern=r'^f0$'))
|
||||||
|
|
||||||
|
# list of specific node's files
|
||||||
|
# self.add_handler(CallbackQueryHandler(self.wrap(files_list), pattern=r'^f0/.*'))
|
||||||
|
|
||||||
|
# --------
|
||||||
|
# remote files
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
# list of nodes
|
||||||
|
# self.add_handler(MessageHandler(text_filter(self.lang.all('remote_files')), self.wrap(partial(files, remote=True))))
|
||||||
|
# self.add_handler(CallbackQueryHandler(self.wrap(partial(files, remote=True)), pattern=r'^g0$'))
|
||||||
|
|
||||||
|
# list of specific node's files
|
||||||
|
# self.add_handler(CallbackQueryHandler(self.wrap(files_list), pattern=r'^g0/.*'))
|
||||||
|
|
||||||
|
def markup(self, ctx: Optional[Context]) -> Optional[ReplyKeyboardMarkup]:
|
||||||
|
buttons = [
|
||||||
|
[ctx.lang('record'), ctx.lang('settings')],
|
||||||
|
# [ctx.lang('files'), ctx.lang('remote_files')],
|
||||||
|
]
|
||||||
|
if 'guard_server' in config['bot']:
|
||||||
|
buttons.append([
|
||||||
|
ctx.lang('guard_enable'), ctx.lang('guard_disable'), ctx.lang('guard_status')
|
||||||
|
])
|
||||||
|
buttons.append([ctx.lang('sound_sensors')])
|
||||||
|
return ReplyKeyboardMarkup(buttons, one_time_keyboard=False)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('sound_bot')
|
||||||
|
|
||||||
|
nodes = {}
|
||||||
|
for nodename, nodecfg in config['nodes'].items():
|
||||||
|
nodes[nodename] = parse_addr(nodecfg['addr'])
|
||||||
|
|
||||||
|
record_client = RecordClient(nodes,
|
||||||
|
error_handler=record_onerror,
|
||||||
|
finished_handler=record_onfinished,
|
||||||
|
download_on_finish=True)
|
||||||
|
|
||||||
|
bot = SoundBot()
|
||||||
|
if 'api' in config:
|
||||||
|
bot.enable_logging(BotType.SOUND)
|
||||||
|
bot.run()
|
||||||
|
|
||||||
|
record_client.stop()
|
225
src/sound_node.py
Executable file
225
src/sound_node.py
Executable file
@ -0,0 +1,225 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import os
|
||||||
|
|
||||||
|
from typing import Optional
|
||||||
|
from aiohttp import web
|
||||||
|
from aiohttp.web_exceptions import (
|
||||||
|
HTTPNotFound
|
||||||
|
)
|
||||||
|
from home.config import config
|
||||||
|
from home.util import parse_addr, stringify, format_tb
|
||||||
|
from home.sound import (
|
||||||
|
amixer,
|
||||||
|
Recorder,
|
||||||
|
RecordStatus,
|
||||||
|
RecordStorage
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
This script must be run as root as it runs arecord.
|
||||||
|
|
||||||
|
This script implements HTTP API for amixer and arecord.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# some global variables
|
||||||
|
# ---------------------
|
||||||
|
|
||||||
|
recorder: Optional[Recorder]
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
storage: Optional[RecordStorage]
|
||||||
|
|
||||||
|
|
||||||
|
# common http funcs & helpers
|
||||||
|
# ---------------------------
|
||||||
|
|
||||||
|
@web.middleware
|
||||||
|
async def errors_handler_middleware(request, handler):
|
||||||
|
try:
|
||||||
|
response = await handler(request)
|
||||||
|
return response
|
||||||
|
|
||||||
|
except HTTPNotFound:
|
||||||
|
return web.json_response({'error': 'not found'}, status=404)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
data = {
|
||||||
|
'error': exc.__class__.__name__,
|
||||||
|
'message': exc.message if hasattr(exc, 'message') else str(exc)
|
||||||
|
}
|
||||||
|
tb = format_tb(exc)
|
||||||
|
if tb:
|
||||||
|
data['stacktrace'] = tb
|
||||||
|
|
||||||
|
return web.json_response(data, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
def ok(data=None):
|
||||||
|
if data is None:
|
||||||
|
data = 1
|
||||||
|
response = {'response': data}
|
||||||
|
return web.json_response(response, dumps=stringify)
|
||||||
|
|
||||||
|
|
||||||
|
# recording methods
|
||||||
|
# -----------------
|
||||||
|
|
||||||
|
@routes.get('/record/')
|
||||||
|
async def do_record(request):
|
||||||
|
duration = int(request.query['duration'])
|
||||||
|
max = Recorder.get_max_record_time()*15
|
||||||
|
if not 0 < duration <= max:
|
||||||
|
raise ValueError(f'invalid duration: max duration is {max}')
|
||||||
|
|
||||||
|
record_id = recorder.record(duration)
|
||||||
|
return ok({'id': record_id})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/record/info/{id}/')
|
||||||
|
async def record_info(request):
|
||||||
|
record_id = int(request.match_info['id'])
|
||||||
|
info = recorder.get_info(record_id)
|
||||||
|
return ok(info.as_dict())
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/record/forget/{id}/')
|
||||||
|
async def record_forget(request):
|
||||||
|
record_id = int(request.match_info['id'])
|
||||||
|
|
||||||
|
info = recorder.get_info(record_id)
|
||||||
|
assert info.status in (RecordStatus.FINISHED, RecordStatus.ERROR), f"can't forget: record status is {info.status}"
|
||||||
|
|
||||||
|
recorder.forget(record_id)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/record/download/{id}/')
|
||||||
|
async def record_download(request):
|
||||||
|
record_id = int(request.match_info['id'])
|
||||||
|
|
||||||
|
info = recorder.get_info(record_id)
|
||||||
|
assert info.status == RecordStatus.FINISHED, f"record status is {info.status}"
|
||||||
|
|
||||||
|
return web.FileResponse(info.file.path)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/storage/list/')
|
||||||
|
async def storage_list(request):
|
||||||
|
extended = 'extended' in request.query and int(request.query['extended']) == 1
|
||||||
|
|
||||||
|
files = storage.getfiles(as_objects=extended)
|
||||||
|
if extended:
|
||||||
|
files = list(map(lambda file: file.__dict__(), files))
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
'files': files
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/storage/delete/')
|
||||||
|
async def storage_delete(request):
|
||||||
|
file_id = request.query['file_id']
|
||||||
|
file = storage.find(file_id)
|
||||||
|
if not file:
|
||||||
|
raise ValueError(f'file {file} not found')
|
||||||
|
|
||||||
|
storage.delete(file)
|
||||||
|
return ok()
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/storage/download/')
|
||||||
|
async def storage_download(request):
|
||||||
|
file_id = request.query['file_id']
|
||||||
|
file = storage.find(file_id)
|
||||||
|
if not file:
|
||||||
|
raise ValueError(f'file {file} not found')
|
||||||
|
|
||||||
|
return web.FileResponse(file.path)
|
||||||
|
|
||||||
|
|
||||||
|
# ALSA mixer methods
|
||||||
|
# ------------------
|
||||||
|
|
||||||
|
def _amixer_control_response(control):
|
||||||
|
info = amixer.get(control)
|
||||||
|
caps = amixer.get_caps(control)
|
||||||
|
return ok({
|
||||||
|
'caps': caps,
|
||||||
|
'info': info
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/amixer/get-all/')
|
||||||
|
async def amixer_get_all(request):
|
||||||
|
controls_info = amixer.get_all()
|
||||||
|
return ok(controls_info)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/amixer/get/{control}/')
|
||||||
|
async def amixer_get(request):
|
||||||
|
control = request.match_info['control']
|
||||||
|
if not amixer.has_control(control):
|
||||||
|
raise ValueError(f'invalid control: {control}')
|
||||||
|
|
||||||
|
return _amixer_control_response(control)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/amixer/{op:mute|unmute|cap|nocap}/{control}/')
|
||||||
|
async def amixer_set(request):
|
||||||
|
op = request.match_info['op']
|
||||||
|
control = request.match_info['control']
|
||||||
|
if not amixer.has_control(control):
|
||||||
|
raise ValueError(f'invalid control: {control}')
|
||||||
|
|
||||||
|
f = getattr(amixer, op)
|
||||||
|
f(control)
|
||||||
|
|
||||||
|
return _amixer_control_response(control)
|
||||||
|
|
||||||
|
|
||||||
|
@routes.get('/amixer/{op:incr|decr}/{control}/')
|
||||||
|
async def amixer_volume(request):
|
||||||
|
op = request.match_info['op']
|
||||||
|
control = request.match_info['control']
|
||||||
|
if not amixer.has_control(control):
|
||||||
|
raise ValueError(f'invalid control: {control}')
|
||||||
|
|
||||||
|
def get_step() -> Optional[int]:
|
||||||
|
if 'step' in request.query:
|
||||||
|
step = int(request.query['step'])
|
||||||
|
if not 1 <= step <= 50:
|
||||||
|
raise ValueError('invalid step value')
|
||||||
|
return step
|
||||||
|
return None
|
||||||
|
|
||||||
|
f = getattr(amixer, op)
|
||||||
|
f(control, step=get_step())
|
||||||
|
|
||||||
|
return _amixer_control_response(control)
|
||||||
|
|
||||||
|
|
||||||
|
# entry point
|
||||||
|
# -----------
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.getegid() == 0:
|
||||||
|
raise RuntimeError("Must be run as root.")
|
||||||
|
|
||||||
|
config.load('sound_node')
|
||||||
|
|
||||||
|
storage = RecordStorage(config['node']['storage'])
|
||||||
|
|
||||||
|
recorder = Recorder(storage=storage)
|
||||||
|
recorder.start_thread()
|
||||||
|
|
||||||
|
# start http server
|
||||||
|
host, port = parse_addr(config['node']['listen'])
|
||||||
|
app = web.Application()
|
||||||
|
app.add_routes(routes)
|
||||||
|
app.middlewares.append(errors_handler_middleware)
|
||||||
|
|
||||||
|
web.run_app(app,
|
||||||
|
host=host,
|
||||||
|
port=port,
|
||||||
|
handle_signals=True)
|
32
src/sound_sensor_node.py
Executable file
32
src/sound_sensor_node.py
Executable file
@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from home.config import config
|
||||||
|
from home.util import parse_addr
|
||||||
|
from home.soundsensor import SoundSensorNode
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
if not os.getegid() == 0:
|
||||||
|
sys.exit('Must be run as root.')
|
||||||
|
|
||||||
|
config.load('sound_sensor_node')
|
||||||
|
|
||||||
|
kwargs = {}
|
||||||
|
if 'delay' in config['node']:
|
||||||
|
kwargs['delay'] = config['node']['delay']
|
||||||
|
|
||||||
|
if 'server_addr' in config['node']:
|
||||||
|
server_addr = parse_addr(config['node']['server_addr'])
|
||||||
|
else:
|
||||||
|
server_addr = None
|
||||||
|
|
||||||
|
node = SoundSensorNode(name=config['node']['name'],
|
||||||
|
pinname=config['node']['pin'],
|
||||||
|
server_addr=server_addr,
|
||||||
|
**kwargs)
|
||||||
|
node.run()
|
178
src/sound_sensor_server.py
Executable file
178
src/sound_sensor_server.py
Executable file
@ -0,0 +1,178 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import os
|
||||||
|
|
||||||
|
from time import sleep
|
||||||
|
from typing import Optional
|
||||||
|
from home.config import config
|
||||||
|
from home.util import parse_addr
|
||||||
|
from home.api import WebAPIClient, RequestParams
|
||||||
|
from home.api.types import SoundSensorLocation
|
||||||
|
from home.soundsensor import SoundSensorServer, SoundSensorHitHandler
|
||||||
|
from home.sound import RecordClient
|
||||||
|
|
||||||
|
interrupted = False
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
server: SoundSensorServer
|
||||||
|
|
||||||
|
|
||||||
|
def get_related_sound_nodes(sensor_name: str) -> list[str]:
|
||||||
|
if sensor_name not in config['sensor_to_sound_nodes_relations']:
|
||||||
|
raise ValueError(f'unexpected sensor name {sensor_name}')
|
||||||
|
return config['sensor_to_sound_nodes_relations'][sensor_name]
|
||||||
|
|
||||||
|
|
||||||
|
def get_sound_node_config(name: str) -> Optional[dict]:
|
||||||
|
if name in config['sound_nodes']:
|
||||||
|
return config['sound_nodes'][name]
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class HitCounter:
|
||||||
|
def __init__(self):
|
||||||
|
self.sensors = {}
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self._reset_sensors()
|
||||||
|
|
||||||
|
def _reset_sensors(self):
|
||||||
|
for loc in SoundSensorLocation:
|
||||||
|
self.sensors[loc.name.lower()] = 0
|
||||||
|
|
||||||
|
def add(self, name: str, hits: int):
|
||||||
|
if name not in self.sensors:
|
||||||
|
raise ValueError(f'sensor {name} not found')
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
self.sensors[name] += hits
|
||||||
|
|
||||||
|
def get_all(self) -> list[tuple[str, int]]:
|
||||||
|
vals = []
|
||||||
|
with self.lock:
|
||||||
|
for name, hits in self.sensors.items():
|
||||||
|
if hits > 0:
|
||||||
|
vals.append((name, hits))
|
||||||
|
self._reset_sensors()
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
class HitHandler(SoundSensorHitHandler):
|
||||||
|
def handler(self, name: str, hits: int):
|
||||||
|
if not hasattr(SoundSensorLocation, name.upper()):
|
||||||
|
logger.error(f'invalid sensor name: {name}')
|
||||||
|
return
|
||||||
|
|
||||||
|
node_config = get_sound_node_config(name)
|
||||||
|
if node_config is None:
|
||||||
|
logger.error(f'config for node {name} not found')
|
||||||
|
return
|
||||||
|
|
||||||
|
min_hits = node_config['min_hits'] if 'min_hits' in node_config else 1
|
||||||
|
if hits < min_hits:
|
||||||
|
return
|
||||||
|
|
||||||
|
hc.add(name, hits)
|
||||||
|
|
||||||
|
if server.is_recording_enabled():
|
||||||
|
try:
|
||||||
|
nodes = get_related_sound_nodes(name)
|
||||||
|
for node in nodes:
|
||||||
|
durations = config['sound_nodes'][node]['durations']
|
||||||
|
dur = durations[1] if hits > min_hits else durations[0]
|
||||||
|
record.record(node, dur*60, {'node': node})
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def hits_sender():
|
||||||
|
while not interrupted:
|
||||||
|
all_hits = hc.get_all()
|
||||||
|
if all_hits:
|
||||||
|
api.add_sound_sensor_hits(all_hits)
|
||||||
|
sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
api: Optional[WebAPIClient] = None
|
||||||
|
hc: Optional[HitCounter] = None
|
||||||
|
record: Optional[RecordClient] = None
|
||||||
|
|
||||||
|
|
||||||
|
# record callbacks
|
||||||
|
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
def record_error(info: dict, userdata: dict):
|
||||||
|
node = userdata['node']
|
||||||
|
logger.error('recording ' + str(dict) + ' from node ' + node + ' failed')
|
||||||
|
|
||||||
|
record.forget(node, info['id'])
|
||||||
|
|
||||||
|
|
||||||
|
def record_finished(info: dict, fn: str, userdata: dict):
|
||||||
|
logger.debug('record finished: ' + str(info))
|
||||||
|
|
||||||
|
# audio could have been requested by other user (telegram bot, for example)
|
||||||
|
# so we shouldn't 'forget' it here
|
||||||
|
|
||||||
|
# node = userdata['node']
|
||||||
|
# record.forget(node, info['id'])
|
||||||
|
|
||||||
|
|
||||||
|
# api client callbacks
|
||||||
|
# --------------------
|
||||||
|
|
||||||
|
def api_error_handler(exc, name, req: RequestParams):
|
||||||
|
if name == 'upload_recording':
|
||||||
|
logger.error('failed to upload recording, exception below')
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(f'api call ({name}, params={req.params}) failed, exception below')
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def api_success_handler(response, name, req: RequestParams):
|
||||||
|
if name == 'upload_recording':
|
||||||
|
node = req.params['node']
|
||||||
|
rid = req.params['record_id']
|
||||||
|
|
||||||
|
logger.debug(f'successfully uploaded recording (node={node}, record_id={rid}), api response:' + str(response))
|
||||||
|
|
||||||
|
# deleting temp file
|
||||||
|
try:
|
||||||
|
os.unlink(req.files['file'])
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(f'error while deleting temp file:')
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
record.forget(node, rid)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('sound_sensor_server')
|
||||||
|
|
||||||
|
hc = HitCounter()
|
||||||
|
api = WebAPIClient(timeout=(10, 60))
|
||||||
|
api.enable_async(error_handler=api_error_handler,
|
||||||
|
success_handler=api_success_handler)
|
||||||
|
|
||||||
|
t = threading.Thread(target=hits_sender)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
nodes = {}
|
||||||
|
for nodename, nodecfg in config['sound_nodes'].items():
|
||||||
|
nodes[nodename] = parse_addr(nodecfg['addr'])
|
||||||
|
|
||||||
|
record = RecordClient(nodes,
|
||||||
|
error_handler=record_error,
|
||||||
|
finished_handler=record_finished)
|
||||||
|
|
||||||
|
try:
|
||||||
|
server = SoundSensorServer(parse_addr(config['server']['listen']), HitHandler)
|
||||||
|
server.run()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
interrupted = True
|
||||||
|
record.stop()
|
||||||
|
logging.info('keyboard interrupt, exiting...')
|
0
src/test/__init__.py
Normal file
0
src/test/__init__.py
Normal file
7
src/test/test.py
Executable file
7
src/test/test.py
Executable file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env python
|
||||||
|
from home.relay import RelayClient
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
c = RelayClient()
|
||||||
|
print(c, c._host)
|
79
src/test/test_amixer.py
Executable file
79
src/test/test_amixer.py
Executable file
@ -0,0 +1,79 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys, os.path
|
||||||
|
sys.path.extend([
|
||||||
|
os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')),
|
||||||
|
])
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from src.home.config import config
|
||||||
|
from src.home.sound import amixer
|
||||||
|
|
||||||
|
|
||||||
|
def validate_control(input: str):
|
||||||
|
for control in config['amixer']['controls']:
|
||||||
|
if control['name'] == input:
|
||||||
|
return
|
||||||
|
raise ValueError(f'invalid control name: {input}')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument('--get-all', action='store_true')
|
||||||
|
parser.add_argument('--mute', type=str)
|
||||||
|
parser.add_argument('--unmute', type=str)
|
||||||
|
parser.add_argument('--cap', type=str)
|
||||||
|
parser.add_argument('--nocap', type=str)
|
||||||
|
parser.add_argument('--get', type=str)
|
||||||
|
parser.add_argument('--incr', type=str)
|
||||||
|
parser.add_argument('--decr', type=str)
|
||||||
|
# parser.add_argument('--dump-config', action='store_true')
|
||||||
|
|
||||||
|
args = config.load('test_amixer', parser=parser)
|
||||||
|
|
||||||
|
# if args.dump_config:
|
||||||
|
# print(config.data)
|
||||||
|
# sys.exit()
|
||||||
|
|
||||||
|
if args.get_all:
|
||||||
|
for control in amixer.get_all():
|
||||||
|
print(f'control = {control["name"]}')
|
||||||
|
for line in control['info'].split('\n'):
|
||||||
|
print(f' {line}')
|
||||||
|
print()
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
if args.get:
|
||||||
|
info = amixer.get(args.get)
|
||||||
|
print(info)
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
for action in ['incr', 'decr']:
|
||||||
|
if hasattr(args, action):
|
||||||
|
control = getattr(args, action)
|
||||||
|
if control is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f'attempting to {action} {control}')
|
||||||
|
validate_control(control)
|
||||||
|
func = getattr(amixer, action)
|
||||||
|
try:
|
||||||
|
func(control, step=5)
|
||||||
|
except amixer.AmixerError as e:
|
||||||
|
print('error: ' + str(e))
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
|
for action in ['mute', 'unmute', 'cap', 'nocap']:
|
||||||
|
if hasattr(args, action):
|
||||||
|
control = getattr(args, action)
|
||||||
|
if control is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"attempting to {action} {control}")
|
||||||
|
|
||||||
|
validate_control(control)
|
||||||
|
func = getattr(amixer, action)
|
||||||
|
try:
|
||||||
|
func(control)
|
||||||
|
except amixer.AmixerError as e:
|
||||||
|
print('error: ' + str(e))
|
||||||
|
sys.exit()
|
11
src/test/test_api.py
Executable file
11
src/test/test_api.py
Executable file
@ -0,0 +1,11 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from home.api import WebAPIClient
|
||||||
|
from home.api.types import BotType
|
||||||
|
from home.config import config
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('test_api')
|
||||||
|
|
||||||
|
api = WebAPIClient()
|
||||||
|
print(api.log_bot_request(BotType.ADMIN, 1, "test_api.py"))
|
376
src/test/test_inverter_monitor.py
Executable file
376
src/test/test_inverter_monitor.py
Executable file
@ -0,0 +1,376 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import cmd
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import socket
|
||||||
|
import sys
|
||||||
|
import threading
|
||||||
|
import os.path
|
||||||
|
sys.path.extend([
|
||||||
|
os.path.realpath(
|
||||||
|
os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
from enum import Enum, auto
|
||||||
|
from typing import Optional
|
||||||
|
from src.home.util import stringify
|
||||||
|
from src.home.config import config
|
||||||
|
from src.home.inverter import (
|
||||||
|
wrapper_instance as inverter,
|
||||||
|
|
||||||
|
InverterMonitor,
|
||||||
|
ChargingEvent,
|
||||||
|
BatteryState,
|
||||||
|
BatteryPowerDirection,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_charging(event: ChargingEvent, **kwargs) -> None:
|
||||||
|
msg = 'event: ' + event.name
|
||||||
|
if event == ChargingEvent.AC_CURRENT_CHANGED:
|
||||||
|
msg += f' (current={kwargs["current"]})'
|
||||||
|
evt_logger.info(msg)
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_battery(state: BatteryState, v: float, load_watts: int) -> None:
|
||||||
|
evt_logger.info(f'bat: {state.name}, v: {v}, load_watts: {load_watts}')
|
||||||
|
|
||||||
|
|
||||||
|
def monitor_error(error: str) -> None:
|
||||||
|
evt_logger.warning('error: ' + error)
|
||||||
|
|
||||||
|
|
||||||
|
class InverterTestShell(cmd.Cmd):
|
||||||
|
intro = 'Welcome to the test shell. Type help or ? to list commands.\n'
|
||||||
|
prompt = '(test) '
|
||||||
|
file = None
|
||||||
|
|
||||||
|
def do_connect_ac(self, arg):
|
||||||
|
server.connect_ac()
|
||||||
|
|
||||||
|
def do_disconnect_ac(self, arg):
|
||||||
|
server.disconnect_ac()
|
||||||
|
|
||||||
|
def do_pd_charge(self, arg):
|
||||||
|
server.set_pd(BatteryPowerDirection.CHARGING)
|
||||||
|
|
||||||
|
def do_pd_nothing(self, arg):
|
||||||
|
server.set_pd(BatteryPowerDirection.DO_NOTHING)
|
||||||
|
|
||||||
|
def do_pd_discharge(self, arg):
|
||||||
|
server.set_pd(BatteryPowerDirection.DISCHARGING)
|
||||||
|
|
||||||
|
|
||||||
|
class ChargerMode(Enum):
|
||||||
|
NONE = auto()
|
||||||
|
CHARGING = auto()
|
||||||
|
|
||||||
|
|
||||||
|
class ChargerEmulator(threading.Thread):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setName('ChargerEmulator')
|
||||||
|
|
||||||
|
self.logger = logging.getLogger('charger')
|
||||||
|
self.interrupted = False
|
||||||
|
self.mode = ChargerMode.NONE
|
||||||
|
|
||||||
|
self.pd = None
|
||||||
|
self.ac_connected = False
|
||||||
|
self.mppt_connected = False
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
while not self.interrupted:
|
||||||
|
if self.pd == BatteryPowerDirection.CHARGING\
|
||||||
|
and self.ac_connected\
|
||||||
|
and not self.mppt_connected:
|
||||||
|
|
||||||
|
v = server._get_voltage() + 0.02
|
||||||
|
self.logger.info('incrementing voltage')
|
||||||
|
server.set_voltage(v)
|
||||||
|
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.interrupted = True
|
||||||
|
|
||||||
|
def setmode(self, mode: ChargerMode):
|
||||||
|
self.mode = mode
|
||||||
|
|
||||||
|
def ac_changed(self, connected: bool):
|
||||||
|
self.ac_connected = connected
|
||||||
|
|
||||||
|
def mppt_changed(self, connected: bool):
|
||||||
|
self.mppt_connected = connected
|
||||||
|
|
||||||
|
def current_changed(self, amps):
|
||||||
|
# FIXME
|
||||||
|
# this method is not being called and voltage is not changing]
|
||||||
|
# when current changes
|
||||||
|
v = None
|
||||||
|
if amps == 2:
|
||||||
|
v = 49
|
||||||
|
elif amps == 10:
|
||||||
|
v = 51
|
||||||
|
elif amps == 20:
|
||||||
|
v = 52.5
|
||||||
|
elif amps == 30:
|
||||||
|
v = 53.5
|
||||||
|
elif amps == 40:
|
||||||
|
v = 54.5
|
||||||
|
if v is not None:
|
||||||
|
self.logger.info(f'setting voltage {v}')
|
||||||
|
server.set_voltage(v)
|
||||||
|
|
||||||
|
def pd_changed(self, pd: BatteryPowerDirection):
|
||||||
|
self.pd = pd
|
||||||
|
|
||||||
|
|
||||||
|
class InverterEmulator(threading.Thread):
|
||||||
|
def __init__(self, host: str, port: int):
|
||||||
|
super().__init__()
|
||||||
|
self.setName('InverterEmulatorServer')
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
self.status = {"grid_voltage": {"unit": "V", "value": 0.0},
|
||||||
|
"grid_freq": {"unit": "Hz", "value": 0.0},
|
||||||
|
"ac_output_voltage": {"unit": "V", "value": 230.0},
|
||||||
|
"ac_output_freq": {"unit": "Hz", "value": 50.0},
|
||||||
|
"ac_output_apparent_power": {"unit": "VA", "value": 92},
|
||||||
|
"ac_output_active_power": {"unit": "Wh", "value": 30},
|
||||||
|
"output_load_percent": {"unit": "%", "value": 1},
|
||||||
|
"battery_voltage": {"unit": "V", "value": 48.4},
|
||||||
|
"battery_voltage_scc": {"unit": "V", "value": 0.0},
|
||||||
|
"battery_voltage_scc2": {"unit": "V", "value": 0.0},
|
||||||
|
"battery_discharging_current": {"unit": "A", "value": 0},
|
||||||
|
"battery_charging_current": {"unit": "A", "value": 0},
|
||||||
|
"battery_capacity": {"unit": "%", "value": 62},
|
||||||
|
"inverter_heat_sink_temp": {"unit": "°C", "value": 8},
|
||||||
|
"mppt1_charger_temp": {"unit": "°C", "value": 0},
|
||||||
|
"mppt2_charger_temp": {"unit": "°C", "value": 0},
|
||||||
|
"pv1_input_power": {"unit": "Wh", "value": 0},
|
||||||
|
"pv2_input_power": {"unit": "Wh", "value": 0},
|
||||||
|
"pv1_input_voltage": {"unit": "V", "value": 0.0},
|
||||||
|
"pv2_input_voltage": {"unit": "V", "value": 0.0},
|
||||||
|
"configuration_status": "Default",
|
||||||
|
"mppt1_charger_status": "Abnormal",
|
||||||
|
"mppt2_charger_status": "Abnormal",
|
||||||
|
"load_connected": "Connected",
|
||||||
|
"battery_power_direction": "Discharge",
|
||||||
|
"dc_ac_power_direction": "DC/AC",
|
||||||
|
"line_power_direction": "Do nothing",
|
||||||
|
"local_parallel_id": 0}
|
||||||
|
self.rated = {"ac_input_rating_voltage": {"unit": "V", "value": 230.0},
|
||||||
|
"ac_input_rating_current": {"unit": "A", "value": 21.7},
|
||||||
|
"ac_output_rating_voltage": {"unit": "V", "value": 230.0},
|
||||||
|
"ac_output_rating_freq": {"unit": "Hz", "value": 50.0},
|
||||||
|
"ac_output_rating_current": {"unit": "A", "value": 21.7},
|
||||||
|
"ac_output_rating_apparent_power": {"unit": "VA", "value": 5000},
|
||||||
|
"ac_output_rating_active_power": {"unit": "Wh", "value": 5000},
|
||||||
|
"battery_rating_voltage": {"unit": "V", "value": 48.0},
|
||||||
|
"battery_recharge_voltage": {"unit": "V", "value": 51.0},
|
||||||
|
"battery_redischarge_voltage": {"unit": "V", "value": 58.0},
|
||||||
|
"battery_under_voltage": {"unit": "V", "value": 42.0},
|
||||||
|
"battery_bulk_voltage": {"unit": "V", "value": 57.6},
|
||||||
|
"battery_float_voltage": {"unit": "V", "value": 54.0},
|
||||||
|
"battery_type": "User",
|
||||||
|
"max_charging_current": {"unit": "A", "value": 60},
|
||||||
|
"max_ac_charging_current": {"unit": "A", "value": 10},
|
||||||
|
"input_voltage_range": "Appliance",
|
||||||
|
"output_source_priority": "Parallel output",
|
||||||
|
"charge_source_priority": "Solar-and-Utility",
|
||||||
|
"parallel_max_num": 6,
|
||||||
|
"machine_type": "Off-Grid-Tie",
|
||||||
|
"topology": "Transformer-less",
|
||||||
|
"output_model_setting": "Single module",
|
||||||
|
"solar_power_priority": "Load-Battery-Utility",
|
||||||
|
"mppt": "2"}
|
||||||
|
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.interrupted = False
|
||||||
|
self.logger = logging.getLogger('srv')
|
||||||
|
|
||||||
|
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||||
|
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
self.sock.bind((self.host, self.port))
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
self.sock.listen(5)
|
||||||
|
|
||||||
|
while not self.interrupted:
|
||||||
|
conn, address = self.sock.accept()
|
||||||
|
|
||||||
|
alive = True
|
||||||
|
while alive:
|
||||||
|
try:
|
||||||
|
buf = conn.recv(2048)
|
||||||
|
message = buf.decode().strip()
|
||||||
|
except OSError as exc:
|
||||||
|
self.logger.error('failed to recv()')
|
||||||
|
self.logger.exception(exc)
|
||||||
|
|
||||||
|
alive = False
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
continue # exit the loop
|
||||||
|
|
||||||
|
self.logger.log(0, f'< {message}')
|
||||||
|
|
||||||
|
if message.strip() == '':
|
||||||
|
continue
|
||||||
|
|
||||||
|
if message == 'format json':
|
||||||
|
# self.logger.info(f'got {message}')
|
||||||
|
self.reply_ok(conn)
|
||||||
|
|
||||||
|
elif message.startswith('exec '):
|
||||||
|
command = message[5:].split()
|
||||||
|
args = command[1:]
|
||||||
|
command = command[0]
|
||||||
|
|
||||||
|
if command == 'get-allowed-ac-charging-currents':
|
||||||
|
self.reply_ok(conn, [2, 10, 20, 30, 40, 50, 60])
|
||||||
|
elif command == 'get-status':
|
||||||
|
self.reply_ok(conn, self._get_status())
|
||||||
|
elif command == 'get-rated':
|
||||||
|
self.reply_ok(conn, self._get_rated())
|
||||||
|
elif command == 'set-max-ac-charging-current':
|
||||||
|
self.set_ac_current(args[1])
|
||||||
|
self.reply_ok(conn, 1)
|
||||||
|
else:
|
||||||
|
raise ValueError('unsupported command: ' + command)
|
||||||
|
else:
|
||||||
|
raise ValueError('unexpected request: ' + message)
|
||||||
|
|
||||||
|
def reply_ok(self, connection, data=None):
|
||||||
|
buf = 'ok' + '\r\n'
|
||||||
|
if data:
|
||||||
|
if not isinstance(data, str):
|
||||||
|
data = stringify({'result': 'ok', 'data': data})
|
||||||
|
buf += data + '\r\n'
|
||||||
|
buf += '\r\n'
|
||||||
|
self.logger.log(0, f'> {buf.strip()}')
|
||||||
|
connection.sendall(buf.encode())
|
||||||
|
|
||||||
|
def _get_status(self) -> dict:
|
||||||
|
with self.lock:
|
||||||
|
return self.status
|
||||||
|
|
||||||
|
def _get_rated(self) -> dict:
|
||||||
|
with self.lock:
|
||||||
|
return self.rated
|
||||||
|
|
||||||
|
def _get_voltage(self) -> float:
|
||||||
|
with self.lock:
|
||||||
|
return self.status['battery_voltage']['value']
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
self.interrupted = True
|
||||||
|
self.sock.close()
|
||||||
|
|
||||||
|
def connect_ac(self):
|
||||||
|
with self.lock:
|
||||||
|
self.status['grid_voltage']['value'] = 230
|
||||||
|
self.status['grid_freq']['value'] = 50
|
||||||
|
charger.ac_changed(True)
|
||||||
|
|
||||||
|
def disconnect_ac(self):
|
||||||
|
with self.lock:
|
||||||
|
self.status['grid_voltage']['value'] = 0
|
||||||
|
self.status['grid_freq']['value'] = 0
|
||||||
|
#self.status['battery_voltage']['value'] = 48.4 # revert to initial value
|
||||||
|
charger.ac_changed(False)
|
||||||
|
|
||||||
|
def connect_mppt(self):
|
||||||
|
with self.lock:
|
||||||
|
self.status['pv1_input_power']['value'] = 1
|
||||||
|
self.status['pv1_input_voltage']['value'] = 50
|
||||||
|
self.status['mppt1_charger_status'] = 'Charging'
|
||||||
|
charger.mppt_changed(True)
|
||||||
|
|
||||||
|
def disconnect_mppt(self):
|
||||||
|
with self.lock:
|
||||||
|
self.status['pv1_input_power']['value'] = 0
|
||||||
|
self.status['pv1_input_voltage']['value'] = 0
|
||||||
|
self.status['mppt1_charger_status'] = 'Abnormal'
|
||||||
|
charger.mppt_changed(False)
|
||||||
|
|
||||||
|
def set_voltage(self, v: float):
|
||||||
|
with self.lock:
|
||||||
|
self.status['battery_voltage']['value'] = v
|
||||||
|
|
||||||
|
def set_ac_current(self, amps):
|
||||||
|
with self.lock:
|
||||||
|
self.rated['max_ac_charging_current']['value'] = amps
|
||||||
|
charger.current_changed(amps)
|
||||||
|
|
||||||
|
def set_pd(self, pd: BatteryPowerDirection):
|
||||||
|
if pd == BatteryPowerDirection.CHARGING:
|
||||||
|
val = 'Charge'
|
||||||
|
elif pd == BatteryPowerDirection.DISCHARGING:
|
||||||
|
val = 'Discharge'
|
||||||
|
else:
|
||||||
|
val = 'Do nothing'
|
||||||
|
with self.lock:
|
||||||
|
self.status['battery_power_direction'] = val
|
||||||
|
charger.pd_changed(pd)
|
||||||
|
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
evt_logger = logging.getLogger('evt')
|
||||||
|
server: Optional[InverterEmulator] = None
|
||||||
|
charger: Optional[ChargerEmulator] = None
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
global server, charger
|
||||||
|
|
||||||
|
# start fake inverterd server
|
||||||
|
try:
|
||||||
|
server = InverterEmulator(host=config['inverter']['host'],
|
||||||
|
port=config['inverter']['port'])
|
||||||
|
server.start()
|
||||||
|
except OSError as e:
|
||||||
|
logger.error('failed to start server')
|
||||||
|
logger.exception(e)
|
||||||
|
return
|
||||||
|
logger.info('server started')
|
||||||
|
|
||||||
|
# start charger thread
|
||||||
|
charger = ChargerEmulator()
|
||||||
|
charger.start()
|
||||||
|
|
||||||
|
# init inverterd wrapper
|
||||||
|
inverter.init(host=config['inverter']['host'],
|
||||||
|
port=config['inverter']['port'])
|
||||||
|
|
||||||
|
# start monitor
|
||||||
|
mon = InverterMonitor()
|
||||||
|
mon.set_charging_event_handler(monitor_charging)
|
||||||
|
mon.set_battery_event_handler(monitor_battery)
|
||||||
|
mon.set_error_handler(monitor_error)
|
||||||
|
mon.start()
|
||||||
|
logger.info('monitor started')
|
||||||
|
|
||||||
|
try:
|
||||||
|
InverterTestShell().cmdloop()
|
||||||
|
|
||||||
|
server.join()
|
||||||
|
mon.join()
|
||||||
|
charger.join()
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
server.stop()
|
||||||
|
mon.stop()
|
||||||
|
charger.stop()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('test_inverter_monitor')
|
||||||
|
main()
|
88
src/test/test_record_upload.py
Executable file
88
src/test/test_record_upload.py
Executable file
@ -0,0 +1,88 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os.path
|
||||||
|
sys.path.extend([
|
||||||
|
os.path.realpath(
|
||||||
|
os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from src.home.api import WebAPIClient, RequestParams
|
||||||
|
from src.home.config import config
|
||||||
|
from src.home.sound import RecordClient
|
||||||
|
from src.home.util import parse_addr
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# record callbacks
|
||||||
|
# ----------------
|
||||||
|
|
||||||
|
def record_error(info: dict, userdata: dict):
|
||||||
|
node = userdata['node']
|
||||||
|
# TODO
|
||||||
|
|
||||||
|
|
||||||
|
def record_finished(info: dict, fn: str, userdata: dict):
|
||||||
|
logger.info('record finished: ' + str(info))
|
||||||
|
|
||||||
|
node = userdata['node']
|
||||||
|
api.upload_recording(fn, node, info['id'], int(info['start_time']), int(info['stop_time']))
|
||||||
|
|
||||||
|
|
||||||
|
# api client callbacks
|
||||||
|
# --------------------
|
||||||
|
|
||||||
|
def api_error_handler(exc, name, req: RequestParams):
|
||||||
|
if name == 'upload_recording':
|
||||||
|
logger.error('failed to upload recording, exception below')
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
else:
|
||||||
|
logger.error(f'api call ({name}, params={req.params}) failed, exception below')
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
|
||||||
|
def api_success_handler(response, name, req: RequestParams):
|
||||||
|
if name == 'upload_recording':
|
||||||
|
node = req.params['node']
|
||||||
|
rid = req.params['record_id']
|
||||||
|
|
||||||
|
logger.debug(f'successfully uploaded recording (node={node}, record_id={rid}), api response:' + str(response))
|
||||||
|
|
||||||
|
# deleting temp file
|
||||||
|
try:
|
||||||
|
os.unlink(req.files['file'])
|
||||||
|
except OSError as exc:
|
||||||
|
logger.error(f'error while deleting temp file:')
|
||||||
|
logger.exception(exc)
|
||||||
|
|
||||||
|
record.forget(node, rid)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('test_record_upload')
|
||||||
|
|
||||||
|
nodes = {}
|
||||||
|
for name, addr in config['nodes'].items():
|
||||||
|
nodes[name] = parse_addr(addr)
|
||||||
|
record = RecordClient(nodes,
|
||||||
|
error_handler=record_error,
|
||||||
|
finished_handler=record_finished,
|
||||||
|
download_on_finish=True)
|
||||||
|
|
||||||
|
api = WebAPIClient()
|
||||||
|
api.enable_async(error_handler=api_error_handler,
|
||||||
|
success_handler=api_success_handler)
|
||||||
|
|
||||||
|
record_id = record.record('localhost', 3, {'node': 'localhost'})
|
||||||
|
print(f'record_id: {record_id}')
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
time.sleep(0.1)
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
break
|
25
src/test/test_send_fake_sound_hit.py
Executable file
25
src/test/test_send_fake_sound_hit.py
Executable file
@ -0,0 +1,25 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import os.path
|
||||||
|
sys.path.extend([
|
||||||
|
os.path.realpath(
|
||||||
|
os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
|
||||||
|
)
|
||||||
|
])
|
||||||
|
|
||||||
|
from argparse import ArgumentParser
|
||||||
|
from src.home.util import send_datagram, stringify, parse_addr
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
parser = ArgumentParser()
|
||||||
|
parser.add_argument('--name', type=str, required=True,
|
||||||
|
help='node name, like `diana`')
|
||||||
|
parser.add_argument('--hits', type=int, required=True,
|
||||||
|
help='hits count')
|
||||||
|
parser.add_argument('--server', type=str, required=True,
|
||||||
|
help='center server addr in host:port format')
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
send_datagram(stringify([args.name, args.hits]), parse_addr(args.server))
|
0
src/test/test_sensors_plot.py
Executable file
0
src/test/test_sensors_plot.py
Executable file
19
src/test/test_sound_node_client.py
Executable file
19
src/test/test_sound_node_client.py
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys, os.path
|
||||||
|
sys.path.extend([
|
||||||
|
os.path.realpath(os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')),
|
||||||
|
])
|
||||||
|
|
||||||
|
from src.home.api.errors import ApiResponseError
|
||||||
|
from src.home.sound import SoundNodeClient
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
client = SoundNodeClient(('127.0.0.1', 8313))
|
||||||
|
print(client.amixer_get_all())
|
||||||
|
|
||||||
|
try:
|
||||||
|
client.amixer_get('invalidname')
|
||||||
|
except ApiResponseError as exc:
|
||||||
|
print(exc)
|
||||||
|
|
66
src/test/test_sound_server_api.py
Executable file
66
src/test/test_sound_server_api.py
Executable file
@ -0,0 +1,66 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
import sys
|
||||||
|
import os.path
|
||||||
|
sys.path.extend([
|
||||||
|
os.path.realpath(
|
||||||
|
os.path.join(os.path.dirname(os.path.join(__file__)), '..', '..')
|
||||||
|
)
|
||||||
|
])
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from time import sleep
|
||||||
|
from src.home.config import config
|
||||||
|
from src.home.api import WebAPIClient
|
||||||
|
from src.home.api.types import SoundSensorLocation
|
||||||
|
|
||||||
|
interrupted = False
|
||||||
|
|
||||||
|
|
||||||
|
class HitCounter:
|
||||||
|
def __init__(self):
|
||||||
|
self.sensors = {}
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self._reset_sensors()
|
||||||
|
|
||||||
|
def _reset_sensors(self):
|
||||||
|
for loc in SoundSensorLocation:
|
||||||
|
self.sensors[loc.name.lower()] = 0
|
||||||
|
|
||||||
|
def add(self, name: str, hits: int):
|
||||||
|
if name not in self.sensors:
|
||||||
|
raise ValueError(f'sensor {name} not found')
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
self.sensors[name] += hits
|
||||||
|
|
||||||
|
def get_all(self) -> list[tuple[str, int]]:
|
||||||
|
vals = []
|
||||||
|
with self.lock:
|
||||||
|
for name, hits in self.sensors.items():
|
||||||
|
if hits > 0:
|
||||||
|
vals.append((name, hits))
|
||||||
|
self._reset_sensors()
|
||||||
|
return vals
|
||||||
|
|
||||||
|
|
||||||
|
def hits_sender():
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
all_hits = hc.get_all()
|
||||||
|
if all_hits:
|
||||||
|
api.add_sound_sensor_hits(all_hits)
|
||||||
|
sleep(5)
|
||||||
|
except (KeyboardInterrupt, SystemExit):
|
||||||
|
return
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
config.load('test_api')
|
||||||
|
|
||||||
|
hc = HitCounter()
|
||||||
|
api = WebAPIClient()
|
||||||
|
|
||||||
|
hc.add('spb1', 1)
|
||||||
|
# hc.add('big_house', 123)
|
||||||
|
|
||||||
|
hits_sender()
|
16
src/test/test_stopwatch.py
Executable file
16
src/test/test_stopwatch.py
Executable file
@ -0,0 +1,16 @@
|
|||||||
|
from home.util import Stopwatch, StopwatchError
|
||||||
|
from time import sleep
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
s = Stopwatch()
|
||||||
|
s.go()
|
||||||
|
sleep(2)
|
||||||
|
s.pause()
|
||||||
|
s.go()
|
||||||
|
sleep(1)
|
||||||
|
print(s.get_elapsed_time())
|
||||||
|
sleep(1)
|
||||||
|
print(s.get_elapsed_time())
|
||||||
|
s.pause()
|
||||||
|
print(s.get_elapsed_time())
|
13
src/web_api.py
Executable file
13
src/web_api.py
Executable file
@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from home.web_api import get_app
|
||||||
|
from typing import Optional
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
|
app: Optional[Flask] = None
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ in ('__main__', 'app'):
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0')
|
8
src/web_api_uwsgi.py
Executable file
8
src/web_api_uwsgi.py
Executable file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from home.web_api import get_app
|
||||||
|
|
||||||
|
app = get_app()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run()
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user