├── sample.py
├── tests
├── __init__.py
├── cbpi-test-config
│ ├── recipes
│ │ └── .empty
│ ├── upload
│ │ └── .empty
│ ├── dashboard
│ │ ├── widgets
│ │ │ └── .empty
│ │ └── cbpi_dashboard_1.json
│ ├── kettle.json
│ ├── fermenter_data.json
│ ├── plugin_list.txt
│ ├── step_data.json
│ ├── splash.png
│ ├── craftbeerpi.service
│ ├── sensor.json
│ ├── chromium.desktop
│ ├── actor.json
│ ├── config.yaml
│ └── craftbeerpiboot
├── test_notification_controller.py
├── test_system.py
├── test_cli.py
├── test_dashboard.py
├── cbpi_config_fixture.py
├── test_gpio.py
├── test_sensor.py
├── test_logger.py
├── test_index.py
├── test_config.py
├── test_step.py
├── test_kettle.py
├── test_ws.py
└── test_actor.py
├── cbpi
├── controller
│ ├── __init__.py
│ ├── sensor_controller.py
│ ├── kettle_controller.py
│ ├── job_controller.py
│ ├── fermenter_recipe_controller.py
│ ├── dashboard_controller.py
│ ├── recipe_controller.py
│ └── config_controller.py
├── extension
│ ├── __init__.py
│ ├── timer
│ │ ├── config.yaml
│ │ └── __init__.py
│ ├── gpioactor
│ │ └── config.yaml
│ ├── mashstep
│ │ └── config.yaml
│ ├── dummyactor
│ │ ├── config.yaml
│ │ └── __init__.py
│ ├── dummysensor
│ │ ├── config.yaml
│ │ └── __init__.py
│ ├── httpsensor
│ │ └── config.yaml
│ ├── mqtt_sensor
│ │ └── config.yaml
│ ├── mqtt_util
│ │ ├── config.yaml
│ │ └── __init__.py
│ ├── onewire
│ │ └── config.yaml
│ ├── hysteresis
│ │ ├── config.yaml
│ │ └── __init__.py
│ ├── mqtt_actor
│ │ ├── config.yaml
│ │ ├── tasmota_mqtt_actor.py
│ │ ├── __init__.py
│ │ ├── generic_mqtt_actor.py
│ │ ├── mqtt_actor.py
│ │ └── output_mqtt_actor.py
│ ├── systemdata
│ │ ├── config.yaml
│ │ └── __init__.py
│ ├── ConfigUpdate
│ │ └── config.yaml
│ ├── FermentationStep
│ │ └── config.yaml
│ ├── FermenterHysteresis
│ │ └── config.yaml
│ ├── SensorLogTarget_CSV
│ │ ├── config.yaml
│ │ └── __init__.py
│ └── SensorLogTarget_InfluxDB
│ │ └── config.yaml
├── http_endpoints
│ ├── __init__.py
│ ├── http_login.py
│ ├── http_notification.py
│ ├── http_plugin.py
│ ├── http_config.py
│ └── http_recipe.py
├── utils
│ ├── __init__.py
│ ├── utils.py
│ └── encoder.py
├── config
│ ├── actor.json
│ ├── kettle.json
│ ├── sensor.json
│ ├── fermenter_data.json
│ ├── splash.png
│ ├── plugin_list.txt
│ ├── step_data.json
│ ├── craftbeerpi.template
│ ├── chromium.desktop
│ ├── config.yaml
│ ├── config.json
│ ├── craftbeerpiboot
│ └── create_database.sql
├── __init__.py
├── static
│ ├── splash.png
│ ├── test.html
│ ├── kettle_icon.svg
│ ├── tank_icon.svg
│ ├── control_icon.svg
│ ├── thermomenter.svg
│ ├── sensor_icon.svg
│ ├── target_temp.svg
│ ├── liquid_icon.svg
│ ├── calculator_icon.svg
│ ├── svg_icon.svg
│ ├── glass_icon.svg
│ ├── grain.svg
│ ├── yeast.svg
│ ├── pipe_icon.svg
│ ├── beer_icon.svg
│ ├── kettle2_icon.svg
│ └── hops_icon.svg
├── api
│ ├── config.py
│ ├── exceptions.py
│ ├── __init__.py
│ ├── kettle_logic.py
│ ├── fermenter_logic.py
│ ├── extension.py
│ ├── timer.py
│ ├── decorator.py
│ ├── actor.py
│ ├── base.py
│ ├── sensor.py
│ └── property.py
├── job
│ ├── __init__.py
│ ├── aiohttp.py
│ ├── _job.py
│ └── _scheduler.py
├── satellite.py
└── websocket.py
├── .devcontainer
├── cbpi-default-dev-config
│ ├── upload
│ │ └── .gitkeep
│ ├── recipes
│ │ └── .gitkeep
│ ├── fermenterrecipes
│ │ └── .gitkeep
│ ├── dashboard
│ │ └── widgets
│ │ │ └── .gitkeep
│ ├── actor.json
│ ├── kettle.json
│ ├── sensor.json
│ ├── fermenter_data.json
│ ├── additional-dev-requirements.txt
│ ├── step_data.json
│ ├── craftbeerpi.service
│ ├── chromium.desktop
│ └── config.yaml
├── createMqttUser.sh
├── mosquitto
│ └── config
│ │ ├── mosquitto.conf
│ │ └── mosquitto.passwd
├── mqtt-explorer
│ └── config
│ │ └── settings.json
├── docker-compose.dev.yml
├── Dockerfile
└── devcontainer.json
├── run.py
├── MANIFEST.in
├── craftbeerpi.service
├── .vscode
├── settings.json
├── tasks.json
└── launch.json
├── cheat_sheet.txt
├── chromium.desktop
├── .gitignore
├── testversion.py
├── requirements.txt
├── .dockerignore
├── release.py
├── craftbeerpi4boot
├── Dockerfile
├── README.md
├── setup.py
└── .github
└── workflows
└── build.yml
/sample.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cbpi/controller/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cbpi/extension/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cbpi/http_endpoints/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/recipes/.empty:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/upload/.empty:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/dashboard/widgets/.empty:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/upload/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/recipes/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/cbpi/utils/__init__.py:
--------------------------------------------------------------------------------
1 | from cbpi.utils.utils import *
2 |
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/fermenterrecipes/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/kettle.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": []
3 | }
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/dashboard/widgets/.gitkeep:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/fermenter_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": []
3 | }
--------------------------------------------------------------------------------
/cbpi/config/actor.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 |
4 | ]
5 | }
--------------------------------------------------------------------------------
/cbpi/config/kettle.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 |
4 | ]
5 | }
--------------------------------------------------------------------------------
/cbpi/config/sensor.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 |
4 | ]
5 | }
--------------------------------------------------------------------------------
/run.py:
--------------------------------------------------------------------------------
1 | from cbpi.cli import main
2 |
3 | main(auto_envvar_prefix='CBPI')
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/actor.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": []
3 | }
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/kettle.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": []
3 | }
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/sensor.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": []
3 | }
--------------------------------------------------------------------------------
/cbpi/__init__.py:
--------------------------------------------------------------------------------
1 | __version__ = "4.7.0"
2 | __codename__ = "Winter Bock"
3 |
--------------------------------------------------------------------------------
/cbpi/config/fermenter_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 |
4 | ]
5 | }
--------------------------------------------------------------------------------
/cbpi/extension/timer/config.yaml:
--------------------------------------------------------------------------------
1 | name: timer
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/fermenter_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": []
3 | }
--------------------------------------------------------------------------------
/cbpi/extension/gpioactor/config.yaml:
--------------------------------------------------------------------------------
1 | name: GPIOActor
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/mashstep/config.yaml:
--------------------------------------------------------------------------------
1 | name: MashStep
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/dummyactor/config.yaml:
--------------------------------------------------------------------------------
1 | name: DummyActor
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/dummysensor/config.yaml:
--------------------------------------------------------------------------------
1 | name: DummySensor
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/httpsensor/config.yaml:
--------------------------------------------------------------------------------
1 | name: DummySensor
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_sensor/config.yaml:
--------------------------------------------------------------------------------
1 | name: DummySensor
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_util/config.yaml:
--------------------------------------------------------------------------------
1 | name: MQTTUtil
2 | version: 4
3 | active: true
4 |
--------------------------------------------------------------------------------
/cbpi/extension/onewire/config.yaml:
--------------------------------------------------------------------------------
1 | name: OneWireSensor
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/hysteresis/config.yaml:
--------------------------------------------------------------------------------
1 | name: DummyKettleLogic
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_actor/config.yaml:
--------------------------------------------------------------------------------
1 | name: MQTTActor
2 | version: 4
3 | active: true
4 |
--------------------------------------------------------------------------------
/cbpi/extension/systemdata/config.yaml:
--------------------------------------------------------------------------------
1 | name: Systemdata
2 | version: 4
3 | active: true
4 |
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/additional-dev-requirements.txt:
--------------------------------------------------------------------------------
1 | cbpi4-SimulatedSensor==0.0.2
--------------------------------------------------------------------------------
/cbpi/extension/ConfigUpdate/config.yaml:
--------------------------------------------------------------------------------
1 | name: ConfigUpdate
2 | version: 4
3 | active: true
4 |
--------------------------------------------------------------------------------
/cbpi/extension/FermentationStep/config.yaml:
--------------------------------------------------------------------------------
1 | name: FermentationStep
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/config/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PiBrewing/craftbeerpi4/HEAD/cbpi/config/splash.png
--------------------------------------------------------------------------------
/cbpi/extension/FermenterHysteresis/config.yaml:
--------------------------------------------------------------------------------
1 | name: FermenterHysteresis
2 | version: 4
3 | active: true
--------------------------------------------------------------------------------
/cbpi/static/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PiBrewing/craftbeerpi4/HEAD/cbpi/static/splash.png
--------------------------------------------------------------------------------
/cbpi/config/plugin_list.txt:
--------------------------------------------------------------------------------
1 | cbpi4-ui:
2 | installation_date: '2021-01-06 16:03:31'
3 | version: '0.0.1'
--------------------------------------------------------------------------------
/cbpi/extension/SensorLogTarget_CSV/config.yaml:
--------------------------------------------------------------------------------
1 | name: SensorLogTargetCSV
2 | version: 4
3 | active: true
4 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | recursive-include cbpi/config *
2 | recursive-include cbpi/extension *
3 | recursive-include cbpi/static *
--------------------------------------------------------------------------------
/cbpi/extension/SensorLogTarget_InfluxDB/config.yaml:
--------------------------------------------------------------------------------
1 | name: SensorLogTargetInfluxDB
2 | version: 4
3 | active: true
4 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/plugin_list.txt:
--------------------------------------------------------------------------------
1 | cbpi4-ui:
2 | installation_date: '2021-01-06 16:03:31'
3 | version: '0.0.1'
--------------------------------------------------------------------------------
/tests/cbpi-test-config/step_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "basic": {
3 | "name": ""
4 | },
5 | "steps": []
6 | }
--------------------------------------------------------------------------------
/cbpi/config/step_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "basic": {
3 | "name": ""
4 | },
5 | "steps": [
6 |
7 | ]
8 | }
--------------------------------------------------------------------------------
/tests/cbpi-test-config/splash.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/PiBrewing/craftbeerpi4/HEAD/tests/cbpi-test-config/splash.png
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/step_data.json:
--------------------------------------------------------------------------------
1 | {
2 | "basic": {
3 | "name": ""
4 | },
5 | "steps": []
6 | }
--------------------------------------------------------------------------------
/tests/cbpi-test-config/dashboard/cbpi_dashboard_1.json:
--------------------------------------------------------------------------------
1 | {
2 | "config": {},
3 | "dbid": 1,
4 | "type": "Test",
5 | "x": 0,
6 | "y": 0
7 | }
--------------------------------------------------------------------------------
/craftbeerpi.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Craftbeer Pi
3 |
4 | [Service]
5 | WorkingDirectory=/home/pi
6 | ExecStart=/usr/local/bin/cbpi start
7 |
8 | [Install]
9 | WantedBy=multi-user.target
10 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/craftbeerpi.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Craftbeer Pi
3 |
4 | [Service]
5 | WorkingDirectory=/home/pi
6 | ExecStart=/usr/local/bin/cbpi start
7 |
8 | [Install]
9 | WantedBy=multi-user.target
10 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "python.pythonPath": "/bin/python3",
3 | "python.testing.pytestArgs": [
4 | "tests"
5 | ],
6 | "python.testing.unittestEnabled": false,
7 | "python.testing.pytestEnabled": true
8 | }
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/craftbeerpi.service:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Craftbeer Pi
3 |
4 | [Service]
5 | WorkingDirectory=/home/pi
6 | ExecStart=/usr/local/bin/cbpi start
7 |
8 | [Install]
9 | WantedBy=multi-user.target
10 |
--------------------------------------------------------------------------------
/.devcontainer/createMqttUser.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 |
3 | USER=craftbeerpi
4 | PASSWORD=craftbeerpi
5 | docker run -it --rm -v "$(pwd)/mosquitto/config/mosquitto.passwd:/opt/passwdfile" eclipse-mosquitto:2 mosquitto_passwd -b /opt/passwdfile $USER $PASSWORD
--------------------------------------------------------------------------------
/cbpi/config/craftbeerpi.template:
--------------------------------------------------------------------------------
1 | [Unit]
2 | Description=Craftbeer Pi
3 |
4 | [Service]
5 | WorkingDirectory=/home/{{ user }}
6 | ExecStart={{ path }} start
7 | KillSignal=SIGKILL
8 | TimeoutStopSec=15
9 |
10 | [Install]
11 | WantedBy=multi-user.target
12 |
--------------------------------------------------------------------------------
/cheat_sheet.txt:
--------------------------------------------------------------------------------
1 | #clean
2 | python3.7 setup.py clean --all
3 |
4 | #build
5 | python3 setup.py sdist
6 |
7 | #Upload
8 | twine upload dist/*
9 |
10 |
11 | # Checkout Pull Request
12 | git fetch origin pull/ID/head:BRANCHNAMEgit checkout BRANCHNAME
13 |
14 |
15 |
--------------------------------------------------------------------------------
/.devcontainer/mosquitto/config/mosquitto.conf:
--------------------------------------------------------------------------------
1 | persistence true
2 | persistence_location /mosquitto/data
3 |
4 | log_dest file /mosquitto/log/mosquitto.log
5 | log_dest stdout
6 |
7 | password_file /mosquitto/config/mosquitto.passwd
8 | allow_anonymous false
9 |
10 | port 1883
11 |
--------------------------------------------------------------------------------
/chromium.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Type=Application
3 | Name=Chromium
4 | Comment=Chromium Webbrowser
5 | NoDisplay=false
6 | Exec=chromium-browser --noerrordialogs --disable-session-crashed-bubble --disable-infobars --force-device-scale-factor=1.00 --start-fullscreen "http://localhost:8000"
7 |
--------------------------------------------------------------------------------
/cbpi/api/config.py:
--------------------------------------------------------------------------------
1 | from enum import Enum
2 |
3 |
4 | class ConfigType(Enum):
5 | STRING = "string"
6 | NUMBER = "number"
7 | SELECT = "select"
8 | KETTLE = "kettle"
9 | ACTOR = "actor"
10 | SENSOR = "sensor"
11 | STEP = "step"
12 | FERMENTER = "fermenter"
13 |
--------------------------------------------------------------------------------
/tests/test_notification_controller.py:
--------------------------------------------------------------------------------
1 | from aiohttp.test_utils import unittest_run_loop
2 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
3 |
4 |
5 | class NotificationTestCase(CraftBeerPiTestCase):
6 |
7 | async def test_actor_switch(self):
8 | self.cbpi.notify("test", "test")
--------------------------------------------------------------------------------
/.devcontainer/mosquitto/config/mosquitto.passwd:
--------------------------------------------------------------------------------
1 | craftbeerpi:$7$101$cRIEIwJ9L/+TAFF1$lxT+v9SisokWaRBgB/Scut7DaotH4RMgzHttYHhwuy6m5yatSoac7bwrkztoQ7raNehBhKt/A4VVejnzozdxXA==
2 | mqtt-explorer:$7$101$SFFKvbIBVXFFAIBp$Pgue6DaAfcuhegjEqtTjf+WWgNZ8geiv1/3fXqmJ0APmd0L80wNTSrEhnFdJmHvi0/vW6V9bVKPJfVRDIjPxCw==
3 |
--------------------------------------------------------------------------------
/cbpi/config/chromium.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Type=Application
3 | Name=Chromium
4 | Comment=Chromium Webbrowser
5 | NoDisplay=false
6 | Exec=chromium-browser --noerrordialogs --disable-session-crashed-bubble --disable-infobars --force-device-scale-factor=1.00 --start-fullscreen "http://localhost:8000"
7 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/sensor.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 | {
4 | "id": "unconfigured_test_sensor_ID",
5 | "name": "unconfigured_mqtt_sensor",
6 | "props": {},
7 | "state": false,
8 | "type": "MQTTSensor"
9 | }
10 | ]
11 | }
--------------------------------------------------------------------------------
/tests/cbpi-test-config/chromium.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Type=Application
3 | Name=Chromium
4 | Comment=Chromium Webbrowser
5 | NoDisplay=false
6 | Exec=chromium-browser --noerrordialogs --disable-session-crashed-bubble --disable-infobars --force-device-scale-factor=1.00 --start-fullscreen "http://localhost:8000"
7 |
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/chromium.desktop:
--------------------------------------------------------------------------------
1 | [Desktop Entry]
2 | Type=Application
3 | Name=Chromium
4 | Comment=Chromium Webbrowser
5 | NoDisplay=false
6 | Exec=chromium-browser --noerrordialogs --disable-session-crashed-bubble --disable-infobars --force-device-scale-factor=1.00 --start-fullscreen "http://localhost:8000"
7 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.pyc
2 | docs_src/build/
3 | build
4 | dist
5 | .idea
6 | *.log
7 | *egg-info/
8 | log
9 | venv
10 | cbpi/extension/ui
11 | node_modules
12 | .DS_STORE
13 | .DS_Store
14 | .vscode
15 | .venv*
16 | .DS_Store
17 | config/*
18 | logs/
19 | .coverage
20 | .devcontainer/cbpi-dev-config/*
21 | cbpi4-*
22 | temp*
23 | *.patch
--------------------------------------------------------------------------------
/cbpi/static/test.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CraftBeerPi 4.0
6 |
7 |
13 |
14 |
15 |
16 |
17 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/actor.json:
--------------------------------------------------------------------------------
1 | {
2 | "data": [
3 | {
4 | "id": "3CUJte4bkxDMFCtLX8eqsX",
5 | "name": "SomeActor",
6 | "output": 100,
7 | "power": 100,
8 | "props": {},
9 | "state": false,
10 | "timer": 0,
11 | "type": "DummyActor"
12 | }
13 | ]
14 | }
--------------------------------------------------------------------------------
/tests/cbpi-test-config/config.yaml:
--------------------------------------------------------------------------------
1 |
2 | name: CraftBeerPi
3 | version: 4.0.8
4 |
5 | index_url: /cbpi_ui/static/index.html
6 |
7 | port: 8000
8 |
9 | mqtt: false
10 | mqtt_host: localhost
11 | mqtt_port: 1883
12 | mqtt_username: ""
13 | mqtt_password: ""
14 |
15 | username: cbpi
16 | password: 123
17 |
18 | plugins:
19 | - cbpi4ui
20 |
21 | mqtt_offset: false
22 |
--------------------------------------------------------------------------------
/.devcontainer/cbpi-default-dev-config/config.yaml:
--------------------------------------------------------------------------------
1 |
2 | name: CraftBeerPi
3 | version: 4.0.8
4 |
5 | index_url: /cbpi_ui/static/index.html
6 |
7 | port: 8000
8 |
9 | mqtt: true
10 | mqtt_host: mqtt
11 | mqtt_port: 1883
12 | mqtt_username: craftbeerpi
13 | mqtt_password: cbpiSuperSecMq77!
14 |
15 | username: cbpi
16 | password: 123
17 |
18 | plugins:
19 | - cbpi4ui
20 |
21 |
--------------------------------------------------------------------------------
/cbpi/config/config.yaml:
--------------------------------------------------------------------------------
1 |
2 | name: CraftBeerPi
3 | version: 4.0.8
4 |
5 | index_url: /cbpi_ui/static/index.html
6 |
7 | port: 8000
8 | debug-log-level: 30
9 | mqtt: false
10 | mqtt_host: localhost
11 | mqtt_port: 1883
12 | mqtt_username: ""
13 | mqtt_password: ""
14 | mqtt_offset: false
15 |
16 | username: cbpi
17 | password: 123
18 |
19 | plugins:
20 | - cbpi4gui
21 |
22 |
--------------------------------------------------------------------------------
/testversion.py:
--------------------------------------------------------------------------------
1 | import json
2 | import sys
3 | from urllib import request
4 | from pkg_resources import parse_version
5 |
6 | def versions(pkg_name):
7 | url = f'https://pypi.python.org/pypi/{pkg_name}/json'
8 | releases = json.loads(request.urlopen(url).read())['releases']
9 | releases = sorted(releases, key=parse_version, reverse=True)
10 | return [releases[0]]
11 |
12 | if __name__ == '__main__':
13 | print(*versions(sys.argv[1]), sep='\n')
--------------------------------------------------------------------------------
/cbpi/utils/utils.py:
--------------------------------------------------------------------------------
1 | from cbpi.utils.encoder import ComplexEncoder
2 |
3 | __all__ = ["load_config", "json_dumps"]
4 |
5 | import json
6 |
7 | import yaml
8 |
9 |
10 | def load_config(fname):
11 |
12 | try:
13 | with open(fname, "rt") as f:
14 | data = yaml.load(f, Loader=yaml.FullLoader)
15 | return data
16 | except Exception as e:
17 |
18 | pass
19 |
20 |
21 | def json_dumps(obj):
22 | return json.dumps(obj, cls=ComplexEncoder)
23 |
--------------------------------------------------------------------------------
/tests/test_system.py:
--------------------------------------------------------------------------------
1 | from aiohttp.test_utils import unittest_run_loop
2 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
3 |
4 |
5 | class IndexTestCase(CraftBeerPiTestCase):
6 |
7 | async def test_endpoints(self):
8 | # Test Index Page
9 | resp = await self.client.post(path="/system/restart")
10 | assert resp.status == 200
11 |
12 | resp = await self.client.post(path="/system/shutdown")
13 | assert resp.status == 200
14 |
15 |
--------------------------------------------------------------------------------
/cbpi/api/exceptions.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "CBPiException",
3 | "KettleException",
4 | "FermenterException",
5 | "SensorException",
6 | "ActorException",
7 | ]
8 |
9 |
10 | class CBPiException(Exception):
11 | pass
12 |
13 |
14 | class KettleException(CBPiException):
15 | pass
16 |
17 |
18 | class FermenterException(CBPiException):
19 | pass
20 |
21 |
22 | class SensorException(CBPiException):
23 | pass
24 |
25 |
26 | class ActorException(CBPiException):
27 | pass
28 |
--------------------------------------------------------------------------------
/tests/test_cli.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import unittest
3 | from cbpi.cli import CraftBeerPiCli
4 |
5 | from cbpi.configFolder import ConfigFolder
6 |
7 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(name)s - %(message)s')
8 |
9 | class CLITest(unittest.TestCase):
10 |
11 | def test_list(self):
12 | cli = CraftBeerPiCli(ConfigFolder("./cbpi-test-config", './logs')) # inside tests folder
13 | cli.plugins_list()
14 |
15 | if __name__ == '__main__':
16 | unittest.main()
--------------------------------------------------------------------------------
/cbpi/static/kettle_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_actor/tasmota_mqtt_actor.py:
--------------------------------------------------------------------------------
1 | from cbpi.api import Property, parameters
2 |
3 | from . import GenericMqttActor
4 |
5 |
6 | @parameters(
7 | [
8 | Property.Text(label="Topic", configurable=True, description="MQTT Topic"),
9 | ]
10 | )
11 | class TasmotaMqttActor(GenericMqttActor):
12 | def __init__(self, cbpi, id, props):
13 | GenericMqttActor.__init__(self, cbpi, id, props)
14 |
15 | async def on_start(self):
16 | await GenericMqttActor.on_start(self)
17 | self.payload = "{switch_onoff}"
18 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | typing-extensions>=4
2 | aiohttp==3.13.2
3 | aiohttp-auth==0.1.1
4 | aiohttp-route-decorator==0.1.4
5 | aiohttp-security==0.5.0
6 | aiohttp-session==2.12.1
7 | aiohttp-swagger==1.0.16
8 | #async-timeout==4.0.3
9 | aiojobs==1.4.0
10 | aiosqlite==0.21.0
11 | cryptography==46.0.3
12 | pyopenssl==25.3.0
13 | requests==2.32.5
14 | voluptuous==0.15.2
15 | pyfiglet==1.0.4
16 | pandas==2.3.3
17 | shortuuid==1.0.13
18 | tabulate==0.9.0
19 | numpy==2.3.5
20 | cbpi4gui
21 | click==8.3.1
22 | importlib_metadata
23 | aiomqtt==2.4.0
24 | psutil==7.1.3
25 | zipp>=0.5
26 | distro>=1.8.0
27 | colorama==0.4.6
28 | pytest-aiohttp
29 | coverage==6.3.1
30 | inquirer==3.4.0
31 | systemd-python
32 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | # Docker
2 | docker-compose.yml
3 | .docker
4 |
5 | # Distribution / packaging
6 | .Python
7 | env/
8 | build/
9 | develop-eggs/
10 | dist/
11 | downloads/
12 | eggs/
13 | lib/
14 | lib64/
15 | parts/
16 | sdist/
17 | var/
18 | *.egg-info/
19 | .installed.cfg
20 | *.egg
21 | **/__pycache__
22 | **/*.py[cod]
23 |
24 | # Installer logs
25 | pip-log.txt
26 | pip-delete-this-directory.txt
27 |
28 | # Unit test / coverage reports
29 | htmlcov/
30 | .tox/
31 | .coverage
32 | .cache
33 | nosetests.xml
34 | coverage.xml
35 |
36 | # Virtual environment
37 | .env/
38 | .venv/
39 | venv/
40 | venv3/
41 |
42 | *.cover
43 | *.log
44 | .git
45 | .mypy_cache
46 | .pytest_cache
47 | .hypothesis
48 | .idea
49 |
50 | **/*.swp
--------------------------------------------------------------------------------
/cbpi/static/tank_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/.vscode/tasks.json:
--------------------------------------------------------------------------------
1 | {
2 | // See https://go.microsoft.com/fwlink/?LinkId=733558
3 | // for the documentation about the tasks.json format
4 | "version": "2.0.0",
5 | "tasks": [
6 | {
7 | "label": "copy default cbpi config files if dev config files dont exist",
8 | "type": "shell",
9 | "command": "cp -ru ${workspaceFolder}/.devcontainer/cbpi-default-dev-config/. ${workspaceFolder}/.devcontainer/cbpi-dev-config",
10 | "windows": {
11 | "command": "echo 'this pre debug task should only be run inside the docker dev container - doing nothing instead'"
12 | },
13 | "group": "build",
14 | "presentation": {
15 | "reveal": "silent",
16 | "panel": "shared"
17 | }
18 | }
19 | ]
20 | }
--------------------------------------------------------------------------------
/cbpi/utils/encoder.py:
--------------------------------------------------------------------------------
1 | import datetime
2 | from json import JSONEncoder
3 |
4 | from pandas import Timestamp
5 |
6 |
7 | class ComplexEncoder(JSONEncoder):
8 |
9 | def default(self, obj):
10 | try:
11 |
12 | if hasattr(obj, "to_json") and callable(getattr(obj, "to_json")):
13 | return obj.to_json()
14 | elif isinstance(obj, datetime.datetime):
15 | return obj.__str__()
16 | elif isinstance(obj, Timestamp):
17 | # print("TIMe")
18 | return obj.__str__()
19 | else:
20 | # print(type(obj))
21 | raise TypeError()
22 | except Exception as e:
23 |
24 | pass
25 | return None
26 |
--------------------------------------------------------------------------------
/.devcontainer/mqtt-explorer/config/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "ConnectionManager_connections": {
3 | "mqtt.eclipse.org": {
4 | "configVersion": 1,
5 | "certValidation": true,
6 | "clientId": "mqtt-explorer-8eb042b9",
7 | "id": "mqtt.eclipse.org",
8 | "name": "CraftBeerPi MQTT Explorer",
9 | "encryption": false,
10 | "subscriptions": [
11 | {
12 | "topic": "#",
13 | "qos": 0
14 | },
15 | {
16 | "topic": "$SYS/#",
17 | "qos": 0
18 | }
19 | ],
20 | "type": "mqtt",
21 | "host": "mqtt",
22 | "port": 1883,
23 | "protocol": "mqtt",
24 | "changeSet": {
25 | "password": "mqtt-explorer"
26 | },
27 | "username": "mqtt-explorer",
28 | "password": "mqtt-explorer"
29 | }
30 | }
31 | }
--------------------------------------------------------------------------------
/cbpi/api/__init__.py:
--------------------------------------------------------------------------------
1 | __all__ = [
2 | "CBPiActor",
3 | "CBPiExtension",
4 | "Property",
5 | "PropertyType",
6 | "on_event",
7 | "on_startup",
8 | "request_mapping",
9 | "action",
10 | "parameters",
11 | "background_task",
12 | "CBPiKettleLogic",
13 | "CBPiFermenterLogic",
14 | "CBPiException",
15 | "KettleException",
16 | "SensorException",
17 | "ActorException",
18 | "CBPiSensor",
19 | "CBPiStep",
20 | "CBPiFermentationStep",
21 | ]
22 |
23 | from cbpi.api.actor import *
24 | from cbpi.api.decorator import *
25 | from cbpi.api.exceptions import *
26 | from cbpi.api.extension import *
27 | from cbpi.api.fermenter_logic import *
28 | from cbpi.api.kettle_logic import *
29 | from cbpi.api.property import *
30 | from cbpi.api.sensor import *
31 | from cbpi.api.step import *
32 |
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_actor/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | from cbpi.api import *
3 |
4 | from .mqtt_actor import MQTTActor
5 | from .generic_mqtt_actor import GenericMqttActor
6 | from .tasmota_mqtt_actor import TasmotaMqttActor
7 | from .output_mqtt_actor import OutputMQTTActor
8 |
9 |
10 |
11 | def setup(cbpi):
12 | """
13 | This method is called by the server during startup
14 | Here you need to register your plugins at the server
15 |
16 | :param cbpi: the cbpi core
17 | :return:
18 | """
19 | if str(cbpi.static_config.get("mqtt", False)).lower() == "true":
20 | cbpi.plugin.register("MQTTActor", MQTTActor)
21 | cbpi.plugin.register("MQTT Actor (Generic)", GenericMqttActor)
22 | cbpi.plugin.register("MQTT Actor (Output)", OutputMQTTActor)
23 | cbpi.plugin.register("MQTT Actor (Tasmota)", TasmotaMqttActor)
24 |
--------------------------------------------------------------------------------
/cbpi/static/control_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/.devcontainer/docker-compose.dev.yml:
--------------------------------------------------------------------------------
1 | version: '3.4'
2 | services:
3 | mqtt:
4 | image: eclipse-mosquitto:2
5 | volumes:
6 | - "./mosquitto/config:/mosquitto/config"
7 | restart: unless-stopped
8 |
9 | craftbeerpi4-development:
10 | build:
11 | context: ../
12 | dockerfile: .devcontainer/Dockerfile
13 | command: /bin/sh -c "while sleep 1000; do :; done"
14 | user: vscode
15 | depends_on:
16 | - mqtt
17 | volumes:
18 | # Update this to wherever you want VS Code to mount the folder of your project
19 | - ../:/workspace:cached
20 |
21 | mqtt-explorer:
22 | image: smeagolworms4/mqtt-explorer
23 | environment:
24 | HTTP_PORT: 4000
25 | CONFIG_PATH: /mqtt-explorer/config
26 | volumes:
27 | - "./mqtt-explorer/config:/mqtt-explorer/config"
28 | depends_on:
29 | - mqtt
30 | restart: unless-stopped
31 |
--------------------------------------------------------------------------------
/tests/test_dashboard.py:
--------------------------------------------------------------------------------
1 | from aiohttp.test_utils import unittest_run_loop
2 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
3 |
4 | from cbpi.craftbeerpi import CraftBeerPi
5 |
6 |
7 | class DashboardTestCase(CraftBeerPiTestCase):
8 |
9 | async def test_crud(self):
10 | data = {
11 | "name": "MyDashboard",
12 |
13 | }
14 |
15 | dashboard_content = {
16 | "type": "Test",
17 | "x": 0,
18 | "y": 0,
19 | "config": {}
20 | }
21 |
22 | resp = await self.client.get(path="/dashboard/current")
23 | assert resp.status == 200
24 |
25 | dashboard_id = await resp.json()
26 |
27 | # Add dashboard content
28 | dashboard_content["dbid"] = dashboard_id
29 | resp = await self.client.post(path="/dashboard/%s/content" % dashboard_id, json=dashboard_content)
30 | assert resp.status == 204
--------------------------------------------------------------------------------
/cbpi/config/config.json:
--------------------------------------------------------------------------------
1 | {
2 | "AUTHOR": {
3 | "description": "Author",
4 | "name": "AUTHOR",
5 | "options": null,
6 | "type": "string",
7 | "source": "craftbeerpi",
8 | "value": "John Doe"
9 | },
10 | "BREWERY_NAME": {
11 | "description": "Brewery Name",
12 | "name": "BREWERY_NAME",
13 | "options": null,
14 | "type": "string",
15 | "source": "craftbeerpi",
16 | "value": "CraftBeerPi Brewery"
17 | },
18 | "TEMP_UNIT": {
19 | "description": "Temperature Unit",
20 | "name": "TEMP_UNIT",
21 | "options": [
22 | {
23 | "label": "C",
24 | "value": "C"
25 | },
26 | {
27 | "label": "F",
28 | "value": "F"
29 | }
30 | ],
31 | "type": "select",
32 | "source": "craftbeerpi",
33 | "value": "C"
34 | }
35 |
36 |
37 | }
38 |
--------------------------------------------------------------------------------
/tests/cbpi_config_fixture.py:
--------------------------------------------------------------------------------
1 | # content of conftest.py
2 | from codecs import ignore_errors
3 | from distutils.command.config import config
4 | import os
5 | from cbpi.configFolder import ConfigFolder
6 | from cbpi.craftbeerpi import CraftBeerPi
7 | from aiohttp.test_utils import AioHTTPTestCase
8 | from distutils.dir_util import copy_tree
9 |
10 |
11 | class CraftBeerPiTestCase(AioHTTPTestCase):
12 |
13 | async def get_application(self):
14 | self.config_folder = self.configuration()
15 | self.cbpi = CraftBeerPi(self.config_folder)
16 | await self.cbpi.init_serivces()
17 | return self.cbpi.app
18 |
19 | def configuration(self):
20 | test_directory = os.path.dirname(__file__)
21 | test_config_directory = os.path.join(test_directory, 'cbpi-test-config')
22 | test_logs_directory = os.path.join(test_directory, 'logs')
23 | configFolder = ConfigFolder(test_config_directory, test_logs_directory)
24 | return configFolder
25 |
--------------------------------------------------------------------------------
/tests/test_gpio.py:
--------------------------------------------------------------------------------
1 | import unittest
2 | from unittest import mock
3 | from unittest.mock import MagicMock, patch
4 |
5 | try:
6 | import RPi.GPIO as GPIO
7 | except Exception:
8 | print("Error importing RPi.GPIO!")
9 | MockRPi = MagicMock()
10 | modules = {
11 | "RPi": MockRPi,
12 | "RPi.GPIO": MockRPi.GPIO
13 | }
14 | patcher = patch.dict("sys.modules", modules)
15 | patcher.start()
16 | import RPi.GPIO as GPIO
17 |
18 | class TestSwitch(unittest.TestCase):
19 |
20 | GPIO_NUM = 22
21 |
22 | @patch("RPi.GPIO.setup")
23 | def test_switch_inits_gpio(self, patched_setup):
24 | GPIO.setmode(GPIO.BCM)
25 | GPIO.setup(self.GPIO_NUM, GPIO.OUT)
26 | patched_setup.assert_called_once_with(self.GPIO_NUM, GPIO.OUT)
27 |
28 | @patch("RPi.GPIO.output")
29 | def test_switch_without_scheduler_starts_disabled(self, patched_output):
30 | GPIO.output(self.GPIO_NUM, GPIO.LOW)
31 | patched_output.assert_called_once_with(self.GPIO_NUM, GPIO.LOW)
--------------------------------------------------------------------------------
/.devcontainer/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM mcr.microsoft.com/vscode/devcontainers/python:3.13-trixie
2 |
3 | RUN apt-get update \
4 | && apt-get upgrade -y
5 | RUN apt-get install --no-install-recommends -y \
6 | libsystemd-dev \
7 | libffi-dev \
8 | python3-pip \
9 | && rm -rf /var/lib/apt/lists/*
10 | RUN python3 -m pip install --no-cache-dir --upgrade pip setuptools wheel
11 |
12 | # Install craftbeerpi requirements and additional-dev-requirements for better caching
13 | COPY ./requirements.txt ./.devcontainer/cbpi-default-dev-config/additional-dev-requirements.txt /workspace/
14 | RUN cat /workspace/additional-dev-requirements.txt 2>/dev/null 1>> /workspace/requirements.txt \
15 | && pip3 install --no-cache-dir -r /workspace/requirements.txt
16 |
17 | # Install current version of cbpi-ui
18 | RUN mkdir /opt/downloads \
19 | && curl https://github.com/PiBrewing/craftbeerpi4-ui/archive/main.zip -L -o /opt/downloads/cbpi-ui.zip \
20 | && pip3 install --no-cache-dir /opt/downloads/cbpi-ui.zip \
21 | && rm -rf /opt/downloads
--------------------------------------------------------------------------------
/cbpi/job/__init__.py:
--------------------------------------------------------------------------------
1 | """Jobs scheduler for managing background task (asyncio).
2 | The library gives controlled way for scheduling background tasks for
3 | asyncio applications.
4 | """
5 |
6 | __version__ = "0.2.2"
7 |
8 | import asyncio
9 |
10 | from ._scheduler import Scheduler
11 |
12 |
13 | async def create_scheduler(
14 | cbpi, *, close_timeout=0.1, limit=100, pending_limit=10000, exception_handler=None
15 | ):
16 | if exception_handler is not None and not callable(exception_handler):
17 | raise TypeError(
18 | "A callable object or None is expected, "
19 | "got {!r}".format(exception_handler)
20 | )
21 | try:
22 | loop = asyncio.get_running_loop()
23 | except RuntimeError:
24 | loop = asyncio.new_event_loop()
25 |
26 | return Scheduler(
27 | cbpi=cbpi,
28 | loop=loop,
29 | close_timeout=close_timeout,
30 | limit=limit,
31 | pending_limit=pending_limit,
32 | exception_handler=exception_handler,
33 | )
34 |
35 |
36 | __all__ = ("create_scheduler",)
37 |
--------------------------------------------------------------------------------
/tests/test_sensor.py:
--------------------------------------------------------------------------------
1 | from aiohttp.test_utils import unittest_run_loop
2 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
3 |
4 |
5 | class SensorTestCase(CraftBeerPiTestCase):
6 |
7 | async def test_crud(self):
8 |
9 | data = {
10 | "name": "CustomSensor",
11 | "type": "CustomSensor",
12 | "config": {
13 | "interval": 1
14 | }
15 | }
16 |
17 | # Add new sensor
18 | resp = await self.client.post(path="/sensor/", json=data)
19 | assert resp.status == 200
20 |
21 | m = await resp.json()
22 | sensor_id = m["id"]
23 |
24 | # Get sensor value
25 | resp = await self.client.get(path="/sensor/%s"% sensor_id)
26 | assert resp.status == 200
27 |
28 | m2 = await resp.json()
29 |
30 | # Update Sensor
31 | resp = await self.client.put(path="/sensor/%s" % sensor_id, json=m)
32 | assert resp.status == 200
33 |
34 | # # Delete Sensor
35 | resp = await self.client.delete(path="/sensor/%s" % sensor_id)
36 | assert resp.status == 204
37 |
--------------------------------------------------------------------------------
/tests/test_logger.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import glob
3 |
4 | from aiohttp.test_utils import unittest_run_loop
5 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
6 | import os
7 |
8 | class LoggerTestCase(CraftBeerPiTestCase):
9 |
10 | async def test_log_data(self):
11 |
12 | os.makedirs(os.path.join(".", "tests", "logs"), exist_ok=True)
13 | log_name = "unconfigured_test_sensor_ID"
14 | #clear all logs
15 | self.cbpi.log.clear_log(log_name)
16 | assert len(glob.glob(os.path.join(self.cbpi.log.logsFolderPath, f"sensor_{log_name}.log*"))) == 0
17 |
18 | # write log entries
19 | for i in range(5):
20 | print(log_name)
21 | self.cbpi.log.log_data(log_name, 222)
22 | await asyncio.sleep(1)
23 |
24 | # read log data
25 | data = await self.cbpi.log.get_data(log_name, sample_rate='1s')
26 | assert len(data["time"]) == 5
27 |
28 | assert self.cbpi.log.zip_log_data(log_name) is not None
29 |
30 | self.cbpi.log.clear_zip(log_name)
31 |
32 | self.cbpi.log.clear_log(log_name)
33 |
34 |
35 |
36 |
--------------------------------------------------------------------------------
/tests/test_index.py:
--------------------------------------------------------------------------------
1 | from aiohttp.test_utils import unittest_run_loop
2 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
3 |
4 |
5 | class IndexTestCase(CraftBeerPiTestCase):
6 |
7 | async def test_index(self):
8 |
9 |
10 | # Test Index Page
11 | resp = await self.client.get(path="/")
12 | assert resp.status == 200
13 |
14 | async def test_404(self):
15 | # Test Index Page
16 | resp = await self.client.get(path="/abc")
17 | assert resp.status == 500
18 |
19 | async def test_wrong_login(self):
20 | resp = await self.client.post(path="/login", data={"username": "beer", "password": "123"})
21 | print("REPONSE STATUS", resp.status)
22 | assert resp.status == 403
23 |
24 | async def test_login(self):
25 |
26 | resp = await self.client.post(path="/login", data={"username": "cbpi", "password": "123"})
27 | print("REPONSE STATUS", resp.status)
28 | assert resp.status == 200
29 |
30 | resp = await self.client.get(path="/logout")
31 | print("REPONSE STATUS LGOUT", resp.status)
32 | assert resp.status == 200
33 |
--------------------------------------------------------------------------------
/tests/test_config.py:
--------------------------------------------------------------------------------
1 | import time
2 |
3 | from aiohttp.test_utils import unittest_run_loop
4 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
5 |
6 | class ConfigTestCase(CraftBeerPiTestCase):
7 |
8 | async def test_get(self):
9 |
10 | assert self.cbpi.config.get("steps_boil_temp", 1) == "99"
11 |
12 | async def test_set_get(self):
13 | value = 35
14 |
15 | await self.cbpi.config.set("steps_cooldown_temp", value)
16 | assert self.cbpi.config.get("steps_cooldown_temp", 1) == value
17 |
18 | async def test_http_set(self):
19 | value = "Some New Brewery Name"
20 | key = "BREWERY_NAME"
21 |
22 | resp = await self.client.request("PUT", "/config/%s/" % key, json={'value': value})
23 | assert resp.status == 204
24 |
25 | assert self.cbpi.config.get(key, -1) == value
26 |
27 | async def test_http_get(self):
28 | resp = await self.client.request("GET", "/config/")
29 | assert resp.status == 200
30 |
31 | async def test_get_default(self):
32 | value = self.cbpi.config.get("HELLO_WORLD", "DefaultValue")
33 | assert value == "DefaultValue"
--------------------------------------------------------------------------------
/cbpi/api/kettle_logic.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from abc import ABCMeta
4 |
5 | from cbpi.api.base import CBPiBase
6 | from cbpi.api.extension import CBPiExtension
7 |
8 |
9 | class CBPiKettleLogic(CBPiBase, metaclass=ABCMeta):
10 |
11 | def __init__(self, cbpi, id, props):
12 | self.cbpi = cbpi
13 | self.id = id
14 | self.props = props
15 | self.state = False
16 | self.running = False
17 |
18 | def init(self):
19 | pass
20 |
21 | async def on_start(self):
22 | pass
23 |
24 | async def on_stop(self):
25 | pass
26 |
27 | async def run(self):
28 | pass
29 |
30 | async def _run(self):
31 |
32 | try:
33 | await self.on_start()
34 | self.cancel_reason = await self.run()
35 | except asyncio.CancelledError as e:
36 | pass
37 | finally:
38 | await self.on_stop()
39 |
40 | def get_state(self):
41 | return dict(running=self.state)
42 |
43 | async def start(self):
44 |
45 | self.state = True
46 |
47 | async def stop(self):
48 |
49 | self.task.cancel()
50 | await self.task
51 | self.state = False
52 |
--------------------------------------------------------------------------------
/cbpi/api/fermenter_logic.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from abc import ABCMeta
4 |
5 | from cbpi.api.base import CBPiBase
6 | from cbpi.api.extension import CBPiExtension
7 |
8 |
9 | class CBPiFermenterLogic(CBPiBase, metaclass=ABCMeta):
10 |
11 | def __init__(self, cbpi, id, props):
12 | self.cbpi = cbpi
13 | self.id = id
14 | self.props = props
15 | self.state = False
16 | self.running = False
17 |
18 | def init(self):
19 | pass
20 |
21 | async def on_start(self):
22 | pass
23 |
24 | async def on_stop(self):
25 | pass
26 |
27 | async def run(self):
28 | pass
29 |
30 | async def _run(self):
31 |
32 | try:
33 | await self.on_start()
34 | self.cancel_reason = await self.run()
35 | except asyncio.CancelledError as e:
36 | pass
37 | finally:
38 | await self.on_stop()
39 |
40 | def get_state(self):
41 | return dict(running=self.state)
42 |
43 | async def start(self):
44 |
45 | self.state = True
46 |
47 | async def stop(self):
48 |
49 | self.task.cancel()
50 | await self.task
51 | self.state = False
52 |
--------------------------------------------------------------------------------
/cbpi/http_endpoints/http_login.py:
--------------------------------------------------------------------------------
1 | from aiohttp import web
2 | from aiohttp_auth import auth
3 | from cbpi.api import *
4 |
5 |
6 | class Login:
7 |
8 | def __init__(self, cbpi):
9 | self.cbpi = cbpi
10 | self.cbpi.register(self, url_prefix="/")
11 | self.db = {
12 | cbpi.static_config.get("username", "cbpi"): cbpi.static_config.get(
13 | "password", "cbpi"
14 | )
15 | }
16 |
17 | @request_mapping(path="/logout", name="Logout", method="GET", auth_required=True)
18 | async def logout_view(self, request):
19 | await auth.forget(request)
20 | return web.Response(body="OK".encode("utf-8"))
21 |
22 | @request_mapping(path="/login", name="Login", method="POST", auth_required=False)
23 | async def login_view(self, request):
24 |
25 | params = await request.post()
26 |
27 | user = params.get("username", None)
28 |
29 | if user in self.db and params.get("password", None) == str(self.db[user]):
30 | # User is in our database, remember their login details
31 | await auth.remember(request, user)
32 | return web.Response(body="OK".encode("utf-8"))
33 |
34 | raise web.HTTPForbidden()
35 |
--------------------------------------------------------------------------------
/cbpi/controller/sensor_controller.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from cbpi.api.dataclasses import Sensor
4 | from cbpi.controller.basic_controller2 import BasicController
5 |
6 |
7 | class SensorController(BasicController):
8 | def __init__(self, cbpi):
9 | super(SensorController, self).__init__(cbpi, Sensor, "sensor.json")
10 | self.update_key = "sensorupdate"
11 | self.sorting = True
12 |
13 | def create_dict(self, data):
14 | try:
15 | instance = data.get("instance")
16 | state = instance.get_state()
17 | except Exception as e:
18 | logging.error("Failed to create sensor dict {} ".format(e))
19 | state = dict()
20 |
21 | return dict(
22 | name=data.get("name"),
23 | id=data.get("id"),
24 | type=data.get("type"),
25 | state=state,
26 | props=data.get("props", []),
27 | )
28 |
29 | def get_sensor_value(self, id):
30 | if id is None:
31 | return None
32 | try:
33 | return self.find_by_id(id).instance.get_state()
34 | except Exception as e:
35 | logging.error("Failed read sensor value {} {} ".format(id, e))
36 | return None
37 |
--------------------------------------------------------------------------------
/cbpi/api/extension.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import os
3 | import sys
4 |
5 | import yaml
6 |
7 | __all__ = ["CBPiExtension"]
8 |
9 |
10 | logger = logging.getLogger(__file__)
11 |
12 |
13 | class CBPiExtension:
14 |
15 | def init(self):
16 | pass
17 |
18 | def stop(self):
19 | pass
20 |
21 | def __init__(self, *args, **kwds):
22 |
23 | for a in kwds:
24 | logger.debug("Parameter: %s Value: %s" % (a, kwds.get(a)))
25 | super(CBPiExtension, self).__setattr__(a, kwds.get(a))
26 | self.cbpi = kwds.get("cbpi")
27 | self.id = kwds.get("id")
28 | self.value = None
29 | self.__dirty = False
30 |
31 | def __setattr__(self, name, value):
32 |
33 | if name != "_CBPiExtension__dirty":
34 | self.__dirty = True
35 | super(CBPiExtension, self).__setattr__(name, value)
36 | else:
37 | super(CBPiExtension, self).__setattr__(name, value)
38 |
39 | def load_config(self):
40 |
41 | path = os.path.dirname(sys.modules[self.__class__.__module__].__file__)
42 |
43 | try:
44 | with open("%s/config.yaml" % path, "rt") as f:
45 | data = yaml.load(f)
46 |
47 | return data
48 | except:
49 | logger.warning("Failed to load config %s/config.yaml" % path)
50 |
--------------------------------------------------------------------------------
/cbpi/static/thermomenter.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
--------------------------------------------------------------------------------
/cbpi/job/aiohttp.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from aiohttp.web import View
4 |
5 | from . import create_scheduler
6 |
7 | __all__ = ("setup", "spawn", "get_scheduler", "get_scheduler_from_app", "atomic")
8 |
9 |
10 | def get_scheduler(request):
11 | scheduler = get_scheduler_from_request(request)
12 | if scheduler is None:
13 | raise RuntimeError("Call aiojobs.aiohttp.setup() on application initialization")
14 | return scheduler
15 |
16 |
17 | def get_scheduler_from_app(app):
18 | return app.get("AIOJOBS_SCHEDULER")
19 |
20 |
21 | def get_scheduler_from_request(request):
22 | return request.config_dict.get("AIOJOBS_SCHEDULER")
23 |
24 |
25 | async def spawn(request, coro, name="Manuel"):
26 | return await get_scheduler(request).spawn(coro)
27 |
28 |
29 | def atomic(coro):
30 | @wraps(coro)
31 | async def wrapper(request):
32 | if isinstance(request, View):
33 | # Class Based View decorated.
34 | request = request.request
35 |
36 | job = await spawn(request, coro(request))
37 | return await job.wait()
38 |
39 | return wrapper
40 |
41 |
42 | async def setup(app, cbpi, **kwargs):
43 |
44 | app["AIOJOBS_SCHEDULER"] = await create_scheduler(cbpi, **kwargs)
45 |
46 | async def on_cleanup(app):
47 | await app["AIOJOBS_SCHEDULER"].close()
48 |
49 | app.on_cleanup.append(on_cleanup)
50 |
--------------------------------------------------------------------------------
/cbpi/extension/dummyactor/__init__.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from datetime import datetime
3 | from socket import timeout
4 | from typing import KeysView
5 | from unittest.mock import MagicMock, patch
6 |
7 | from cbpi.api import *
8 | from cbpi.api.dataclasses import NotificationAction, NotificationType
9 | from voluptuous.schema_builder import message
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | @parameters([])
15 | class DummyActor(CBPiActor):
16 |
17 | def __init__(self, cbpi, id, props):
18 | super().__init__(cbpi, id, props)
19 |
20 | @action("SAY HELLO", {})
21 | async def helloWorld(self, **kwargs):
22 | self.cbpi.notify("HELLO", "WOOHO", NotificationType.ERROR)
23 |
24 | async def start(self):
25 | await super().start()
26 |
27 | async def on(self, power=0, output=0):
28 | logger.info("ACTOR %s ON " % self.id)
29 | self.state = True
30 |
31 | async def off(self):
32 | logger.info("ACTOR %s OFF " % self.id)
33 |
34 | self.state = False
35 |
36 | def get_state(self):
37 |
38 | return self.state
39 |
40 | async def run(self):
41 | pass
42 |
43 |
44 | def setup(cbpi):
45 | """
46 | This method is called by the server during startup
47 | Here you need to register your plugins at the server
48 |
49 | :param cbpi: the cbpi core
50 | :return:
51 | """
52 |
53 | cbpi.plugin.register("DummyActor", DummyActor)
54 |
--------------------------------------------------------------------------------
/tests/test_step.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from aiohttp.test_utils import unittest_run_loop
3 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
4 |
5 | class StepTestCase(CraftBeerPiTestCase):
6 |
7 | async def test_get(self):
8 |
9 | resp = await self.client.request("GET", "/step2")
10 | print(resp)
11 | assert resp.status == 200
12 |
13 |
14 | async def test_crud(self):
15 | data = {
16 | "name": "Test",
17 | "type": "CustomStepCBPi",
18 | "config": {}
19 | }
20 |
21 | # Add new step
22 | resp = await self.client.post(path="/step2/", json=data)
23 | assert resp.status == 200
24 |
25 | m = await resp.json()
26 | print("Step", m)
27 | sensor_id = m["id"]
28 |
29 | # Update step
30 | resp = await self.client.put(path="/step2/%s" % sensor_id, json=m)
31 | assert resp.status == 200
32 |
33 | # # Delete step
34 | resp = await self.client.delete(path="/step2/%s" % sensor_id)
35 | assert resp.status == 204
36 |
37 | def create_wait_callback(self, topic):
38 | future = self.cbpi.app.loop.create_future()
39 |
40 | async def test(**kwargs):
41 | print("GOON")
42 | future.set_result("OK")
43 | self.cbpi.bus.register(topic, test, once=True)
44 | return future
45 |
46 | async def wait(self, future):
47 | done, pending = await asyncio.wait({future})
48 |
49 | if future in done:
50 | pass
--------------------------------------------------------------------------------
/.vscode/launch.json:
--------------------------------------------------------------------------------
1 | {
2 | // Verwendet IntelliSense zum Ermitteln möglicher Attribute.
3 | // Zeigen Sie auf vorhandene Attribute, um die zugehörigen Beschreibungen anzuzeigen.
4 | // Weitere Informationen finden Sie unter https://go.microsoft.com/fwlink/?linkid=830387
5 | "version": "0.2.0",
6 | "configurations": [
7 |
8 | {
9 | "name": "run CraftBeerPi4",
10 | "type": "debugpy",
11 | "request": "launch",
12 | "module": "run",
13 | "args": [
14 | "--config-folder-path=./.devcontainer/cbpi-dev-config",
15 | "--debug-log-level=20",
16 | "start"
17 | ],
18 | "preLaunchTask": "copy default cbpi config files if dev config files dont exist"
19 | },
20 |
21 | {
22 | "name": "create CraftBeerPi4 plugin",
23 | "type": "debugpy",
24 | "request": "launch",
25 | "module": "run",
26 | "args": ["--config-folder-path=./.devcontainer/cbpi-dev-config", "create"]
27 | },
28 |
29 | {
30 | "name": "setup CraftBeerPi4: create config folder structure",
31 | "type": "debugpy",
32 | "request": "launch",
33 | "module": "run",
34 | "args": ["--config-folder-path=./.devcontainer/cbpi-dev-config", "setup"]
35 | },
36 |
37 | {
38 | "name": "run tests",
39 | "type": "debugpy",
40 | "request": "launch",
41 | "module": "pytest",
42 | "args": ["tests"]
43 | }
44 | ]
45 | }
--------------------------------------------------------------------------------
/tests/test_kettle.py:
--------------------------------------------------------------------------------
1 | from aiohttp.test_utils import unittest_run_loop
2 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
3 |
4 |
5 | class KettleTestCase(CraftBeerPiTestCase):
6 |
7 | async def test_get(self):
8 |
9 | resp = await self.client.request("GET", "/kettle")
10 | assert resp.status == 200
11 | kettle = await resp.json()
12 | assert kettle != None
13 |
14 | async def test_crud(self):
15 | data = {
16 | "name": "Test",
17 | "sensor": None,
18 | "heater": "1",
19 | "automatic": None,
20 | "logic": "CustomKettleLogic",
21 | "config": {
22 | "test": "WOOHO"
23 | },
24 | "agitator": None,
25 | "target_temp": None
26 | }
27 |
28 | # Add new sensor
29 | resp = await self.client.post(path="/kettle/", json=data)
30 | assert resp.status == 200
31 |
32 | m = await resp.json()
33 |
34 | kettle_id = m["id"]
35 | print("KETTLE", m["id"], m)
36 |
37 | # Update Kettle
38 | resp = await self.client.put(path="/kettle/%s" % kettle_id, json=m)
39 | assert resp.status == 200
40 |
41 | # Set Kettle target temp
42 | resp = await self.client.post(path="/kettle/%s/target_temp" % kettle_id, json={"temp":75})
43 | assert resp.status == 204
44 |
45 | # # Delete Sensor
46 | resp = await self.client.delete(path="/kettle/%s" % kettle_id)
47 | assert resp.status == 204
48 |
49 |
50 |
51 |
52 |
--------------------------------------------------------------------------------
/cbpi/static/sensor_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
16 |
--------------------------------------------------------------------------------
/cbpi/extension/systemdata/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | import psutil
4 |
5 | from cbpi.api import *
6 | from cbpi.api.base import CBPiBase
7 | from cbpi.api.config import ConfigType
8 | from cbpi.controller.fermentation_controller import FermentationController
9 | from cbpi.controller.kettle_controller import KettleController
10 |
11 | logger = logging.getLogger(__name__)
12 |
13 |
14 | class Systemdata(CBPiExtension):
15 |
16 | def __init__(self, cbpi):
17 | self.cbpi = cbpi
18 | self.update_key = "systemupdate"
19 | self.sorting = False
20 | self._task = asyncio.create_task(self.run())
21 | logger.error("INIT Systemdata Extension")
22 |
23 | async def run(self):
24 | while True:
25 | mem = psutil.virtual_memory()
26 | totalmem=round((int(mem.total) / (1024 * 1024)), 1)
27 | availablemem=round((int(mem.available) / (1024 * 1024)), 1)
28 | percentmem=round(float(mem.percent), 1)
29 | # if availablemem < 200:
30 | #logger.error("Low Memory: {} MB".format(availablemem))
31 | self.cbpi.ws.send(
32 | dict(
33 | topic=self.update_key,
34 | data=dict(
35 | totalmem=totalmem,
36 | availablemem=availablemem,
37 | percentmem=percentmem,
38 | )
39 | ), self.sorting)
40 | # logging.error("Systemdata: Total Memory: {} MB, Available Memory: {} MB, Used Memory: {}%".format(totalmem, availablemem, percentmem))
41 | await asyncio.sleep(300)
42 |
43 |
44 |
45 |
46 |
47 | def setup(cbpi):
48 | cbpi.plugin.register("Systemdata", Systemdata)
49 | pass
50 |
--------------------------------------------------------------------------------
/cbpi/http_endpoints/http_notification.py:
--------------------------------------------------------------------------------
1 | from aiohttp import web
2 | from cbpi.api import request_mapping
3 | from cbpi.utils import json_dumps
4 |
5 |
6 | class NotificationHttpEndpoints:
7 |
8 | def __init__(self, cbpi):
9 | self.cbpi = cbpi
10 | self.cbpi.register(self, url_prefix="/notification")
11 |
12 | @request_mapping(
13 | path="/{id}/action/{action_id}", method="POST", auth_required=False
14 | )
15 | async def action(self, request):
16 | """
17 | ---
18 | description: Update an actor
19 | tags:
20 | - Notification
21 | parameters:
22 | - name: "id"
23 | in: "path"
24 | description: "Notification Id"
25 | required: true
26 | type: "string"
27 | - name: "action_id"
28 | in: "path"
29 | description: "Action Id"
30 | required: true
31 | type: "string"
32 |
33 | responses:
34 | "200":
35 | description: successful operation
36 | """
37 |
38 | notification_id = request.match_info["id"]
39 | action_id = request.match_info["action_id"]
40 | # print(notification_id, action_id)
41 | self.cbpi.notification.notify_callback(notification_id, action_id)
42 | return web.Response(status=200)
43 |
44 | @request_mapping("/delete", method="POST", auth_required=False)
45 | async def restart(self, request):
46 | """
47 | ---
48 | description: DeleteNotifications
49 | tags:
50 | - Notification
51 | responses:
52 | "200":
53 | description: successful operation
54 | """
55 | self.cbpi.notification.delete_all_notifications()
56 | return web.Response(status=200)
57 |
--------------------------------------------------------------------------------
/cbpi/static/target_temp.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/config/craftbeerpiboot:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ### BEGIN INIT INFO
4 | # Provides: craftbeerpi
5 | # Required-Start: $remote_fs $syslog
6 | # Required-Stop: $remote_fs $syslog
7 | # Default-Start: 2 3 4 5
8 | # Default-Stop: 0 1 6
9 | # Short-Description: Put a short description of the service here
10 | # Description: Put a long description of the service here
11 | ### END INIT INFO
12 |
13 | # Change the next 3 lines to suit where you install your script and what you want to call it
14 | DIR=#DIR#
15 | DAEMON=$DIR/cbpi
16 | DAEMON_NAME=CraftBeerPI
17 |
18 | # Add any command line options for your daemon here
19 | DAEMON_OPTS=""
20 |
21 | # This next line determines what user the script runs as.
22 | # Root generally not recommended but necessary if you are using the Raspberry Pi GPIO from Python.
23 | DAEMON_USER=root
24 |
25 | # The process ID of the script when it runs is stored here:
26 | PIDFILE=/var/run/$DAEMON_NAME.pid
27 |
28 | . /lib/lsb/init-functions
29 |
30 | do_start () {
31 | log_daemon_msg "Starting system $DAEMON_NAME daemon"
32 | start-stop-daemon --start --background --pidfile $PIDFILE --make-pidfile --user $DAEMON_USER --chuid $DAEMON_USER --chdir $DIR --startas $DAEMON -- $DAEMON_OPTS
33 | log_end_msg $?
34 | }
35 | do_stop () {
36 | log_daemon_msg "Stopping system $DAEMON_NAME daemon"
37 | start-stop-daemon --stop --pidfile $PIDFILE --retry 10
38 | log_end_msg $?
39 | }
40 |
41 | case "$1" in
42 |
43 | start|stop)
44 | do_${1}
45 | ;;
46 |
47 | restart|reload|force-reload)
48 | do_stop
49 | do_start
50 | ;;
51 |
52 | status)
53 | status_of_proc "$DAEMON_NAME" "$DAEMON" && exit 0 || exit $?
54 | ;;
55 |
56 | *)
57 | echo "Usage: /etc/init.d/$DAEMON_NAME {start|stop|restart|status}"
58 | exit 1
59 | ;;
60 |
61 | esac
62 | exit 0
--------------------------------------------------------------------------------
/release.py:
--------------------------------------------------------------------------------
1 | import code
2 | import subprocess
3 | import click
4 | import re
5 |
6 | @click.group()
7 | def main():
8 | pass
9 |
10 | @click.command()
11 | @click.option('-m', prompt='Commit Message')
12 | def commit(m):
13 |
14 | new_content = []
15 | file = "./cbpi/__init__.py"
16 | with open(file) as reader:
17 | match = re.search('.*\"(.*)\"', reader.readline())
18 | codename = reader.readline()
19 | try:
20 | major, minor, patch, build = match.group(1).split(".")
21 | except:
22 | major, minor, patch = match.group(1).split(".")
23 | patch = int(patch)
24 | patch += 1
25 | new_content.append("__version__ = \"{}.{}.{}\"".format(major,minor,patch))
26 | new_content.append(codename)
27 | with open(file,'w',encoding = 'utf-8') as file:
28 | print("New Version {}.{}.{}".format(major,minor,patch))
29 | file.writelines("%s\n" % i for i in new_content)
30 |
31 | subprocess.run(["git", "add", "-A"])
32 | subprocess.run(["git", "commit", "-m", "\"{}\"".format(m)])
33 | subprocess.run(["git", "push"])
34 |
35 |
36 | @click.command()
37 | def build():
38 | subprocess.run(["python3", "setup.py", "sdist"])
39 |
40 |
41 |
42 | @click.command()
43 | def release():
44 | subprocess.run(["python3", "setup.py", "sdist"])
45 | file = "./cbpi/__init__.py"
46 | with open(file) as reader:
47 | match = re.search('.*\"(.*)\"', reader.readline())
48 | version = match.group(1)
49 |
50 | path = "dist/cbpi4-{}.tar.gz".format(version)
51 | print("Uploading File {} ".format(path))
52 | subprocess.run(["twine", "upload", path])
53 |
54 |
55 | main.add_command(commit)
56 | main.add_command(release)
57 | main.add_command(build)
58 |
59 | if __name__ == '__main__':
60 | main()
61 |
--------------------------------------------------------------------------------
/tests/cbpi-test-config/craftbeerpiboot:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ### BEGIN INIT INFO
4 | # Provides: craftbeerpi
5 | # Required-Start: $remote_fs $syslog
6 | # Required-Stop: $remote_fs $syslog
7 | # Default-Start: 2 3 4 5
8 | # Default-Stop: 0 1 6
9 | # Short-Description: Put a short description of the service here
10 | # Description: Put a long description of the service here
11 | ### END INIT INFO
12 |
13 | # Change the next 3 lines to suit where you install your script and what you want to call it
14 | DIR=#DIR#
15 | DAEMON=$DIR/cbpi
16 | DAEMON_NAME=CraftBeerPI
17 |
18 | # Add any command line options for your daemon here
19 | DAEMON_OPTS=""
20 |
21 | # This next line determines what user the script runs as.
22 | # Root generally not recommended but necessary if you are using the Raspberry Pi GPIO from Python.
23 | DAEMON_USER=root
24 |
25 | # The process ID of the script when it runs is stored here:
26 | PIDFILE=/var/run/$DAEMON_NAME.pid
27 |
28 | . /lib/lsb/init-functions
29 |
30 | do_start () {
31 | log_daemon_msg "Starting system $DAEMON_NAME daemon"
32 | start-stop-daemon --start --background --pidfile $PIDFILE --make-pidfile --user $DAEMON_USER --chuid $DAEMON_USER --chdir $DIR --startas $DAEMON -- $DAEMON_OPTS
33 | log_end_msg $?
34 | }
35 | do_stop () {
36 | log_daemon_msg "Stopping system $DAEMON_NAME daemon"
37 | start-stop-daemon --stop --pidfile $PIDFILE --retry 10
38 | log_end_msg $?
39 | }
40 |
41 | case "$1" in
42 |
43 | start|stop)
44 | do_${1}
45 | ;;
46 |
47 | restart|reload|force-reload)
48 | do_stop
49 | do_start
50 | ;;
51 |
52 | status)
53 | status_of_proc "$DAEMON_NAME" "$DAEMON" && exit 0 || exit $?
54 | ;;
55 |
56 | *)
57 | echo "Usage: /etc/init.d/$DAEMON_NAME {start|stop|restart|status}"
58 | exit 1
59 | ;;
60 |
61 | esac
62 | exit 0
--------------------------------------------------------------------------------
/craftbeerpi4boot:
--------------------------------------------------------------------------------
1 | #!/bin/sh
2 |
3 | ### BEGIN INIT INFO
4 | # Provides: craftbeerpi
5 | # Required-Start: $remote_fs $syslog
6 | # Required-Stop: $remote_fs $syslog
7 | # Default-Start: 2 3 4 5
8 | # Default-Stop: 0 1 6
9 | # Short-Description: Put a short description of the service here
10 | # Description: Put a long description of the service here
11 | ### END INIT INFO
12 |
13 | # Change the next 3 lines to suit where you install your script and what you want to call it
14 | DIR=/home/pi
15 | DAEMON="/usr/local/bin/cbpi start"
16 | DAEMON_NAME=CraftBeerPI4
17 |
18 | # Add any command line options for your daemon here
19 | DAEMON_OPTS=""
20 |
21 | # This next line determines what user the script runs as.
22 | # Root generally not recommended but necessary if you are using the Raspberry Pi GPIO from Python.
23 | DAEMON_USER=root
24 |
25 | # The process ID of the script when it runs is stored here:
26 | PIDFILE=/var/run/$DAEMON_NAME.pid
27 |
28 | . /lib/lsb/init-functions
29 |
30 | do_start () {
31 | log_daemon_msg "Starting system $DAEMON_NAME daemon"
32 | start-stop-daemon --start --background --pidfile $PIDFILE --make-pidfile --user $DAEMON_USER --chuid $DAEMON_USER --chdir $DIR --startas $DAEMON -- $DAEMON_OPTS
33 | log_end_msg $?
34 | }
35 | do_stop () {
36 | log_daemon_msg "Stopping system $DAEMON_NAME daemon"
37 | start-stop-daemon --stop --pidfile $PIDFILE --retry 10
38 | log_end_msg $?
39 | }
40 |
41 | case "$1" in
42 |
43 | start|stop)
44 | do_${1}
45 | ;;
46 |
47 | restart|reload|force-reload)
48 | do_stop
49 | do_start
50 | ;;
51 |
52 | status)
53 | status_of_proc "$DAEMON_NAME" "$DAEMON" && exit 0 || exit $?
54 | ;;
55 |
56 | *)
57 | echo "Usage: /etc/init.d/$DAEMON_NAME {start|stop|restart|status}"
58 | exit 1
59 | ;;
60 |
61 | esac
62 | exit 0
63 |
--------------------------------------------------------------------------------
/tests/test_ws.py:
--------------------------------------------------------------------------------
1 | import aiohttp
2 | from aiohttp.test_utils import unittest_run_loop
3 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
4 |
5 | # class WebSocketTestCase(CraftBeerPiTestCase):
6 |
7 | # @unittest_run_loop
8 | # async def test_brewing_process(self):
9 |
10 | # count_step_done = 0
11 | # async with self.client.ws_connect('/ws') as ws:
12 | # await ws.send_json(data=dict(topic="step/stop"))
13 | # await ws.send_json(data=dict(topic="step/start"))
14 | # async for msg in ws:
15 | # if msg.type == aiohttp.WSMsgType.TEXT:
16 | # try:
17 | # msg_obj = msg.json()
18 | # topic = msg_obj.get("topic")
19 | # if topic == "job/step/done":
20 | # count_step_done = count_step_done + 1
21 | # if topic == "step/brewing/finished":
22 | # await ws.send_json(data=dict(topic="close"))
23 | # except Exception as e:
24 | # print(e)
25 | # break
26 | # elif msg.type == aiohttp.WSMsgType.ERROR:
27 | # break
28 |
29 | # assert count_step_done == 4
30 |
31 | # @unittest_run_loop
32 | # async def test_wrong_format(self):
33 |
34 | # async with self.client.ws_connect('/ws') as ws:
35 | # await ws.send_json(data=dict(a="close"))
36 | # async for msg in ws:
37 | # print("MSG TYP", msg.type, msg.data)
38 | # if msg.type == aiohttp.WSMsgType.TEXT:
39 | # msg_obj = msg.json()
40 | # if msg_obj["topic"] != "connection/success":
41 | # print(msg.data)
42 | # raise Exception()
43 |
44 | # else:
45 | # raise Exception()
46 |
47 |
--------------------------------------------------------------------------------
/cbpi/controller/kettle_controller.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from cbpi.api.dataclasses import Kettle, Props
4 | from cbpi.controller.basic_controller2 import BasicController
5 | from tabulate import tabulate
6 |
7 |
8 | class KettleController(BasicController):
9 |
10 | def __init__(self, cbpi):
11 | super(KettleController, self).__init__(cbpi, Kettle, "kettle.json")
12 | self.update_key = "kettleupdate"
13 | self.autostart = False
14 |
15 | def create(self, data):
16 | return Kettle(
17 | data.get("id"),
18 | data.get("name"),
19 | type=data.get("type"),
20 | props=Props(data.get("props", {})),
21 | sensor=data.get("sensor"),
22 | heater=data.get("heater"),
23 | agitator=data.get("agitator"),
24 | )
25 |
26 | async def toggle(self, id):
27 |
28 | try:
29 | item = self.find_by_id(id)
30 |
31 | if item.instance is None or item.instance.state == False:
32 | await self.start(id)
33 | else:
34 | await item.instance.stop()
35 | await self.push_udpate()
36 | except Exception as e:
37 | logging.error("Failed to switch on KettleLogic {} {}".format(id, e))
38 |
39 | async def set_target_temp(self, id, target_temp):
40 | try:
41 | item = self.find_by_id(id)
42 | item.target_temp = target_temp
43 | await self.save()
44 | except Exception as e:
45 | logging.error("Failed to set Target Temp {} {}".format(id, e))
46 |
47 | async def stop(self, id):
48 | try:
49 | logging.info("Stop Kettle {}".format(id))
50 | item = self.find_by_id(id)
51 | if item.instance:
52 | await item.instance.stop()
53 | await self.push_udpate()
54 | except Exception as e:
55 | logging.error("Failed to switch off KettleLogic {} {}".format(id, e))
56 |
--------------------------------------------------------------------------------
/cbpi/controller/job_controller.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 |
4 | from cbpi.job.aiohttp import get_scheduler_from_app, setup
5 |
6 | logger = logging.getLogger(__name__)
7 |
8 |
9 | class JobController(object):
10 |
11 | def __init__(self, cbpi):
12 | self.cbpi = cbpi
13 |
14 | async def init(self):
15 | await setup(self.cbpi.app, self.cbpi)
16 |
17 | def register_background_task(self, obj):
18 | """
19 | This method parses all method for the @background_task decorator and registers the background job
20 | which will be launched during start up of the server
21 |
22 | :param obj: the object to parse
23 | :return:
24 | """
25 |
26 | async def job_loop(app, name, interval, method):
27 | logger.info(
28 | "Start Background Task %s Interval %s Method %s"
29 | % (name, interval, method)
30 | )
31 | while True:
32 | logger.debug(
33 | "Execute Task %s - interval(%s second(s)" % (name, interval)
34 | )
35 | await asyncio.sleep(interval)
36 | await method()
37 |
38 | async def spawn_job(app):
39 | scheduler = get_scheduler_from_app(self.cbpi.app)
40 | for method in [
41 | getattr(obj, f)
42 | for f in dir(obj)
43 | if callable(getattr(obj, f))
44 | and hasattr(getattr(obj, f), "background_task")
45 | ]:
46 | name = method.__getattribute__("name")
47 | interval = method.__getattribute__("interval")
48 | job = await scheduler.spawn(
49 | job_loop(self.app, name, interval, method), name, "background"
50 | )
51 |
52 | self.cbpi.app.on_startup.append(spawn_job)
53 |
54 | async def start_job(self, method, name, type):
55 | scheduler = get_scheduler_from_app(self.cbpi.app)
56 | return await scheduler.spawn(method, name, type)
57 |
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_actor/generic_mqtt_actor.py:
--------------------------------------------------------------------------------
1 | from cbpi.api import Property, parameters
2 |
3 | from . import MQTTActor
4 |
5 |
6 | @parameters(
7 | [
8 | Property.Text(label="Topic", configurable=True, description="MQTT Topic"),
9 | Property.Text(
10 | label="Payload",
11 | configurable=True,
12 | default_value='',
13 | description="Payload that is sent as MQTT message. Available placeholders are {switch_onoff}: [on|off], {switch_10}: [1|0], {power}: [0-100].",
14 | ),
15 | ]
16 | )
17 | class GenericMqttActor(MQTTActor):
18 | def __init__(self, cbpi, id, props):
19 | MQTTActor.__init__(self, cbpi, id, props)
20 | self.payload = ""
21 |
22 | async def on_start(self):
23 | await MQTTActor.on_start(self)
24 | self.payload = self.props.get(
25 | "Payload", '{{"state": "{switch_onoff}", "power": {power}}}'
26 | )
27 |
28 | def normalize_power_value(self, power):
29 | if power is not None:
30 | if power != self.power:
31 | power = min(100, power)
32 | power = max(0, power)
33 | self.power = round(power)
34 |
35 | async def publish_mqtt_message(self, topic, payload):
36 | self.logger.info(
37 | "Publish '{payload}' to '{topic}'".format(payload=payload, topic=self.topic)
38 | )
39 | await self.cbpi.satellite.publish(self.topic, payload, True)
40 |
41 | async def on(self, power=None):
42 | self.normalize_power_value(power)
43 | formatted_payload = self.payload.format(
44 | switch_onoff="on", switch_10=1, power=self.power
45 | )
46 | await self.publish_mqtt_message(self.topic, formatted_payload)
47 | self.state = True
48 |
49 | async def off(self):
50 | formatted_payload = self.payload.format(
51 | switch_onoff="off", switch_10=0, power=0
52 | )
53 | await self.publish_mqtt_message(self.topic, formatted_payload)
54 | self.state = False
55 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM alpine:latest as download
2 | RUN apk --no-cache add curl && mkdir /downloads
3 | # Download installation files
4 | RUN curl https://github.com/PiBrewing/craftbeerpi4-ui/archive/main.zip -L -o ./downloads/cbpi-ui.zip
5 |
6 | FROM python:3.13 as base
7 |
8 | # Install dependencies
9 | RUN apt-get update \
10 | && apt-get upgrade -y
11 | RUN apt-get install --no-install-recommends -y \
12 | libsystemd-dev \
13 | libffi-dev \
14 | python3-pip \
15 | && rm -rf /var/lib/apt/lists/*
16 |
17 | ENV VIRTUAL_ENV=/opt/venv
18 |
19 | # Create non-root user working directory
20 | RUN groupadd -g 1000 -r craftbeerpi \
21 | && useradd -u 1000 -r -s /bin/false -g craftbeerpi craftbeerpi \
22 | && mkdir /cbpi \
23 | && chown craftbeerpi:craftbeerpi /cbpi \
24 | && mkdir -p $VIRTUAL_ENV \
25 | && chown -R craftbeerpi:craftbeerpi ${VIRTUAL_ENV}
26 |
27 | USER craftbeerpi
28 |
29 | # create virtual environment
30 | RUN python3 -m venv $VIRTUAL_ENV
31 | ENV PATH="$VIRTUAL_ENV/bin:$PATH"
32 |
33 | RUN python3 -m pip install --no-cache-dir --upgrade pip setuptools wheel
34 |
35 | # Install craftbeerpi requirements for better caching
36 | COPY --chown=craftbeerpi ./requirements.txt /cbpi-src/
37 | RUN pip3 install --no-cache-dir -r /cbpi-src/requirements.txt
38 |
39 | # Install RPi.GPIO separately because it's excluded in setup.py for non-raspberrys.
40 | # This can enable GPIO support for the image when used on a raspberry pi and the
41 | # /dev/gpiomem device.
42 | RUN pip3 install --no-cache-dir RPi.GPIO==0.7.1
43 |
44 | FROM base as deploy
45 | # Install craftbeerpi from source
46 | COPY --chown=craftbeerpi . /cbpi-src
47 | RUN pip3 install --no-cache-dir /cbpi-src
48 |
49 | # Install craftbeerpi-ui
50 | COPY --from=download --chown=craftbeerpi /downloads /downloads
51 | RUN pip3 install --no-cache-dir /downloads/cbpi-ui.zip
52 |
53 | # Clean up installation files
54 | USER root
55 | RUN rm -rf /downloads /cbpi-src
56 | USER craftbeerpi
57 |
58 | WORKDIR /cbpi
59 | RUN ["cbpi", "setup"]
60 |
61 | EXPOSE 8000
62 |
63 | # Start cbpi
64 | CMD ["cbpi", "start"]
65 |
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_actor/mqtt_actor.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 |
4 | from cbpi.api import *
5 | from cbpi.api import CBPiActor, Property, parameters
6 |
7 | @parameters([Property.Text(label="Topic", configurable=True, description="MQTT Topic")])
8 | class MQTTActor(CBPiActor):
9 |
10 | # Custom property which can be configured by the user
11 | @action(
12 | "Set Power",
13 | parameters=[
14 | Property.Number(
15 | label="Power", configurable=True, description="Power Setting [0-100]"
16 | )
17 | ],
18 | )
19 | async def setpower(self, Power=100, **kwargs):
20 | self.power = int(Power)
21 | if self.power < 0:
22 | self.power = 0
23 | if self.power > 100:
24 | self.power = 100
25 | await self.set_power(self.power)
26 |
27 | def __init__(self, cbpi, id, props):
28 | super(MQTTActor, self).__init__(cbpi, id, props)
29 |
30 | async def on_start(self):
31 | self.topic = self.props.get("Topic", None)
32 | self.power = 100
33 | await self.off()
34 | self.state = False
35 |
36 | async def on(self, power=None):
37 | if power is not None:
38 | if power != self.power:
39 | power = min(100, power)
40 | power = max(0, power)
41 | self.power = round(power)
42 | await self.cbpi.satellite.publish(
43 | self.topic, json.dumps({"state": "on", "power": self.power}), True
44 | )
45 | self.state = True
46 | pass
47 |
48 | async def off(self):
49 | self.state = False
50 | await self.cbpi.satellite.publish(
51 | self.topic, json.dumps({"state": "off", "power": 0}), True
52 | )
53 | pass
54 |
55 | async def run(self):
56 | while self.running:
57 | await asyncio.sleep(1)
58 |
59 | def get_state(self):
60 | return self.state
61 |
62 | async def set_power(self, power):
63 | self.power = round(power)
64 | if self.state == True:
65 | await self.on(power)
66 | else:
67 | await self.off()
68 | await self.cbpi.actor.actor_update(self.id, power)
69 | pass
70 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // For format details, see https://aka.ms/devcontainer.json. For config options, see the README at:
2 | // https://github.com/microsoft/vscode-dev-containers/tree/v0.177.0/containers/docker-existing-docker-compose
3 | // If you want to run as a non-root user in the container, see .devcontainer/docker-compose.yml.
4 | {
5 | "name": "CraftBeerPi4",
6 |
7 | // Update the 'dockerComposeFile' list if you have more compose files or use different names.
8 | // The .devcontainer/docker-compose.yml file contains any overrides you need/want to make.
9 | "dockerComposeFile": [
10 | "docker-compose.dev.yml"
11 | ],
12 |
13 | // The 'service' property is the name of the service for the container that VS Code should
14 | // use. Update this value and .devcontainer/docker-compose.yml to the real service name.
15 | "service": "craftbeerpi4-development",
16 |
17 | // The optional 'workspaceFolder' property is the path VS Code should open by default when
18 | // connected. This is typically a file mount in .devcontainer/docker-compose.yml
19 | "workspaceFolder": "/workspace",
20 |
21 | // Set *default* container specific settings.json values on container create.
22 | "settings": {
23 | //"terminal.integrated.shell.linux": null
24 | },
25 |
26 | // Add the IDs of extensions you want installed when the container is created.
27 | "extensions": [
28 | "ms-python.python",
29 | "ms-azuretools.vscode-docker",
30 | "editorconfig.editorconfig",
31 | "eamodio.gitlens"
32 | ],
33 |
34 | // Use 'forwardPorts' to make a list of ports inside the container available locally.
35 | "forwardPorts": [
36 | "craftbeerpi4-development:8000",
37 | "mqtt-explorer:4000"
38 | ],
39 |
40 | // Uncomment the next line if you want start specific services in your Docker Compose config.
41 | // "runServices": [],
42 |
43 | // Uncomment the next line if you want to keep your containers running after VS Code shuts down.
44 | "shutdownAction": "stopCompose",
45 |
46 | // Uncomment the next line to run commands after the container is created - for example installing curl.
47 | //"postCreateCommand": "pip3 install -r ./requirements.txt",
48 |
49 | // Uncomment to connect as a non-root user if you've added one. See https://aka.ms/vscode-remote/containers/non-root.
50 | "remoteUser": "vscode"
51 | }
52 |
--------------------------------------------------------------------------------
/cbpi/static/liquid_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/static/calculator_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
11 |
--------------------------------------------------------------------------------
/cbpi/static/svg_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/extension/hysteresis/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from asyncio import tasks
4 |
5 | from cbpi.api import *
6 |
7 |
8 | @parameters(
9 | [
10 | Property.Number(
11 | label="OffsetOn",
12 | configurable=True,
13 | description="Offset below target temp when heater should switched on",
14 | ),
15 | Property.Number(
16 | label="OffsetOff",
17 | configurable=True,
18 | description="Offset below target temp when heater should switched off",
19 | ),
20 | ]
21 | )
22 | class Hysteresis(CBPiKettleLogic):
23 |
24 | async def run(self):
25 | try:
26 | self.offset_on = float(self.props.get("OffsetOn", 0))
27 | self.offset_off = float(self.props.get("OffsetOff", 0))
28 | self.kettle = self.get_kettle(self.id)
29 | self.heater = self.kettle.heater
30 | heater = self.cbpi.actor.find_by_id(self.heater)
31 | logging.info(
32 | "Hysteresis {} {} {} {}".format(
33 | self.offset_on, self.offset_off, self.id, self.heater
34 | )
35 | )
36 |
37 | # self.get_actor_state()
38 |
39 | while self.running == True:
40 |
41 | sensor_value = self.get_sensor_value(self.kettle.sensor).get("value")
42 | target_temp = self.get_kettle_target_temp(self.id)
43 | try:
44 | heater_state = heater.instance.state
45 | except:
46 | heater_state = False
47 | if sensor_value < target_temp - self.offset_on:
48 | if self.heater and (heater_state == False):
49 | await self.actor_on(self.heater)
50 | elif sensor_value >= target_temp - self.offset_off:
51 | if self.heater and (heater_state == True):
52 | await self.actor_off(self.heater)
53 | await asyncio.sleep(1)
54 |
55 | except asyncio.CancelledError as e:
56 | pass
57 | except Exception as e:
58 | logging.error("CustomLogic Error {}".format(e))
59 | finally:
60 | self.running = False
61 | await self.actor_off(self.heater)
62 |
63 |
64 | def setup(cbpi):
65 | """
66 | This method is called by the server during startup
67 | Here you need to register your plugins at the server
68 |
69 | :param cbpi: the cbpi core
70 | :return:
71 | """
72 |
73 | cbpi.plugin.register("Hysteresis", Hysteresis)
74 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CraftBeerPi 4
2 |
3 | [](https://github.com/PiBrewing/craftbeerpi4/actions/workflows/build.yml)
4 | [](https://github.com/PiBrewing/craftbeerpi4/blob/master/LICENSE)
5 | 
6 | [](https://github.com/PiBrewing/craftbeerpi4/commits)
7 | 
8 | 
9 |
10 |
11 |
12 |
13 |
14 | CraftBeerPi 4 is an open source software solution to control the brewing and
15 | fermentation of beer :beer:.
16 |
17 | ## 📚 Documentation
18 | Instructions on how to install CraftBeerPi and use its plugins is described
19 | in the documentation, that can be found here: [gitbook.io](https://openbrewing.gitbook.io/craftbeerpi4_support/).
20 |
21 | ## 📚 Changelog
22 | Changelog can be found [here](./CHANGELOG.md)
23 |
24 | ### Plugins
25 | Plugins extend the base functionality of CraftBeerPi 4.
26 | You can find a list of available plugins [here](https://openbrewing.gitbook.io/craftbeerpi4_support/master/plugin-installation#plugin-list).
27 |
28 | ## 🧑🤝🧑 Contribute
29 | You want to help develop CraftBeerPi4? To get you quickly stated, this repository comes with a preconfigured
30 | development environment. To be able to use this environment you need 2 things installed on your computer:
31 |
32 | - docker
33 | - visual studio code (vscode)
34 |
35 | To start developing clone this repository, open the folder in vscode and use the _development container_ feature. The command is called _Reopen in container_. Please note that this quick start setup does not work if you want to develop directly on a 32bit raspberry pi os because docker is only available for 64bit arm plattform. Please use the regular development setup for that.
36 |
37 | For a more detailed description of a development setup without the _development container_ feature see the documentation page:
38 | [gitbook.io](https://openbrewing.gitbook.io/craftbeerpi4_support/)
39 |
40 | ### Contributors
41 | Thanks to all the people who have contributed
42 |
43 | [](https://github.com/PiBrewing/craftbeerpi4/graphs/contributors)
44 |
--------------------------------------------------------------------------------
/cbpi/api/timer.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import math
3 | import time
4 |
5 |
6 | class Timer(object):
7 |
8 | def __init__(self, timeout, on_done=None, on_update=None) -> None:
9 | super().__init__()
10 | self.timeout = timeout
11 | self._timemout = self.timeout
12 | self._task = None
13 | self._callback = on_done
14 | self._update = on_update
15 | self.start_time = None
16 | self.end_time = None
17 |
18 | def done(self, task):
19 | if self._callback is not None:
20 | asyncio.create_task(self._callback(self))
21 |
22 | async def _job(self):
23 | self.start_time = int(time.time())
24 | self.end_time = self.start_time + int(round(self._timemout, 0))
25 | self.count = self.end_time - self.start_time
26 | try:
27 | while self.count > 0:
28 | self.count = self.end_time - int(time.time())
29 | if self._update is not None:
30 | await self._update(self, self.count)
31 | await asyncio.sleep(1)
32 | except asyncio.CancelledError:
33 | end = int(time.time())
34 | duration = end - self.start_time
35 | self._timemout = self._timemout - duration
36 |
37 | async def add(self, seconds):
38 | self.end_time = self.end_time + seconds
39 |
40 | def start(self):
41 | self._task = asyncio.create_task(self._job())
42 | self._task.add_done_callback(self.done)
43 |
44 | async def stop(self):
45 | if self._task and self._task.done() is False:
46 | self._task.cancel()
47 | await self._task
48 |
49 | def reset(self):
50 | if self.is_running is True:
51 | return
52 | self._timemout = self.timeout
53 |
54 | def is_running(self):
55 | return not self._task.done()
56 |
57 | def set_time(self, timeout):
58 | if self.is_running is True:
59 | return
60 | self.timeout = timeout
61 |
62 | def get_time(self):
63 | return self.format_time(int(round(self._timemout, 0)))
64 |
65 | @classmethod
66 | def format_time(cls, time):
67 | pattern_h = "{0:02d}:{1:02d}:{2:02d}"
68 | pattern_d = "{0:02d}D {1:02d}:{2:02d}:{3:02d}"
69 | seconds = time % 60
70 | minutes = math.floor(time / 60) % 60
71 | hours = math.floor(time / 3600) % 24
72 | days = math.floor(time / 86400)
73 | if days != 0:
74 | remaining_time = pattern_d.format(days, hours, minutes, seconds)
75 | else:
76 | remaining_time = pattern_h.format(hours, minutes, seconds)
77 | return remaining_time
78 |
--------------------------------------------------------------------------------
/cbpi/static/glass_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/static/grain.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/api/decorator.py:
--------------------------------------------------------------------------------
1 | from functools import wraps
2 |
3 | from voluptuous import Schema
4 |
5 | __all__ = [
6 | "request_mapping",
7 | "on_startup",
8 | "on_event",
9 | "action",
10 | "background_task",
11 | "parameters",
12 | ]
13 |
14 | from aiohttp_auth import auth
15 |
16 |
17 | def composed(*decs):
18 | def deco(f):
19 | for dec in reversed(decs):
20 | f = dec(f)
21 | return f
22 |
23 | return deco
24 |
25 |
26 | def request_mapping(
27 | path, name=None, method="GET", auth_required=True, json_schema=None
28 | ):
29 |
30 | def on_http_request(path, name=None):
31 | def real_decorator(func):
32 | func.route = True
33 | func.path = path
34 | func.name = name
35 | func.method = method
36 | return func
37 |
38 | return real_decorator
39 |
40 | def validate_json_body(func):
41 |
42 | @wraps(func)
43 | async def wrapper(*args):
44 |
45 | if json_schema is not None:
46 | data = await args[-1].json()
47 | schema = Schema(json_schema)
48 | schema(data)
49 |
50 | return await func(*args)
51 |
52 | return wrapper
53 |
54 | if auth_required is True:
55 | return composed(
56 | on_http_request(path, name), auth.auth_required, validate_json_body
57 | )
58 | else:
59 | return composed(on_http_request(path, name), validate_json_body)
60 |
61 |
62 | def on_event(topic):
63 | def real_decorator(func):
64 | func.eventbus = True
65 | func.topic = topic
66 | func.c = None
67 | return func
68 |
69 | return real_decorator
70 |
71 |
72 | def action(key, parameters):
73 | def real_decorator(func):
74 | func.action = True
75 | func.key = key
76 | func.parameters = parameters
77 | return func
78 |
79 | return real_decorator
80 |
81 |
82 | def parameters(parameter):
83 | def real_decorator(func):
84 | func.cbpi_p = True
85 | func.cbpi_parameters = parameter
86 | return func
87 |
88 | return real_decorator
89 |
90 |
91 | def background_task(name, interval):
92 | def real_decorator(func):
93 | func.background_task = True
94 | func.name = name
95 | func.interval = interval
96 | return func
97 |
98 | return real_decorator
99 |
100 |
101 | def on_startup(name, order=0):
102 | def real_decorator(func):
103 | func.on_startup = True
104 | func.name = name
105 | func.order = order
106 | return func
107 |
108 | return real_decorator
109 |
110 |
111 | def entry_exit(f):
112 | def new_f():
113 |
114 | f()
115 |
116 | return new_f
117 |
--------------------------------------------------------------------------------
/cbpi/static/yeast.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/api/actor.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | from abc import ABCMeta
3 |
4 | from cbpi.api.config import ConfigType
5 |
6 | __all__ = ["CBPiActor"]
7 |
8 | import logging
9 |
10 | logger = logging.getLogger(__file__)
11 |
12 |
13 | class CBPiActor(metaclass=ABCMeta):
14 |
15 | def __init__(self, cbpi, id, props):
16 | self.cbpi = cbpi
17 | self.id = id
18 | self.props = props
19 | self.logger = logging.getLogger(__file__)
20 | self.data_logger = None
21 | self.state = False
22 | self.running = False
23 | self.power = 100
24 | self.output = 100
25 | self.maxoutput = 100
26 | self.timer = 0
27 |
28 | def init(self):
29 | pass
30 |
31 | def log_data(self, value):
32 | self.cbpi.log.log_data(self.id, value)
33 |
34 | def get_state(self):
35 | return dict(state=self.state)
36 |
37 | async def start(self):
38 | pass
39 |
40 | async def stop(self):
41 | pass
42 |
43 | async def on_start(self):
44 | pass
45 |
46 | async def on_stop(self):
47 | pass
48 |
49 | async def run(self):
50 | pass
51 |
52 | async def _run(self):
53 |
54 | try:
55 | await self.on_start()
56 | self.cancel_reason = await self.run()
57 | except asyncio.CancelledError as e:
58 | pass
59 | finally:
60 | await self.on_stop()
61 |
62 | def get_static_config_value(self, name, default):
63 | return self.cbpi.static_config.get(name, default)
64 |
65 | def get_config_value(self, name, default):
66 | return self.cbpi.config.get(name, default=default)
67 |
68 | async def set_config_value(self, name, value):
69 | return await self.cbpi.config.set(name, value)
70 |
71 | async def add_config_value(
72 | self, name, value, type: ConfigType, description, options=None
73 | ):
74 | await self.cbpi.add(name, value, type, description, options=None)
75 |
76 | async def on(self, power, output=None):
77 | """
78 | Code to switch the actor on. Power is provided as integer value
79 |
80 | :param power: power value between 0 and 100
81 | :return: None
82 | """
83 | pass
84 |
85 | async def off(self):
86 | """
87 | Code to switch the actor off
88 |
89 | :return: None
90 | """
91 | pass
92 |
93 | async def set_power(self, power):
94 | """
95 | Code to set power for actor
96 |
97 | :return: dict power
98 | """
99 | return dict(power=self.power)
100 | pass
101 |
102 | async def set_output(self, output):
103 | """
104 | Code to set power for actor
105 |
106 | :return: dict power
107 | """
108 | return dict(output=self.output)
109 | pass
--------------------------------------------------------------------------------
/tests/test_actor.py:
--------------------------------------------------------------------------------
1 | import logging
2 | from unittest import mock
3 | from aiohttp.test_utils import unittest_run_loop
4 | from tests.cbpi_config_fixture import CraftBeerPiTestCase
5 |
6 | logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(name)s - %(message)s')
7 |
8 |
9 | class ActorTestCase(CraftBeerPiTestCase):
10 |
11 | async def test_actor_switch(self):
12 |
13 | resp = await self.client.post(path="/login", data={"username": "cbpi", "password": "123"})
14 | assert resp.status == 200, "login should be successful"
15 |
16 | resp = await self.client.request("POST", "/actor/3CUJte4bkxDMFCtLX8eqsX/on")
17 | assert resp.status == 204, "switching actor on should work"
18 | i = self.cbpi.actor.find_by_id("3CUJte4bkxDMFCtLX8eqsX")
19 |
20 | assert i.instance.state is True
21 |
22 | resp = await self.client.request("POST", "/actor/3CUJte4bkxDMFCtLX8eqsX/off")
23 | assert resp.status == 204
24 | i = self.cbpi.actor.find_by_id("3CUJte4bkxDMFCtLX8eqsX")
25 | assert i.instance.state is False
26 |
27 | async def test_crud(self):
28 | data = {
29 | "name": "SomeActor",
30 | "power": 100,
31 | "props": {
32 | },
33 | "state": False,
34 | "type": "DummyActor"
35 | }
36 |
37 | # Add new sensor
38 | resp = await self.client.post(path="/actor/", json=data)
39 | assert resp.status == 200
40 |
41 | m = await resp.json()
42 | sensor_id = m["id"]
43 |
44 | # Get sensor
45 | resp = await self.client.get(path="/actor/%s" % sensor_id)
46 | assert resp.status == 200
47 |
48 | m2 = await resp.json()
49 | sensor_id = m2["id"]
50 |
51 | resp = await self.client.request("POST", "/actor/%s/on" % sensor_id)
52 | assert resp.status == 204
53 |
54 |
55 |
56 | # Update Sensor
57 | resp = await self.client.put(path="/actor/%s" % sensor_id, json=m)
58 | assert resp.status == 200
59 |
60 | # # Delete Sensor
61 | resp = await self.client.delete(path="/actor/%s" % sensor_id)
62 | assert resp.status == 204
63 |
64 | async def test_crud_negative(self):
65 | data = {
66 | "name": "CustomActor",
67 | "type": "CustomActor",
68 | "config": {
69 | "interval": 5
70 | }
71 | }
72 |
73 | # Get actor which not exists
74 | resp = await self.client.get(path="/actor/%s" % 9999)
75 | assert resp.status == 500
76 |
77 | # Update not existing actor
78 | resp = await self.client.put(path="/actor/%s" % 9999, json=data)
79 | assert resp.status == 500
80 |
81 | async def test_actor_action(self):
82 | resp = await self.client.post(path="/actor/1/action", json=dict(name="myAction", parameter=dict(name="Manuel")))
83 | assert resp.status == 204
84 |
--------------------------------------------------------------------------------
/cbpi/static/pipe_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
20 |
--------------------------------------------------------------------------------
/cbpi/controller/fermenter_recipe_controller.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os.path
4 | import re
5 | from os import listdir
6 | from os.path import isfile, join
7 |
8 | import shortuuid
9 | import yaml
10 |
11 | from ..api.step import StepMove, StepResult, StepState
12 |
13 |
14 | class FermenterRecipeController:
15 |
16 | def __init__(self, cbpi):
17 | self.cbpi = cbpi
18 | self.logger = logging.getLogger(__name__)
19 |
20 | def urlify(self, s):
21 |
22 | # Remove all non-word characters (everything except numbers and letters)
23 | s = re.sub(r"[^\w\s]", "", s)
24 |
25 | # Replace all runs of whitespace with a single dash
26 | s = re.sub(r"\s+", "-", s)
27 |
28 | return s
29 |
30 | async def create(self, name):
31 | id = shortuuid.uuid()
32 | path = self.cbpi.config_folder.get_fermenter_recipe_by_id(id)
33 | data = dict(basic=dict(name=name), steps=[])
34 | with open(path, "w") as file:
35 | yaml.dump(data, file)
36 | return id
37 |
38 | async def save(self, name, data):
39 | path = self.cbpi.config_folder.get_fermenter_recipe_by_id(name)
40 | logging.info(data)
41 | with open(path, "w") as file:
42 | yaml.dump(data, file, indent=4, sort_keys=True)
43 |
44 | async def get_recipes(self):
45 | fermenter_recipe_ids = self.cbpi.config_folder.get_all_fermenter_recipes()
46 |
47 | result = []
48 | for recipe_id in fermenter_recipe_ids:
49 |
50 | with open(
51 | self.cbpi.config_folder.get_fermenter_recipe_by_id(recipe_id)
52 | ) as file:
53 | data = yaml.load(file, Loader=yaml.FullLoader)
54 | dataset = data["basic"]
55 | dataset["file"] = recipe_id
56 | result.append(dataset)
57 | logging.info(result)
58 | return result
59 |
60 | async def get_by_name(self, name):
61 | recipe_path = self.cbpi.config_folder.get_fermenter_recipe_by_id(name)
62 | with open(recipe_path) as file:
63 | return yaml.load(file, Loader=yaml.FullLoader)
64 |
65 | async def remove(self, name):
66 | path = self.cbpi.config_folder.get_fermenter_recipe_by_id(name)
67 | os.remove(path)
68 |
69 | async def brew(self, recipeid, fermenterid, name):
70 | recipe_path = self.cbpi.config_folder.get_fermenter_recipe_by_id(recipeid)
71 |
72 | logging.info(recipe_path)
73 | with open(recipe_path) as file:
74 | data = yaml.load(file, Loader=yaml.FullLoader)
75 | await self.cbpi.fermenter.load_recipe(data, fermenterid, name)
76 |
77 | async def clone(self, id, new_name):
78 | recipe_path = self.cbpi.config_folder.get_fermenter_recipe_by_id(id)
79 | with open(recipe_path) as file:
80 | data = yaml.load(file, Loader=yaml.FullLoader)
81 | data["basic"]["name"] = new_name
82 | new_id = shortuuid.uuid()
83 | await self.save(new_id, data)
84 |
85 | return new_id
86 |
--------------------------------------------------------------------------------
/cbpi/api/base.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | import time
4 | from abc import ABCMeta, abstractmethod
5 |
6 | from cbpi.api.config import ConfigType
7 |
8 |
9 | class CBPiBase(metaclass=ABCMeta):
10 |
11 | def get_static_config_value(self, name, default):
12 | return self.cbpi.static_config.get(name, default)
13 |
14 | def get_config_value(self, name, default):
15 | return self.cbpi.config.get(name, default=default)
16 |
17 | async def set_config_value(self, name, value):
18 | return await self.cbpi.config.set(name, value)
19 |
20 | async def remove_config_parameter(self, name):
21 | return await self.cbpi.config.remove(name)
22 |
23 | async def add_config_value(
24 | self, name, value, type: ConfigType, description, source, options=None
25 | ):
26 | await self.cbpi.config.add(name, value, type, description, source, options=None)
27 |
28 | def get_kettle(self, id):
29 | return self.cbpi.kettle.find_by_id(id)
30 |
31 | def get_kettle_target_temp(self, id):
32 | return self.cbpi.kettle.find_by_id(id).target_temp
33 |
34 | async def set_target_temp(self, id, temp):
35 | await self.cbpi.kettle.set_target_temp(id, temp)
36 |
37 | def get_fermenter(self, id):
38 | return self.cbpi.fermenter._find_by_id(id)
39 |
40 | def get_fermenter_target_temp(self, id):
41 | return self.cbpi.fermenter._find_by_id(id).target_temp
42 |
43 | async def set_fermenter_target_temp(self, id, temp):
44 | await self.cbpi.fermenter.set_target_temp(id, temp)
45 |
46 | def get_fermenter_target_pressure(self, id):
47 | return self.cbpi.fermenter._find_by_id(id).target_pressure
48 |
49 | async def set_fermenter_target_pressure(self, id, temp):
50 | await self.cbpi.fermenter.set_target_pressure(id, temp)
51 |
52 | def get_sensor(self, id):
53 | return self.cbpi.sensor.find_by_id(id)
54 |
55 | def get_sensor_value(self, id):
56 |
57 | return self.cbpi.sensor.get_sensor_value(id)
58 |
59 | def get_actor(self, id):
60 | return self.cbpi.actor.find_by_id(id)
61 |
62 | def get_actor_state(self, id):
63 | try:
64 | actor = self.cbpi.actor.find_by_id(id)
65 | return actor.instance.state
66 | except:
67 | logging.error("Failed to read actor state in step - actor {}".format(id))
68 | return False
69 |
70 | async def actor_on(self, id, power=100, output=None):
71 |
72 | try:
73 | await self.cbpi.actor.on(id, power, output)
74 | except Exception as e:
75 | pass
76 |
77 | async def actor_off(self, id):
78 | try:
79 | await self.cbpi.actor.off(id)
80 | except Exception as e:
81 | pass
82 |
83 | async def actor_set_power(self, id, power):
84 | try:
85 | await self.cbpi.actor.set_power(id, power)
86 | except Exception as e:
87 | pass
88 |
89 | async def actor_set_output(self, id, output):
90 | try:
91 | await self.cbpi.actor.set_output(id, output)
92 | except Exception as e:
93 | pass
94 |
--------------------------------------------------------------------------------
/cbpi/extension/dummysensor/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import asyncio
3 | import logging
4 | import random
5 | import time
6 |
7 | from cbpi.api import *
8 | from cbpi.api.base import CBPiBase
9 | from cbpi.api.dataclasses import DataType, Fermenter, Kettle, Props
10 |
11 |
12 | @parameters([])
13 | class CustomSensor(CBPiSensor):
14 |
15 | def __init__(self, cbpi, id, props):
16 | super(CustomSensor, self).__init__(cbpi, id, props)
17 | self.value = 0
18 |
19 | async def run(self):
20 | while self.running:
21 | self.value = random.randint(10, 100)
22 | self.log_data(self.value)
23 |
24 | self.push_update(self.value)
25 | await asyncio.sleep(1)
26 |
27 | def get_state(self):
28 | return dict(value=self.value)
29 |
30 |
31 | @parameters(
32 | [
33 | Property.Number(
34 | label="Pressure", configurable=True, description="Start Pressure"
35 | ),
36 | Property.Number(
37 | label="PressureIncrease",
38 | configurable=True,
39 | description="Pressure increase per hour",
40 | ),
41 | Property.Number(
42 | label="PressureDecrease",
43 | configurable=True,
44 | description="Pressure decrease per second on openm valve",
45 | ),
46 | Property.Fermenter(label="Fermenter", description="Fermenter"),
47 | ]
48 | )
49 | class DummyPressure(CBPiSensor):
50 |
51 | def __init__(self, cbpi, id, props):
52 | super(DummyPressure, self).__init__(cbpi, id, props)
53 | self.value = float(self.props.get("Pressure", 0))
54 | fermenter = self.props.get("Fermenter", None)
55 | self.fermenter = self.get_fermenter(fermenter)
56 | self.valve = self.fermenter.valve
57 |
58 | async def run(self):
59 | self.uprate = float(self.props.get("PressureIncrease", 0)) / 3600
60 | self.decrease = float(self.props.get("PressureDecrease", 0))
61 | logging.info(self.uprate)
62 | logging.info(self.decrease)
63 |
64 | while self.running:
65 | valve_state = self.get_actor_state(self.valve)
66 | fermenter_instance = self.fermenter.instance
67 | if fermenter_instance:
68 | fermenter_state = fermenter_instance.state
69 | else:
70 | fermenter_state = False
71 | if valve_state == False and fermenter_state:
72 | self.value = self.value + self.uprate
73 | elif valve_state and fermenter_state:
74 | self.value = self.value - self.decrease
75 |
76 | self.log_data(self.value)
77 |
78 | self.push_update(round(self.value, 2))
79 | await asyncio.sleep(1)
80 |
81 | def get_state(self):
82 | return dict(value=self.value)
83 |
84 |
85 | def setup(cbpi):
86 | """
87 | This method is called by the server during startup
88 | Here you need to register your plugins at the server
89 |
90 | :param cbpi: the cbpi core
91 | :return:
92 | """
93 | cbpi.plugin.register("CustomSensor", CustomSensor)
94 | cbpi.plugin.register("DummyPressure", DummyPressure)
95 |
--------------------------------------------------------------------------------
/cbpi/config/create_database.sql:
--------------------------------------------------------------------------------
1 | CREATE TABLE IF NOT EXISTS dashboard
2 | (
3 | id INTEGER PRIMARY KEY NOT NULL,
4 | name VARCHAR(80)
5 |
6 | );
7 |
8 | CREATE TABLE IF NOT EXISTS dashboard_content
9 | (
10 | id INTEGER PRIMARY KEY NOT NULL,
11 | dbid INTEGER(80),
12 | element_id INTEGER,
13 | type VARCHAR(80),
14 | x INTEGER(5),
15 | y INTEGER(5),
16 | config VARCHAR(3000)
17 | );
18 |
19 | CREATE TABLE IF NOT EXISTS actor
20 | (
21 | id INTEGER PRIMARY KEY NOT NULL,
22 | name VARCHAR(80),
23 | type VARCHAR(80),
24 | config VARCHAR(3000)
25 |
26 | );
27 |
28 | CREATE TABLE IF NOT EXISTS sensor
29 | (
30 | id INTEGER PRIMARY KEY NOT NULL,
31 | name VARCHAR(80),
32 | type VARCHAR(80),
33 | config VARCHAR(3000)
34 |
35 | );
36 |
37 | CREATE TABLE IF NOT EXISTS kettle
38 | (
39 | id INTEGER PRIMARY KEY NOT NULL,
40 | name VARCHAR(80),
41 | sensor INTEGER,
42 | heater INTEGER,
43 | automatic VARCHAR(255),
44 | logic VARCHAR(50),
45 | config VARCHAR(1000),
46 | agitator INTEGER,
47 | target_temp INTEGER,
48 | height INTEGER,
49 | diameter INTEGER
50 | );
51 |
52 | CREATE TABLE IF NOT EXISTS config
53 | (
54 | name VARCHAR(50) PRIMARY KEY NOT NULL,
55 | value VARCHAR(255),
56 | type VARCHAR(50),
57 | description VARCHAR(255),
58 | options VARCHAR(255)
59 | );
60 |
61 | CREATE TABLE IF NOT EXISTS sensor
62 | (
63 | id INTEGER PRIMARY KEY NOT NULL,
64 | type VARCHAR(100),
65 | name VARCHAR(80),
66 | config VARCHAR(3000)
67 | );
68 |
69 | CREATE TABLE IF NOT EXISTS step
70 | (
71 | id INTEGER PRIMARY KEY NOT NULL,
72 | "order" INTEGER,
73 | name VARCHAR(80),
74 | type VARCHAR(100),
75 | stepstate VARCHAR(255),
76 | state VARCHAR(1),
77 | start INTEGER,
78 | end INTEGER,
79 | config VARCHAR(255),
80 | kettleid INTEGER
81 | );
82 |
83 | CREATE TABLE IF NOT EXISTS tank
84 | (
85 | id INTEGER PRIMARY KEY NOT NULL,
86 | name VARCHAR(80),
87 | brewname VARCHAR(80),
88 | sensor VARCHAR(80),
89 | sensor2 VARCHAR(80),
90 | sensor3 VARCHAR(80),
91 | heater VARCHAR(10),
92 | logic VARCHAR(50),
93 | config VARCHAR(1000),
94 | cooler VARCHAR(10),
95 | target_temp INTEGER
96 | );
97 |
98 | CREATE TABLE IF NOT EXISTS translation
99 | (
100 | language_code VARCHAR(3) NOT NULL,
101 | key VARCHAR(80) NOT NULL,
102 | text VARCHAR(100) NOT NULL,
103 | PRIMARY KEY (language_code, key)
104 | );
105 |
106 | CREATE TABLE IF NOT EXISTS dummy
107 | (
108 | id INTEGER PRIMARY KEY NOT NULL,
109 | name VARCHAR(80)
110 |
111 | );
112 |
113 |
114 | INSERT OR IGNORE INTO config (name, value, type, description, options) VALUES ('TEMP_UNIT', 'F', 'select', 'Temperature Unit', '[{"value": "C", "label": "C"}, {"value": "F", "label": "F"}]');
115 | INSERT OR IGNORE INTO config (name, value, type, description, options) VALUES ('NAME', 'India Pale Ale1', 'string', 'Brew Name', 'null');
116 | INSERT OR IGNORE INTO config (name, value, type, description, options) VALUES ('BREWERY_NAME', 'CraftBeerPI', 'string', 'Brewery Name', 'null');
117 |
--------------------------------------------------------------------------------
/cbpi/satellite.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import weakref
3 | from collections import defaultdict
4 |
5 | import aiohttp
6 | from aiohttp import web
7 | from cbpi.utils import json_dumps
8 | from voluptuous import Schema
9 |
10 |
11 | class CBPiSatellite:
12 | def __init__(self, cbpi) -> None:
13 | self.cbpi = cbpi
14 | self._callbacks = defaultdict(set)
15 | self._clients = weakref.WeakSet()
16 | self.logger = logging.getLogger(__name__)
17 | self.cbpi.app.add_routes([web.get("/satellite", self.websocket_handler)])
18 | self.cbpi.bus.register_object(self)
19 |
20 | # if self.cbpi.config.static.get("ws_push_all", False):
21 | self.cbpi.bus.register("#", self.listen)
22 |
23 | async def listen(self, topic, **kwargs):
24 | data = dict(topic=topic, data=dict(**kwargs))
25 | self.logger.debug("PUSH %s " % data)
26 | self.send(data)
27 |
28 | def send(self, data):
29 | self.logger.debug("broadcast to ws clients. Data: %s" % data)
30 | for ws in self._clients:
31 |
32 | async def send_data(ws, data):
33 | await ws.send_json(data=data, dumps=json_dumps)
34 |
35 | self.cbpi.app.loop.create_task(send_data(ws, data))
36 |
37 | async def websocket_handler(self, request):
38 |
39 | ws = web.WebSocketResponse()
40 | await ws.prepare(request)
41 | self._clients.add(ws)
42 | try:
43 | peername = request.transport.get_extra_info("peername")
44 | if peername is not None:
45 |
46 | host = peername[0]
47 | port = peername[1]
48 | else:
49 | host, port = "Unknowen"
50 | self.logger.info(
51 | "Client Connected - Host: %s Port: %s - client count: %s "
52 | % (host, port, len(self._clients))
53 | )
54 | except Exception as e:
55 | pass
56 |
57 | try:
58 | await ws.send_json(data=dict(topic="connection/success"))
59 | async for msg in ws:
60 | if msg.type == aiohttp.WSMsgType.TEXT:
61 |
62 | msg_obj = msg.json()
63 | schema = Schema({"topic": str, "data": dict})
64 | schema(msg_obj)
65 |
66 | topic = msg_obj.get("topic")
67 | data = msg_obj.get("data")
68 | if topic == "close":
69 | await ws.close()
70 | else:
71 | if data is not None:
72 | await self.cbpi.bus.fire(topic=topic, **data)
73 | else:
74 | await self.cbpi.bus.fire(topic=topic)
75 | elif msg.type == aiohttp.WSMsgType.ERROR:
76 | self.logger.error(
77 | "ws connection closed with exception %s" % ws.exception()
78 | )
79 |
80 | except Exception as e:
81 | self.logger.error("%s - Received Data %s" % (str(e), msg.data))
82 |
83 | finally:
84 | self._clients.discard(ws)
85 |
86 | self.logger.info("Web Socket Close")
87 |
88 | return ws
89 |
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_util/__init__.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 |
4 | from cbpi.api import *
5 | from cbpi.api.base import CBPiBase
6 | from cbpi.api.config import ConfigType
7 | from cbpi.controller.fermentation_controller import FermentationController
8 | from cbpi.controller.kettle_controller import KettleController
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class MQTTUtil(CBPiExtension):
14 |
15 | def __init__(self, cbpi):
16 | self.cbpi = cbpi
17 | self.kettlecontroller = cbpi.kettle
18 | self.fermentationcontroller = cbpi.fermenter
19 | # sensor and actor update is done anyhow during startup
20 | # self.sensorcontroller = cbpi.sensor
21 | # self.actorcontroller = cbpi.actor
22 |
23 | self.mqttupdate = int(self.cbpi.config.get("MQTTUpdate", 0))
24 | if self.mqttupdate != 0:
25 | self._task = asyncio.create_task(self.run())
26 | logger.info("INIT MQTTUtil")
27 | else:
28 | self._task = asyncio.create_task(self.push_once())
29 |
30 | async def push_once(self):
31 | # wait some time to ensure that kettlecontroller is started
32 | await asyncio.sleep(5)
33 | self.push_update()
34 |
35 | async def run(self):
36 |
37 | while True:
38 | self.push_update()
39 | await asyncio.sleep(self.mqttupdate)
40 |
41 | def remove_key(self, d, key):
42 | r = dict(d)
43 | del r[key]
44 | return r
45 |
46 | def push_update(self):
47 | # try:
48 | # self.actor=self.actorcontroller.get_state()
49 | # for item in self.actor['data']:
50 | # self.cbpi.push_update("cbpi/{}/{}".format("actorupdate",item['id']), item)
51 | # except Exception as e:
52 | # logging.error(e)
53 | # pass
54 | # try:
55 | # self.sensor=self.sensorcontroller.get_state()
56 | # for item in self.sensor['data']:
57 | # self.cbpi.push_update("cbpi/{}/{}".format("sensorupdate",item['id']), item)
58 | # except Exception as e:
59 | # logging.error(e)
60 | # pass
61 | try:
62 | self.kettle = self.kettlecontroller.get_state()
63 | for item in self.kettle["data"]:
64 | self.cbpi.push_update(
65 | "cbpi/{}/{}".format("kettleupdate", item["id"]), item
66 | )
67 | except Exception as e:
68 | logging.error(e)
69 | pass
70 | try:
71 | self.fermenter = self.fermentationcontroller.get_state()
72 | for item in self.fermenter["data"]:
73 | item_new = self.remove_key(item, "steps")
74 | self.cbpi.push_update(
75 | "cbpi/{}/{}".format("fermenterupdate", item["id"]), item_new
76 | )
77 | except Exception as e:
78 | logging.error(e)
79 | pass
80 |
81 |
82 | def setup(cbpi):
83 | if str(cbpi.static_config.get("mqtt", False)).lower() == "true":
84 | cbpi.plugin.register("MQTTUtil", MQTTUtil)
85 | pass
86 |
--------------------------------------------------------------------------------
/cbpi/static/beer_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from setuptools import setup, find_packages
2 | from cbpi import __version__
3 | import platform
4 |
5 | # read the contents of your README file
6 | from os import popen, path
7 | import os
8 |
9 | localsystem = platform.system()
10 | board_reqs = []
11 | raspberrypi=False
12 | if localsystem == "Linux":
13 | command="cat /proc/cpuinfo | grep 'Raspberry'"
14 | model=popen(command).read()
15 | if len(model) != 0:
16 | raspberrypi=True
17 | if os.path.exists("/proc/device-tree/compatible"):
18 | with open("/proc/device-tree/compatible", "rb") as f:
19 | compat = f.read()
20 | # Pi 5
21 | if b"brcm,bcm2712" in compat:
22 | board_reqs = [
23 | "rpi-lgpio"
24 | ]
25 | # Pi 4 and Earlier
26 | elif (
27 | b"brcm,bcm2835" in compat
28 | or b"brcm,bcm2836" in compat
29 | or b"brcm,bcm2837" in compat
30 | or b"brcm,bcm2838" in compat
31 | or b"brcm,bcm2711" in compat
32 | ):
33 | board_reqs = ["RPi.GPIO"]
34 |
35 | # read the contents of your README file
36 | this_directory = path.abspath(path.dirname(__file__))
37 | with open(path.join(this_directory, 'README.md'), encoding='utf-8') as f:
38 | long_description = f.read()
39 |
40 | setup(name='cbpi4',
41 | version=__version__,
42 | description='CraftBeerPi4 Brewing Software',
43 | author='Manuel Fritsch / Alexander Vollkopf',
44 | author_email='avollkopf@l@web.de',
45 | url='https://github.com/PiBrewing/craftbeerpi4',
46 | license='GPLv3',
47 | project_urls={
48 | 'Documentation': 'https://openbrewing.gitbook.io/craftbeerpi4_support/'},
49 | packages=find_packages(),
50 | include_package_data=True,
51 | package_data={
52 | # If any package contains *.txt or *.rst files, include them:
53 | '': ['*.txt', '*.rst', '*.yaml'],
54 | 'cbpi': ['*','*.txt', '*.rst', '*.yaml']},
55 |
56 | python_requires='>=3.9',
57 | long_description=long_description,
58 | long_description_content_type='text/markdown',
59 | install_requires=[
60 | "typing-extensions>=4",
61 | "aiohttp==3.13.2",
62 | "aiohttp-auth==0.1.1",
63 | "aiohttp-route-decorator==0.1.4",
64 | "aiohttp-security==0.5.0",
65 | "aiohttp-session==2.12.1",
66 | "aiohttp-swagger==1.0.16",
67 | "aiojobs==1.4.0 ",
68 | "aiosqlite==0.21.0",
69 | "cryptography==46.0.3",
70 | "pyopenssl==25.3.0",
71 | "requests==2.32.5",
72 | "voluptuous==0.15.2",
73 | "pyfiglet==1.0.4",
74 | 'click==8.3.1',
75 | 'shortuuid==1.0.13',
76 | 'tabulate==0.9.0',
77 | 'aiomqtt==2.4.0',
78 | 'inquirer==3.4.1',
79 | 'colorama==0.4.6',
80 | 'psutil==7.1.3',
81 | 'cbpi4gui',
82 | 'importlib_metadata',
83 | 'distro>=1.8.0',
84 | 'numpy==2.3.5',
85 | 'pandas==2.3.3'] + board_reqs + (
86 | ['systemd-python'] if localsystem == "Linux" else [] ),
87 |
88 | dependency_links=[
89 | 'https://testpypi.python.org/pypi',
90 |
91 | ],
92 | entry_points = {
93 | "console_scripts": [
94 | "cbpi=cbpi.cli:main",
95 | ]
96 | }
97 | )
98 |
--------------------------------------------------------------------------------
/cbpi/static/kettle2_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
13 |
--------------------------------------------------------------------------------
/cbpi/api/sensor.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import logging
3 | from abc import ABCMeta, abstractmethod
4 |
5 | from cbpi.api.base import CBPiBase
6 | from cbpi.api.dataclasses import DataType
7 | from cbpi.api.extension import CBPiExtension
8 |
9 |
10 | class CBPiSensor(CBPiBase, metaclass=ABCMeta):
11 |
12 | def __init__(self, cbpi, id, props):
13 | self.cbpi = cbpi
14 | self.id = id
15 | self.props = props
16 | self.logger = logging.getLogger(__file__)
17 | self.data_logger = None
18 | self.state = False
19 | self.running = False
20 | self.datatype = DataType.VALUE
21 | self.inrange = True
22 | self.temprange = 0
23 | self.kettle = None
24 | self.fermenter = None
25 |
26 | def init(self):
27 | pass
28 |
29 | def log_data(self, value):
30 | self.cbpi.log.log_data(self.id, value)
31 |
32 | def get_state(self):
33 | pass
34 |
35 | def get_value(self):
36 | pass
37 |
38 | def get_unit(self):
39 | pass
40 |
41 | def checkrange(self, value):
42 | # if Kettle and fermenter are selected, range check is deactivated
43 | if self.kettle is not None and self.fermenter is not None:
44 | return True
45 | try:
46 | if self.kettle is not None:
47 | target_temp = float(self.kettle.target_temp)
48 | if self.fermenter is not None:
49 | target_temp = float(self.fermenter.target_temp)
50 |
51 | diff = abs(target_temp - value)
52 | if diff > self.temprange:
53 | return False
54 | else:
55 | return True
56 | except Exception as e:
57 | return True
58 |
59 | def push_update(self, value, mqtt=True):
60 | if self.temprange != 0:
61 | self.inrange = self.checkrange(value)
62 | else:
63 | self.inrange = True
64 | try:
65 | self.cbpi.ws.send(
66 | dict(
67 | topic="sensorstate",
68 | id=self.id,
69 | value=value,
70 | datatype=self.datatype.value,
71 | inrange=self.inrange,
72 | )
73 | )
74 | if mqtt:
75 | self.cbpi.push_update(
76 | "cbpi/sensordata/{}".format(self.id),
77 | dict(
78 | id=self.id,
79 | value=value,
80 | datatype=self.datatype.value,
81 | inrange=self.inrange,
82 | ),
83 | retain=True,
84 | )
85 | # self.cbpi.push_update("cbpi/sensor/{}/udpate".format(self.id), dict(id=self.id, value=value), retain=True)
86 | except:
87 | logging.error("Failed to push sensor update for sensor {}".format(self.id))
88 |
89 | async def start(self):
90 | pass
91 |
92 | async def stop(self):
93 | pass
94 |
95 | async def on_start(self):
96 | pass
97 |
98 | async def on_stop(self):
99 | pass
100 |
101 | async def run(self):
102 | pass
103 |
104 | async def _run(self):
105 |
106 | try:
107 | await self.on_start()
108 | self.cancel_reason = await self.run()
109 | except asyncio.CancelledError as e:
110 | pass
111 | finally:
112 | await self.on_stop()
113 |
--------------------------------------------------------------------------------
/cbpi/websocket.py:
--------------------------------------------------------------------------------
1 | import logging
2 | import weakref
3 | from collections import defaultdict
4 |
5 | import aiohttp
6 | from aiohttp import web
7 | from cbpi.utils import json_dumps
8 | from voluptuous import Schema
9 |
10 |
11 | class CBPiWebSocket:
12 | def __init__(self, cbpi) -> None:
13 | self.cbpi = cbpi
14 | self._callbacks = defaultdict(set)
15 | self._clients = weakref.WeakSet()
16 | self.logger = logging.getLogger(__name__)
17 | self.cbpi.app.add_routes([web.get("/ws", self.websocket_handler)])
18 | self.cbpi.bus.register_object(self)
19 |
20 | # if self.cbpi.config.static.get("ws_push_all", False):
21 | self.cbpi.bus.register("#", self.listen)
22 |
23 | async def listen(self, topic, **kwargs):
24 | data = dict(topic=topic, data=dict(**kwargs))
25 | self.logger.debug("PUSH %s " % data)
26 | self.send(data)
27 |
28 | def send(self, data, sorting=False):
29 | self.logger.debug("broadcast to ws clients. Data: %s" % data)
30 | for ws in self._clients:
31 |
32 | async def send_data(ws, data):
33 | try:
34 | if sorting:
35 | try:
36 | data["data"].sort(key=lambda x: x.get("name").upper())
37 | except:
38 | pass
39 | await ws.send_json(data=data, dumps=json_dumps)
40 | except Exception as e:
41 | self.logger.error("Error with client %s: %s" % (ws, str(e)))
42 |
43 | self.cbpi.app.loop.create_task(send_data(ws, data))
44 |
45 | async def websocket_handler(self, request):
46 |
47 | ws = web.WebSocketResponse()
48 | await ws.prepare(request)
49 | self._clients.add(ws)
50 | try:
51 | peername = request.transport.get_extra_info("peername")
52 | if peername is not None:
53 |
54 | host = peername[0]
55 | port = peername[1]
56 | else:
57 | host, port = "Unknowen"
58 | self.logger.info(
59 | "Client Connected - Host: %s Port: %s - client count: %s "
60 | % (host, port, len(self._clients))
61 | )
62 | except Exception as e:
63 | pass
64 |
65 | try:
66 | await ws.send_json(data=dict(topic="connection/success"))
67 | async for msg in ws:
68 | if msg.type == aiohttp.WSMsgType.TEXT:
69 |
70 | msg_obj = msg.json()
71 | schema = Schema({"topic": str, "data": dict})
72 | schema(msg_obj)
73 |
74 | topic = msg_obj.get("topic")
75 | data = msg_obj.get("data")
76 | if topic == "close":
77 | await ws.close()
78 | else:
79 | if data is not None:
80 | await self.cbpi.bus.fire(topic=topic, **data)
81 | else:
82 | await self.cbpi.bus.fire(topic=topic)
83 | elif msg.type == aiohttp.WSMsgType.ERROR:
84 | self.logger.error(
85 | "ws connection closed with exception %s" % ws.exception()
86 | )
87 |
88 | except Exception as e:
89 | self.logger.error("%s - Received Data %s" % (str(e), msg.data))
90 |
91 | finally:
92 | self._clients.discard(ws)
93 |
94 | self.logger.info("Web Socket Close")
95 |
96 | return ws
97 |
--------------------------------------------------------------------------------
/cbpi/controller/dashboard_controller.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os
4 | from os import listdir
5 | from os.path import isfile, join
6 |
7 | from cbpi.api.base import CBPiBase
8 | from cbpi.api.config import ConfigType
9 | from cbpi.api.dataclasses import NotificationType
10 | from voluptuous.schema_builder import message
11 |
12 |
13 | class DashboardController:
14 |
15 | def __init__(self, cbpi):
16 | self.caching = False
17 | self.cbpi = cbpi
18 | self.logger = logging.getLogger(__name__)
19 | self.cbpi.register(self)
20 |
21 | self.path = cbpi.config_folder.get_dashboard_path("cbpi_dashboard_1.json")
22 |
23 | async def init(self):
24 | pass
25 |
26 | async def get_content(self, dashboard_id):
27 | try:
28 | self.path = self.cbpi.config_folder.get_dashboard_path(
29 | "cbpi_dashboard_" + str(dashboard_id) + ".json"
30 | )
31 | logging.info(self.path)
32 | with open(self.path) as json_file:
33 | data = json.load(json_file)
34 | return data
35 | except:
36 | return {"elements": [], "pathes": []}
37 |
38 | async def add_content(self, dashboard_id, data):
39 | # print(data)
40 | self.path = self.cbpi.config_folder.get_dashboard_path(
41 | "cbpi_dashboard_" + str(dashboard_id) + ".json"
42 | )
43 | with open(self.path, "w") as outfile:
44 | json.dump(data, outfile, indent=4, sort_keys=True)
45 | self.cbpi.notify(
46 | title="Dashboard {}".format(dashboard_id),
47 | message="Saved Successfully",
48 | type=NotificationType.SUCCESS,
49 | )
50 | return {"status": "OK"}
51 |
52 | async def delete_content(self, dashboard_id):
53 | self.path = self.cbpi.config_folder.get_dashboard_path(
54 | "cbpi_dashboard_" + str(dashboard_id) + ".json"
55 | )
56 | if os.path.exists(self.path):
57 | os.remove(self.path)
58 | self.cbpi.notify(
59 | title="Dashboard {}".format(dashboard_id),
60 | message="Deleted Successfully",
61 | type=NotificationType.SUCCESS,
62 | )
63 |
64 | async def get_custom_widgets(self):
65 | path = self.cbpi.config_folder.get_dashboard_path("widgets")
66 | onlyfiles = [
67 | os.path.splitext(f)[0]
68 | for f in sorted(listdir(path))
69 | if isfile(join(path, f)) and f.endswith(".svg")
70 | ]
71 | return onlyfiles
72 |
73 | async def get_dashboard_numbers(self):
74 | max_dashboard_number = self.cbpi.config.get("max_dashboard_number", 4)
75 | return max_dashboard_number
76 |
77 | async def get_current_dashboard(self):
78 | current_dashboard_number = self.cbpi.config.get("current_dashboard_number", 1)
79 | return current_dashboard_number
80 |
81 | async def set_current_dashboard(self, dashboard_id=1):
82 | await self.cbpi.config.set("current_dashboard_number", dashboard_id)
83 | return {"status": "OK"}
84 |
85 | async def get_current_grid(self):
86 | current_grid = self.cbpi.config.get("current_grid", 5)
87 | return current_grid
88 |
89 | async def set_current_grid(self, grid_width=5):
90 | await self.cbpi.config.set("current_grid", grid_width)
91 | return {"status": "OK"}
92 |
93 | async def get_slow_pipe_animation(self):
94 | slow_pipe_animation = self.cbpi.config.get("slow_pipe_animation", "Yes")
95 | return slow_pipe_animation
96 |
--------------------------------------------------------------------------------
/cbpi/controller/recipe_controller.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os.path
4 | import re
5 | from os import listdir
6 | from os.path import isfile, join
7 |
8 | import shortuuid
9 | import yaml
10 | from cbpi.api.dataclasses import NotificationType
11 |
12 | from ..api.step import StepMove, StepResult, StepState
13 |
14 |
15 | class RecipeController:
16 |
17 | def __init__(self, cbpi):
18 | self.cbpi = cbpi
19 | self.logger = logging.getLogger(__name__)
20 |
21 | def urlify(self, s):
22 |
23 | # Remove all non-word characters (everything except numbers and letters)
24 | s = re.sub(r"[^\w\s]", "", s)
25 |
26 | # Replace all runs of whitespace with a single dash
27 | s = re.sub(r"\s+", "-", s)
28 |
29 | return s
30 |
31 | async def create(self, name):
32 | id = shortuuid.uuid()
33 | path = self.cbpi.config_folder.get_recipe_file_by_id(id)
34 | data = dict(
35 | basic=dict(name=name, author=self.cbpi.config.get("AUTHOR", "John Doe")),
36 | steps=[],
37 | )
38 | with open(path, "w") as file:
39 | yaml.dump(data, file)
40 | return id
41 |
42 | async def save(self, name, data):
43 | path = self.cbpi.config_folder.get_recipe_file_by_id(name)
44 | logging.info(data)
45 | with open(path, "w") as file:
46 | yaml.dump(data, file, indent=4, sort_keys=True)
47 |
48 | async def get_recipes(self):
49 | path = self.cbpi.config_folder.get_file_path("recipes")
50 | onlyfiles = [
51 | os.path.splitext(f)[0]
52 | for f in listdir(path)
53 | if isfile(join(path, f)) and f.endswith(".yaml")
54 | ]
55 |
56 | result = []
57 | for filename in onlyfiles:
58 | recipe_path = self.cbpi.config_folder.get_recipe_file_by_id(filename)
59 | with open(recipe_path) as file:
60 | try:
61 | data = yaml.load(file, Loader=yaml.FullLoader)
62 | dataset = data["basic"]
63 | dataset["file"] = filename
64 | result.append(dataset)
65 | except Exception as e:
66 | self.cbpi.notify(
67 | "Error",
68 | f"Invalid recipe file skipped: {filename}",
69 | NotificationType.ERROR,
70 | )
71 | logging.error(f"Skip invalid file: {e}")
72 | return result
73 |
74 | async def get_by_name(self, name):
75 |
76 | recipe_path = self.cbpi.config_folder.get_recipe_file_by_id(name)
77 | with open(recipe_path) as file:
78 | return yaml.load(file, Loader=yaml.FullLoader)
79 |
80 | async def remove(self, name):
81 | path = self.cbpi.config_folder.get_recipe_file_by_id(name)
82 | os.remove(path)
83 |
84 | async def brew(self, name):
85 |
86 | recipe_path = self.cbpi.config_folder.get_recipe_file_by_id(name)
87 | with open(recipe_path) as file:
88 | data = yaml.load(file, Loader=yaml.FullLoader)
89 | await self.cbpi.step.load_recipe(data)
90 |
91 | async def clone(self, id, new_name):
92 | recipe_path = self.cbpi.config_folder.get_recipe_file_by_id(id)
93 | with open(recipe_path) as file:
94 | data = yaml.load(file, Loader=yaml.FullLoader)
95 | data["basic"]["name"] = new_name
96 | new_id = shortuuid.uuid()
97 | await self.save(new_id, data)
98 |
99 | return new_id
100 |
--------------------------------------------------------------------------------
/cbpi/extension/SensorLogTarget_CSV/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import asyncio
3 | import base64
4 | import logging
5 | import os
6 | try:
7 | import pwd
8 | module_pwd = True
9 | except:
10 | module_pwd = False
11 | import shutil
12 | import random
13 | from logging.handlers import RotatingFileHandler
14 | from unittest.mock import MagicMock, patch
15 |
16 | import urllib3
17 | from cbpi.api import *
18 | from cbpi.api.config import ConfigType
19 |
20 | logger = logging.getLogger(__name__)
21 |
22 |
23 | class SensorLogTargetCSV(CBPiExtension):
24 |
25 | def __init__(self, cbpi): # called from cbpi on start
26 | self.cbpi = cbpi
27 | self.logfiles = self.cbpi.config.get("CSVLOGFILES", "Yes")
28 | if self.logfiles == "No":
29 | return # never run()
30 | self._task = asyncio.create_task(self.run()) # one time run() only
31 |
32 | async def run(self): # called by __init__ once on start if CSV is enabled
33 | self.listener_ID = self.cbpi.log.add_sensor_data_listener(self.log_data_to_CSV)
34 | logger.info("CSV sensor log target listener ID: {}".format(self.listener_ID))
35 |
36 | async def log_data_to_CSV(
37 | self, cbpi, id: str, value: str, formatted_time, name
38 | ): # called by log_data() hook from the log file controller
39 | self.logfiles = self.cbpi.config.get("CSVLOGFILES", "Yes")
40 | if self.logfiles == "No":
41 | # We intentionally do not unsubscribe the listener here because then we had no way of resubscribing him without a restart of cbpi
42 | # as long as cbpi was STARTED with CSVLOGFILES set to Yes this function is still subscribed, so changes can be made on the fly.
43 | # but after initially enabling this logging target a restart is required.
44 | return
45 | if id not in self.cbpi.log.datalogger:
46 | max_bytes = int(self.cbpi.config.get("SENSOR_LOG_MAX_BYTES", 100000))
47 | backup_count = int(self.cbpi.config.get("SENSOR_LOG_BACKUP_COUNT", 3))
48 |
49 | data_logger = logging.getLogger("cbpi.sensor.%s" % id)
50 | data_logger.propagate = False
51 | data_logger.setLevel(logging.DEBUG)
52 | try:
53 | handler = RotatingFileHandler(
54 | os.path.join(self.cbpi.log.logsFolderPath, f"sensor_{id}.log"),
55 | maxBytes=max_bytes,
56 | backupCount=backup_count,
57 | )
58 | except Exception as e:
59 | logger.error("Error creating log file handler: %s", e)
60 | try:
61 | logger.warning(
62 | "Trying to set rights for cbpi user on the log folder and file"
63 | )
64 | user = pwd.getpwuid(os.getuid()).pw_name
65 | file= os.path.join(self.cbpi.log.logsFolderPath, f"sensor_{id}.log")
66 | shutil.os.system(f'sudo chown {user}:{user} {file}')
67 |
68 | handler = RotatingFileHandler(
69 | os.path.join(self.cbpi.log.logsFolderPath, f"sensor_{id}.log"),
70 | maxBytes=max_bytes,
71 | backupCount=backup_count,
72 | )
73 | except Exception as e:
74 | logger.error("Error creating log file handler after trying to set rights: %s", e)
75 | return
76 |
77 | data_logger.addHandler(handler)
78 | self.cbpi.log.datalogger[id] = data_logger
79 |
80 | self.cbpi.log.datalogger[id].info("%s,%s" % (formatted_time, str(value)))
81 |
82 |
83 | def setup(cbpi):
84 | cbpi.plugin.register("SensorLogTargetCSV", SensorLogTargetCSV)
85 |
--------------------------------------------------------------------------------
/cbpi/extension/timer/__init__.py:
--------------------------------------------------------------------------------
1 | # -*- coding: utf-8 -*-
2 | import asyncio
3 | import logging
4 | from time import gmtime, strftime
5 |
6 | from aiohttp import web
7 | from cbpi.api import *
8 | from cbpi.api import base
9 | from cbpi.api.dataclasses import DataType, NotificationAction, NotificationType
10 | from cbpi.api.timer import Timer
11 |
12 | logger = logging.getLogger(__name__)
13 |
14 |
15 | @parameters([])
16 | class AlarmTimer(CBPiSensor):
17 |
18 | def __init__(self, cbpi, id, props):
19 | super(AlarmTimer, self).__init__(cbpi, id, props)
20 | self.value = "00:00:00"
21 | self.datatype = DataType.STRING
22 | self.timer = None
23 | self.time = 0
24 | self.stopped = False
25 | self.sensor = self.get_sensor(self.id)
26 |
27 | @action(
28 | key="Set Timer",
29 | parameters=[
30 | Property.Number(
31 | label="time", description="Time in Minutes", configurable=True
32 | )
33 | ],
34 | )
35 | async def set(self, time=0, **kwargs):
36 | self.stopped = False
37 | self.time = float(time)
38 | self.value = self.calculate_time(self.time)
39 | if self.timer is not None:
40 | await self.timer.stop()
41 | self.timer = Timer(
42 | int(self.time * 60),
43 | on_update=self.on_timer_update,
44 | on_done=self.on_timer_done,
45 | )
46 | await self.timer.stop()
47 | self.timer.is_running = False
48 | logging.info("Set Timer")
49 |
50 | @action(key="Start Timer", parameters=[])
51 | async def start(self, **kwargs):
52 | if self.timer is None:
53 | self.timer = Timer(
54 | int(self.time * 60),
55 | on_update=self.on_timer_update,
56 | on_done=self.on_timer_done,
57 | )
58 |
59 | if self.timer.is_running is not True:
60 | self.timer.start()
61 | self.stopped = False
62 | self.timer.is_running = True
63 | else:
64 | self.cbpi.notify(
65 | self.sensor.name, "Timer is already running", NotificationType.WARNING
66 | )
67 |
68 | @action(key="Stop Timer", parameters=[])
69 | async def stop(self, **kwargs):
70 | self.stopped = False
71 | await self.timer.stop()
72 | self.timer.is_running = False
73 | logging.info("Stop Timer")
74 |
75 | @action(key="Reset Timer", parameters=[])
76 | async def Reset(self, **kwargs):
77 | self.stopped = False
78 | if self.timer is not None:
79 | await self.timer.stop()
80 | self.value = self.calculate_time(self.time)
81 | self.timer = Timer(
82 | int(self.time * 60),
83 | on_update=self.on_timer_update,
84 | on_done=self.on_timer_done,
85 | )
86 | await self.timer.stop()
87 | self.timer.is_running = False
88 | logging.info("Reset Timer")
89 |
90 | async def on_timer_done(self, timer):
91 | # self.value = "Stopped"
92 | if self.stopped is True:
93 | self.cbpi.notify(self.sensor.name, "Timer done", NotificationType.SUCCESS)
94 |
95 | self.timer.is_running = False
96 | pass
97 |
98 | async def on_timer_update(self, timer, seconds):
99 | self.stopped = True
100 | self.value = Timer.format_time(seconds)
101 |
102 | async def run(self):
103 | while self.running is True:
104 | self.push_update(self.value)
105 | await asyncio.sleep(1)
106 | pass
107 |
108 | def get_state(self):
109 | return dict(value=self.value)
110 |
111 | def calculate_time(self, time):
112 | return strftime("%H:%M:%S", gmtime(time * 60))
113 |
114 |
115 | def setup(cbpi):
116 | cbpi.plugin.register("AlarmTimer", AlarmTimer)
117 | pass
118 |
--------------------------------------------------------------------------------
/cbpi/http_endpoints/http_plugin.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from aiohttp import web
4 | from cbpi.api import request_mapping
5 | from cbpi.utils import json_dumps
6 |
7 |
8 | class PluginHttpEndpoints:
9 |
10 | def __init__(self, cbpi):
11 | self.cbpi = cbpi
12 | self.cbpi.register(self, url_prefix="/plugin")
13 |
14 | @request_mapping(
15 | path="/install/",
16 | method="POST",
17 | auth_required=False,
18 | json_schema={"package_name": str},
19 | )
20 | async def install(self, request):
21 | """
22 | ---
23 | description: Install Plugin
24 | tags:
25 | - Plugin
26 | parameters:
27 | - in: body
28 | name: body
29 | description: Install a plugin
30 | required: true
31 | schema:
32 | type: object
33 | properties:
34 | package_name:
35 | type: string
36 | produces:
37 | - application/json
38 | responses:
39 | "204":
40 | description: successful operation. Return "pong" text
41 | "405":
42 | description: invalid HTTP Method
43 | """
44 |
45 | data = await request.json()
46 | return (
47 | web.Response(status=204)
48 | if await self.cbpi.plugin.install(data["package_name"]) is True
49 | else web.Response(status=500)
50 | )
51 |
52 | @request_mapping(
53 | path="/uninstall",
54 | method="POST",
55 | auth_required=False,
56 | json_schema={"package_name": str},
57 | )
58 | async def uninstall(self, request):
59 | """
60 | ---
61 | description: Uninstall Plugin
62 | tags:
63 | - Plugin
64 | parameters:
65 | - in: body
66 | name: body
67 | description: Uninstall a plugin
68 | required: true
69 | schema:
70 | type: object
71 | properties:
72 | package_name:
73 | type: string
74 | produces:
75 | - application/json
76 | responses:
77 | "204":
78 | description: successful operation. Return "pong" text
79 | "405":
80 | description: invalid HTTP Method
81 | """
82 |
83 | data = await request.json()
84 | return (
85 | web.Response(status=204)
86 | if await self.cbpi.plugin.uninstall(data["package_name"]) is True
87 | else web.Response(status=500)
88 | )
89 |
90 | @request_mapping(path="/list", method="GET", auth_required=False)
91 | async def list(self, request):
92 | """
93 | ---
94 | description: Get a list of avialable plugins
95 | tags:
96 | - Plugin
97 | produces:
98 | - application/json
99 | responses:
100 | "200":
101 | description: successful operation. Return "pong" text
102 | "405":
103 | description: invalid HTTP Method
104 | """
105 | plugin_list = await self.cbpi.plugin.load_plugin_list()
106 | return web.json_response(plugin_list, dumps=json_dumps)
107 |
108 | @request_mapping(path="/names", method="GET", auth_required=False)
109 | async def names(self, request):
110 | """
111 | ---
112 | description: Get a list of avialable plugin names
113 | tags:
114 | - Plugin
115 | produces:
116 | - application/json
117 | responses:
118 | "200":
119 | description: successful operation. Return "pong" text
120 | "405":
121 | description: invalid HTTP Method
122 | """
123 | plugin_names = await self.cbpi.plugin.load_plugin_names()
124 | return web.json_response(plugin_names, dumps=json_dumps)
125 |
--------------------------------------------------------------------------------
/cbpi/static/hops_icon.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
8 |
--------------------------------------------------------------------------------
/cbpi/extension/mqtt_actor/output_mqtt_actor.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import json
3 | from cbpi.api import parameters, Property, CBPiActor
4 | from cbpi.api import *
5 | import logging
6 |
7 |
8 | @parameters(
9 | [
10 | Property.Text(label="Topic", configurable=True, description="MQTT Topic"),
11 | Property.Number(
12 | label="MaxOutput", configurable=True,
13 | default_value=100,
14 | unit="",
15 | description="Max Output Value"
16 | ),
17 | ]
18 | )
19 | class OutputMQTTActor(CBPiActor):
20 |
21 | # Custom property which can be configured by the user
22 | @action(
23 | "Set Power",
24 | parameters=[
25 | Property.Number(
26 | label="Power", configurable=True, description="Power Setting [0-100]"
27 | )
28 | ],
29 | )
30 | async def setpower(self, Power=100, **kwargs):
31 | self.power = int(Power)
32 | if self.power < 0:
33 | self.power = 0
34 | if self.power > 100:
35 | self.power = 100
36 | self.output = round(self.maxoutput * self.power / 100)
37 | await self.set_power(self.power)
38 |
39 | @action(
40 | "Set Output",
41 | parameters=[
42 | Property.Number(
43 | label="Output",
44 | configurable=True,
45 | description="Output Setting [0-MaxOutput]",
46 | )
47 | ],
48 | )
49 | async def setoutput(self, Output=100, **kwargs):
50 | self.output = int(Output)
51 | if self.output < 0:
52 | self.output = 0
53 | if self.output > self.maxoutput:
54 | self.output = self.maxoutput
55 | await self.set_output(self.output)
56 |
57 | def __init__(self, cbpi, id, props):
58 | super(OutputMQTTActor, self).__init__(cbpi, id, props)
59 |
60 | async def on_start(self):
61 | self.topic = self.props.get("Topic", None)
62 | self.power = 100
63 | self.maxoutput = int(self.props.get("MaxOutput", 100))
64 | self.output = self.maxoutput
65 | await self.cbpi.actor.actor_update(
66 | self.id, self.power, self.output, self.maxoutput
67 | )
68 | await self.off()
69 | self.state = False
70 |
71 | async def on(self, power=None, output=None):
72 | if power is not None:
73 | if power != self.power:
74 | power = min(100, power)
75 | power = max(0, power)
76 | self.power = round(power)
77 | if output is not None:
78 | if output != self.output:
79 | output = min(self.maxoutput, output)
80 | output = max(0, output)
81 | self.output = round(output)
82 | await self.cbpi.satellite.publish(
83 | self.topic,
84 | json.dumps({"state": "on", "power": self.power, "output": self.output}),
85 | True,
86 | )
87 | self.state = True
88 | pass
89 |
90 | async def off(self):
91 | self.state = False
92 | await self.cbpi.satellite.publish(
93 | self.topic, json.dumps({"state": "off", "power": 0, "output": 0}), True
94 | )
95 | pass
96 |
97 | async def run(self):
98 | while self.running:
99 | await asyncio.sleep(1)
100 |
101 | def get_state(self):
102 | return self.state
103 |
104 | async def set_power(self, power):
105 | self.power = round(power)
106 | self.output = round(self.maxoutput * self.power / 100)
107 | if self.state == True:
108 | await self.on(power, self.output)
109 | else:
110 | await self.off()
111 | await self.cbpi.actor.actor_update(self.id, power, self.output)
112 | pass
113 |
114 | async def set_output(self, output):
115 | self.output = round(output)
116 | self.power = round(self.output / self.maxoutput * 100)
117 | if self.state == True:
118 | await self.on(self.power, self.output)
119 | else:
120 | await self.off()
121 | await self.cbpi.actor.actor_update(self.id, self.power, self.output)
122 | pass
123 |
--------------------------------------------------------------------------------
/cbpi/api/property.py:
--------------------------------------------------------------------------------
1 | __all__ = ["PropertyType", "Property"]
2 |
3 |
4 | class PropertyType(object):
5 | pass
6 |
7 |
8 | class Property(object):
9 | class Select(PropertyType):
10 | """
11 | Select Property. The user can select value from list set as options parameter
12 | """
13 |
14 | def __init__(self, label, options, default_value=None, description=""):
15 | """
16 |
17 | :param label:
18 | :param options:
19 | :param default_value:
20 | :param description:
21 | """
22 | PropertyType.__init__(self)
23 | self.label = label
24 | self.options = options
25 | self.default_value = default_value
26 | self.description = description
27 |
28 | class Number(PropertyType):
29 | """
30 | The user can set a number value
31 | """
32 |
33 | def __init__(
34 | self, label, configurable=False, default_value=None, unit="", description=""
35 | ):
36 | """
37 |
38 | :param label:
39 | :param configurable:
40 | :param default_value:
41 | :param unit:
42 | :param description:
43 | """
44 | PropertyType.__init__(self)
45 | self.label = label
46 | self.configurable = configurable
47 | self.default_value = default_value
48 | self.description = description
49 |
50 | class Text(PropertyType):
51 | """
52 | The user can set a text value
53 | """
54 |
55 | def __init__(self, label, configurable=False, default_value="", description=""):
56 | """
57 |
58 | :param label:
59 | :param configurable:
60 | :param default_value:
61 | :param description:
62 | """
63 | PropertyType.__init__(self)
64 | self.label = label
65 | self.configurable = configurable
66 | self.default_value = default_value
67 | self.description = description
68 |
69 | class Actor(PropertyType):
70 | """
71 | The user select an actor which is available in the system. The value of this variable will be the actor id
72 | """
73 |
74 | def __init__(self, label, description=""):
75 | """
76 |
77 | :param label:
78 | :param description:
79 | """
80 | PropertyType.__init__(self)
81 | self.label = label
82 | self.configurable = True
83 | self.description = description
84 |
85 | class Sensor(PropertyType):
86 | """
87 | The user select a sensor which is available in the system. The value of this variable will be the sensor id
88 | """
89 |
90 | def __init__(self, label, description=""):
91 | """
92 |
93 | :param label:
94 | :param description:
95 | """
96 | PropertyType.__init__(self)
97 | self.label = label
98 | self.configurable = True
99 | self.description = description
100 |
101 | class Kettle(PropertyType):
102 | """
103 | The user select a kettle which is available in the system. The value of this variable will be the kettle id
104 | """
105 |
106 | def __init__(self, label, description=""):
107 | """
108 |
109 | :param label:
110 | :param description:
111 | """
112 |
113 | PropertyType.__init__(self)
114 | self.label = label
115 | self.configurable = True
116 | self.description = description
117 |
118 | class Fermenter(PropertyType):
119 | """
120 | The user select a fermenter which is available in the system. The value of this variable will be the fermenter id
121 | """
122 |
123 | def __init__(self, label, description=""):
124 | """
125 |
126 | :param label:
127 | :param description:
128 | """
129 |
130 | PropertyType.__init__(self)
131 | self.label = label
132 | self.configurable = True
133 | self.description = description
134 |
--------------------------------------------------------------------------------
/.github/workflows/build.yml:
--------------------------------------------------------------------------------
1 | name: 'Build CraftBeerPi4'
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | - master
8 | - development
9 | pull_request:
10 |
11 | env:
12 | IMAGE_NAME: ghcr.io/${{ github.repository_owner }}/craftbeerpi4
13 |
14 | jobs:
15 | build:
16 | runs-on: ubuntu-latest
17 | permissions:
18 | packages: write
19 | name: Builds the source distribution package
20 | steps:
21 |
22 | - name: Checkout source
23 | uses: actions/checkout@v4
24 |
25 | - name: Setup python environment
26 | uses: actions/setup-python@v4
27 | with:
28 | python-version: '3.11'
29 |
30 | - name: Install packages
31 | run: sudo apt install -y libsystemd-dev
32 |
33 | - name: Clean
34 | run: python setup.py clean --all
35 |
36 | - name: Install Requirements
37 | run: pip3 install -r requirements.txt
38 |
39 | - name: Run tests
40 | run: coverage run --source cbpi -m pytest tests
41 |
42 | - name: Build source distribution package for CraftBeerPi
43 | run: python setup.py sdist
44 |
45 | - name: Upload CraftBeerPi package to be used in next step
46 | uses: actions/upload-artifact@v4
47 | with:
48 | name: craftbeerpi4
49 | path: dist/cbpi4-*.tar.gz
50 | if-no-files-found: error
51 |
52 | docker:
53 | runs-on: ubuntu-latest
54 | name: Builds the docker image(s)
55 | steps:
56 | - name: Checkout
57 | uses: actions/checkout@v4
58 |
59 | - name: Prepare docker image and tag names
60 | id: prep
61 | run: |
62 |
63 | IMAGE_NAME_LOWERCASE=${IMAGE_NAME,,}
64 | echo "Using image name $IMAGE_NAME_LOWERCASE"
65 |
66 | PUBLISH_IMAGE=false
67 | TAGS="$IMAGE_NAME_LOWERCASE:dev"
68 |
69 | # Define the image that will be used as a cached image
70 | # to speed up the build process
71 | BUILD_CACHE_IMAGE_NAME=${TAGS}
72 |
73 | if [[ $GITHUB_REF_NAME == master ]] || [[ $GITHUB_REF_NAME == main ]]; then
74 | # when building master/main use :latest tag and the version number
75 | # from the cbpi/__init__.py file
76 | VERSION=$(grep -o -E "(([0-9]{1,2}[.]?){2,3}[0-9]+)" cbpi/__init__.py)
77 | LATEST_IMAGE=$IMAGE_NAME_LOWERCASE:latest
78 | BUILD_CACHE_IMAGE_NAME=${LATEST_IMAGE}
79 | TAGS="${LATEST_IMAGE},$IMAGE_NAME_LOWERCASE:v${VERSION}"
80 | PUBLISH_IMAGE="true"
81 | elif [[ $GITHUB_REF_NAME == development ]]; then
82 | PUBLISH_IMAGE="true"
83 | fi
84 |
85 |
86 | echo "tags: $TAGS"
87 | echo "publish_image: $PUBLISH_IMAGE"
88 | echo "cache_name: $BUILD_CACHE_IMAGE_NAME"
89 | echo "tags=$TAGS" >> $GITHUB_OUTPUT
90 | echo "publish_image=$PUBLISH_IMAGE" >> $GITHUB_OUTPUT
91 | echo "cache_name=$BUILD_CACHE_IMAGE_NAME" >> $GITHUB_OUTPUT
92 |
93 | - name: Set up QEMU
94 | uses: docker/setup-qemu-action@master
95 | with:
96 | platforms: all
97 |
98 | - name: Set up Docker Buildx
99 | id: buildx
100 | uses: docker/setup-buildx-action@master
101 |
102 | - name: Login to GitHub Container Registry
103 | uses: docker/login-action@v2
104 | with:
105 | registry: ghcr.io
106 | username: ${{ github.repository_owner }}
107 | password: ${{ secrets.GITHUB_TOKEN }}
108 |
109 | - name: Build
110 | uses: docker/build-push-action@v4
111 | with:
112 | builder: ${{ steps.buildx.outputs.name }}
113 | context: .
114 | file: ./Dockerfile
115 | platforms: linux/amd64,linux/arm64
116 | target: deploy
117 | push: ${{ steps.prep.outputs.publish_image == 'true' }}
118 | tags: ${{ steps.prep.outputs.tags }}
119 | cache-from: type=registry,ref=${{ steps.prep.outputs.cache_name }}
120 | cache-to: type=inline
121 | labels: |
122 | org.opencontainers.image.title=${{ github.event.repository.name }}
123 | org.opencontainers.image.description=${{ github.event.repository.description }}
124 | org.opencontainers.image.url=${{ github.event.repository.html_url }}
125 | org.opencontainers.image.revision=${{ github.sha }}
126 |
--------------------------------------------------------------------------------
/cbpi/http_endpoints/http_config.py:
--------------------------------------------------------------------------------
1 | import logging
2 |
3 | from aiohttp import web
4 | from cbpi.api import *
5 | from cbpi.utils import json_dumps
6 |
7 |
8 | class ConfigHttpEndpoints:
9 |
10 | def __init__(self, cbpi):
11 | self.cbpi = cbpi
12 | self.controller = cbpi.config
13 | self.cbpi.register(self, "/config")
14 |
15 | @request_mapping(path="/{name}/", method="PUT", auth_required=False)
16 | async def http_put(self, request) -> web.Response:
17 | """
18 | ---
19 | description: Set config parameter
20 | tags:
21 | - Config
22 | parameters:
23 | - name: "name"
24 | in: "path"
25 | description: "Parameter name"
26 | required: true
27 | type: "string"
28 | - name: body
29 | in: body
30 | description: "Parameter Value"
31 | required: true
32 | schema:
33 | type: object
34 | properties:
35 | value:
36 | type: string
37 | responses:
38 | "204":
39 | description: successful operation
40 | """
41 |
42 | name = request.match_info["name"]
43 | data = await request.json()
44 | await self.controller.set(name=name, value=data.get("value"))
45 | return web.Response(status=204)
46 |
47 | @request_mapping(path="/", auth_required=False)
48 | async def http_get_all(self, request) -> web.Response:
49 | """
50 | ---
51 | description: Get all config parameters
52 | tags:
53 | - Config
54 | responses:
55 | "200":
56 | description: successful operation
57 | """
58 | return web.json_response(self.controller.get_state(), dumps=json_dumps)
59 |
60 | @request_mapping(path="/{name}/", method="POST", auth_required=False)
61 | async def http_paramter(self, request) -> web.Response:
62 | """
63 | ---
64 | description: Get all config parameters
65 | tags:
66 | - Config
67 | parameters:
68 | - name: "name"
69 | in: "path"
70 | description: "Parameter name"
71 | required: true
72 | type: "string"
73 | responses:
74 | "200":
75 | description: successful operation
76 | """
77 | name = request.match_info["name"]
78 | # if name not in self.cache:
79 | # raise CBPiException("Parameter %s not found" % name)
80 | # data = self.controller.get(name)
81 | return web.json_response(self.controller.get(name), dumps=json_dumps)
82 |
83 | @request_mapping(path="/remove/{name}/", method="PUT", auth_required=False)
84 | async def http_remove(self, request) -> web.Response:
85 | """
86 | ---
87 | description: Remove config parameter
88 | tags:
89 | - Config
90 | parameters:
91 | - name: "name"
92 | in: "path"
93 | description: "Parameter name"
94 | required: true
95 | type: "string"
96 | responses:
97 | "200":
98 | description: successful operation
99 | """
100 |
101 | name = request.match_info["name"]
102 | await self.controller.remove(name=name)
103 | return web.Response(status=200)
104 |
105 | @request_mapping(path="/getobsolete", auth_required=False)
106 | async def http_get_obsolete(self, request) -> web.Response:
107 | """
108 | ---
109 | description: Get obsolete config parameters
110 | tags:
111 | - Config
112 | responses:
113 | "List of Obsolete Parameters":
114 | description: successful operation
115 | """
116 | return web.json_response(
117 | await self.controller.obsolete(False), dumps=json_dumps
118 | )
119 |
120 | @request_mapping(path="/removeobsolete", auth_required=False)
121 | async def http_remove_obsolete(self, request) -> web.Response:
122 | """
123 | ---
124 | description: Remove obsolete config parameters
125 | tags:
126 | - Config
127 | responses:
128 | "200":
129 | description: successful operation
130 | """
131 | await self.controller.obsolete(True)
132 | return web.Response(status=200)
133 |
--------------------------------------------------------------------------------
/cbpi/job/_job.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 | import sys
3 |
4 | # import async_timeout
5 | import time
6 | import traceback
7 |
8 |
9 | class Job:
10 | _source_traceback = None
11 | _closed = False
12 | _explicit = False
13 | _task = None
14 |
15 | def __init__(self, coro, name, type, scheduler, loop):
16 | self._loop = loop
17 | self.name = name
18 | self.type = type
19 | self.start_time = time.time()
20 | self._coro = coro
21 | self._scheduler = scheduler
22 | self._started = loop.create_future()
23 |
24 | if loop.get_debug():
25 | self._source_traceback = traceback.extract_stack(sys._getframe(2))
26 |
27 | def __repr__(self):
28 | info = []
29 | if self._closed:
30 | info.append("closed")
31 | elif self._task is None:
32 | info.append("pending")
33 | info = " ".join(info)
34 | if info:
35 | info += " "
36 | return ">".format(info, self._coro)
37 |
38 | @property
39 | def active(self):
40 | return not self.closed and not self.pending
41 |
42 | @property
43 | def pending(self):
44 | return self._task is None and not self.closed
45 |
46 | @property
47 | def closed(self):
48 | return self._closed
49 |
50 | async def _do_wait(self, timeout):
51 | with asyncio.timeout(timeout=timeout, loop=self._loop):
52 | # TODO: add a test for waiting for a pending coro
53 | await self._started
54 | return await self._task
55 |
56 | async def wait(self, *, timeout=None):
57 | if self._closed:
58 | return
59 | self._explicit = True
60 | scheduler = self._scheduler
61 | try:
62 | return await asyncio.shield(self._do_wait(timeout), loop=self._loop)
63 | except asyncio.CancelledError:
64 | # Don't stop inner coroutine on explicit cancel
65 | raise
66 | except Exception:
67 | await self._close(scheduler.close_timeout)
68 | raise
69 |
70 | async def close(self, *, timeout=None):
71 | if self._closed:
72 | return
73 | self._explicit = True
74 | if timeout is None:
75 | timeout = self._scheduler.close_timeout
76 | await self._close(timeout)
77 |
78 | async def _close(self, timeout):
79 | self._closed = True
80 | if self._task is None:
81 | # the task is closed immediately without actual execution
82 | # it prevents a warning like
83 | # RuntimeWarning: coroutine 'coro' was never awaited
84 | self._start()
85 | if not self._task.done():
86 | self._task.cancel()
87 | # self._scheduler is None after _done_callback()
88 | scheduler = self._scheduler
89 | try:
90 | with asyncio.timeout(timeout=timeout, loop=self._loop):
91 | await self._task
92 | except asyncio.CancelledError:
93 | pass
94 | except asyncio.TimeoutError as exc:
95 | if self._explicit:
96 | raise
97 | context = {
98 | "message": "Job closing timed out",
99 | "job": self,
100 | "exception": exc,
101 | }
102 | if self._source_traceback is not None:
103 | context["source_traceback"] = self._source_traceback
104 | scheduler.call_exception_handler(context)
105 | except Exception as exc:
106 | if self._explicit:
107 | raise
108 | self._report_exception(exc)
109 |
110 | def _start(self):
111 | assert self._task is None
112 | self._task = self._loop.create_task(self._coro)
113 | self._task.add_done_callback(self._done_callback)
114 | self._started.set_result(None)
115 |
116 | def _done_callback(self, task):
117 |
118 | scheduler = self._scheduler
119 | scheduler._done(self)
120 | try:
121 | exc = task.exception()
122 | except asyncio.CancelledError:
123 | pass
124 | else:
125 | if exc is not None and not self._explicit:
126 | self._report_exception(exc)
127 | scheduler._failed_tasks.put_nowait(task)
128 | self._scheduler = None # drop backref
129 | self._closed = True
130 |
131 | def _report_exception(self, exc):
132 | context = {"message": "Job processing failed", "job": self, "exception": exc}
133 | if self._source_traceback is not None:
134 | context["source_traceback"] = self._source_traceback
135 | self._scheduler.call_exception_handler(context)
136 |
--------------------------------------------------------------------------------
/cbpi/controller/config_controller.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 | import os
4 | from pathlib import Path
5 |
6 | from cbpi.api.config import ConfigType
7 | from cbpi.api.dataclasses import Config
8 | from cbpi.utils import load_config
9 |
10 |
11 | class ConfigController:
12 |
13 | def __init__(self, cbpi):
14 | self.cache = {}
15 | self.logger = logging.getLogger(__name__)
16 | self.cbpi = cbpi
17 | self.cbpi.register(self)
18 | self.path = cbpi.config_folder.get_file_path("config.json")
19 | self.path_static = cbpi.config_folder.get_file_path("config.yaml")
20 | self.logger.info(
21 | "Config folder path : "
22 | + os.path.join(Path(self.cbpi.config_folder.configFolderPath).absolute())
23 | )
24 |
25 | def get_state(self):
26 | result = {}
27 | for key, value in self.cache.items():
28 | result[key] = value.to_dict()
29 | return result
30 |
31 | async def init(self):
32 | self.static = load_config(self.path_static)
33 | with open(self.path) as json_file:
34 | data = json.load(json_file)
35 | for key, value in data.items():
36 | self.cache[key] = Config(
37 | name=value.get("name"),
38 | value=value.get("value"),
39 | description=value.get("description"),
40 | type=ConfigType(value.get("type", "string")),
41 | source=value.get("source", "craftbeerpi"),
42 | options=value.get("options", None),
43 | )
44 |
45 | def get(self, name, default=None):
46 | self.logger.debug("GET CONFIG VALUE %s (default %s)" % (name, default))
47 | if (
48 | name in self.cache
49 | and self.cache[name].value is not None
50 | and self.cache[name].value != ""
51 | ):
52 | return self.cache[name].value
53 | else:
54 | return default
55 |
56 | async def set(self, name, value):
57 | if name in self.cache:
58 |
59 | self.cache[name].value = value
60 |
61 | data = {}
62 | for key, value in self.cache.items():
63 | data[key] = value.to_dict()
64 | with open(self.path, "w") as file:
65 | json.dump(data, file, indent=4, sort_keys=True)
66 |
67 | async def add(
68 | self,
69 | name,
70 | value,
71 | type: ConfigType,
72 | description,
73 | source="craftbeerpi",
74 | options=None,
75 | ):
76 | self.cache[name] = Config(name, value, description, type, source, options)
77 | data = {}
78 | for key, value in self.cache.items():
79 | data[key] = value.to_dict()
80 | with open(self.path, "w") as file:
81 | json.dump(data, file, indent=4, sort_keys=True)
82 |
83 | async def remove(self, name):
84 | data = {}
85 | self.testcache = {}
86 | success = False
87 | for key, value in self.cache.items():
88 | try:
89 | if key != name:
90 | data[key] = value.to_dict()
91 | self.testcache[key] = Config(
92 | name=data[key].get("name"),
93 | value=data[key].get("value"),
94 | description=data[key].get("description"),
95 | type=ConfigType(data[key].get("type", "string")),
96 | options=data[key].get("options", None),
97 | source=data[key].get("source", "craftbeerpi"),
98 | )
99 | success = True
100 | except Exception as e:
101 | print(e)
102 | success = False
103 | if success == True:
104 | with open(self.path, "w") as file:
105 | json.dump(data, file, indent=4, sort_keys=True)
106 | self.cache = self.testcache
107 |
108 | async def obsolete(self, remove=False):
109 | result = {}
110 | for key, value in self.cache.items():
111 | if value.source not in ("craftbeerpi", "steps", "hidden"):
112 | test = await self.cbpi.plugin.load_plugin_list(value.source)
113 | if test == []:
114 | update = self.get(str(value.source) + "_update")
115 | if update:
116 | result[str(value.source) + "_update"] = {"value": update}
117 | if remove:
118 | await self.remove(str(value.source) + "_update")
119 | if remove:
120 | await self.remove(key)
121 | result[key] = value.to_dict()
122 | return result
123 |
--------------------------------------------------------------------------------
/cbpi/job/_scheduler.py:
--------------------------------------------------------------------------------
1 | import asyncio
2 |
3 | from ._job import Job
4 |
5 | try:
6 | from collections.abc import Collection
7 | except ImportError: # pragma: no cover
8 | # Python 3.5 has no Collection ABC class
9 | from collections.abc import Container, Iterable, Sized
10 |
11 | bases = Sized, Iterable, Container
12 | else: # pragma: no cover
13 | bases = (Collection,)
14 |
15 |
16 | class Scheduler(*bases):
17 | def __init__(
18 | self, cbpi, *, close_timeout, limit, pending_limit, exception_handler, loop
19 | ):
20 | self._loop = loop
21 | self.cbpi = cbpi
22 | self._jobs = set()
23 | self._close_timeout = close_timeout
24 | self._limit = limit
25 | self._exception_handler = exception_handler
26 | self._failed_tasks = asyncio.Queue()
27 | self._failed_task = loop.create_task(self._wait_failed())
28 | self._pending = asyncio.Queue(maxsize=pending_limit)
29 | self._closed = False
30 |
31 | def __iter__(self):
32 | return iter(list(self._jobs))
33 |
34 | def __len__(self):
35 | return len(self._jobs)
36 |
37 | def __contains__(self, job):
38 | return job in self._jobs
39 |
40 | def __repr__(self):
41 | info = []
42 | if self._closed:
43 | info.append("closed")
44 | info = " ".join(info)
45 | if info:
46 | info += " "
47 | return "".format(info, len(self))
48 |
49 | @property
50 | def limit(self):
51 | return self._limit
52 |
53 | @property
54 | def pending_limit(self):
55 | return self._pending.maxsize
56 |
57 | @property
58 | def close_timeout(self):
59 | return self._close_timeout
60 |
61 | @property
62 | def active_count(self):
63 | return len(self._jobs) - self._pending.qsize()
64 |
65 | @property
66 | def pending_count(self):
67 | return self._pending.qsize()
68 |
69 | @property
70 | def closed(self):
71 | return self._closed
72 |
73 | async def spawn(self, coro, name=None, type=None):
74 | if self._closed:
75 | raise RuntimeError("Scheduling a new job after closing")
76 | job = Job(coro, name, type, self, self._loop)
77 | should_start = self._limit is None or self.active_count < self._limit
78 | self._jobs.add(job)
79 | if should_start:
80 | job._start()
81 | else:
82 | # wait for free slot in queue
83 | await self._pending.put(job)
84 | return job
85 |
86 | async def close(self):
87 | if self._closed:
88 | return
89 | self._closed = True # prevent adding new jobs
90 |
91 | jobs = self._jobs
92 | if jobs:
93 | # cleanup pending queue
94 | # all job will be started on closing
95 | while not self._pending.empty():
96 | self._pending.get_nowait()
97 | await asyncio.gather(
98 | *[job._close(self._close_timeout) for job in jobs],
99 | loop=self._loop,
100 | return_exceptions=True
101 | )
102 | self._jobs.clear()
103 | self._failed_tasks.put_nowait(None)
104 | await self._failed_task
105 |
106 | def call_exception_handler(self, context):
107 | handler = self._exception_handler
108 | if handler is None:
109 | handler = self._loop.call_exception_handler(context)
110 | else:
111 | handler(self, context)
112 |
113 | @property
114 | def exception_handler(self):
115 | return self._exception_handler
116 |
117 | def _done(self, job):
118 |
119 | self.cbpi.bus.sync_fire("job/%s/done" % job.type, type=job.type, key=job.name)
120 | self._jobs.discard(job)
121 | if not self.pending_count:
122 | return
123 | # No pending jobs when limit is None
124 | # Safe to subtract.
125 | ntodo = self._limit - self.active_count
126 | i = 0
127 | while i < ntodo:
128 | if not self.pending_count:
129 | return
130 | new_job = self._pending.get_nowait()
131 | if new_job.closed:
132 | continue
133 | new_job._start()
134 | i += 1
135 |
136 | def is_running(self, name):
137 |
138 | for j in self._jobs:
139 | if name == j.name:
140 | return True
141 | return False
142 |
143 | async def _wait_failed(self):
144 | # a coroutine for waiting failed tasks
145 | # without awaiting for failed tasks async raises a warning
146 | while True:
147 | task = await self._failed_tasks.get()
148 | if task is None:
149 | return # closing
150 | try:
151 | await task # should raise exception
152 | except Exception:
153 | # Cleanup a warning
154 | # self.call_exception_handler() is already called
155 | # by Job._add_done_callback
156 | # Thus we caught an task exception and we are good citizens
157 | pass
158 |
--------------------------------------------------------------------------------
/cbpi/http_endpoints/http_recipe.py:
--------------------------------------------------------------------------------
1 | from aiohttp import web
2 | from cbpi.api import *
3 | from cbpi.api.dataclasses import Props, Step
4 | from cbpi.controller.recipe_controller import RecipeController
5 |
6 |
7 | class RecipeHttpEndpoints:
8 |
9 | def __init__(self, cbpi):
10 | self.cbpi = cbpi
11 | self.controller: RecipeController = cbpi.recipe
12 | self.cbpi.register(self, "/recipe")
13 |
14 | @request_mapping(path="/", method="GET", auth_required=False)
15 | async def http_get_all(self, request):
16 | """
17 | ---
18 | description: Get all recipes
19 | tags:
20 | - Recipe
21 | responses:
22 | "200":
23 | description: successful operation
24 | """
25 | return web.json_response(await self.controller.get_recipes())
26 |
27 | @request_mapping(path="/{name}", method="GET", auth_required=False)
28 | async def get_by_name(self, request):
29 | """
30 | ---
31 | description: Get all recipes
32 | tags:
33 | - Recipe
34 | parameters:
35 | - name: "name"
36 | in: "path"
37 | description: "Recipe Name"
38 | required: true
39 | type: "string"
40 | responses:
41 | "200":
42 | description: successful operation
43 | """
44 | name = request.match_info["name"]
45 | return web.json_response(await self.controller.get_by_name(name))
46 |
47 | @request_mapping(path="/create", method="POST", auth_required=False)
48 | async def http_create(self, request):
49 | """
50 | ---
51 | description: Add Recipe
52 | tags:
53 | - Recipe
54 |
55 | responses:
56 | "200":
57 | description: successful operation
58 | """
59 | data = await request.json()
60 | # print(data)
61 | return web.json_response(
62 | dict(id=await self.controller.create(data.get("name")))
63 | )
64 |
65 | @request_mapping(path="/{name}", method="PUT", auth_required=False)
66 | async def http_save(self, request):
67 | """
68 | ---
69 | description: Save Recipe
70 | tags:
71 | - Recipe
72 | parameters:
73 | - name: "id"
74 | in: "path"
75 | description: "Recipe Id"
76 | required: true
77 | type: "string"
78 | - in: body
79 | name: body
80 | description: Recipe Data
81 | required: false
82 | schema:
83 | type: object
84 |
85 | responses:
86 | "200":
87 | description: successful operation
88 | """
89 | data = await request.json()
90 | name = request.match_info["name"]
91 | await self.controller.save(name, data)
92 | # print(data)
93 | return web.Response(status=204)
94 |
95 | @request_mapping(path="/{name}", method="DELETE", auth_required=False)
96 | async def http_remove(self, request):
97 | """
98 | ---
99 | description: Delete
100 | tags:
101 | - Recipe
102 | parameters:
103 | - name: "id"
104 | in: "path"
105 | description: "Recipe Id"
106 | required: true
107 | type: "string"
108 |
109 |
110 | responses:
111 | "200":
112 | description: successful operation
113 | """
114 | name = request.match_info["name"]
115 | await self.controller.remove(name)
116 | return web.Response(status=204)
117 |
118 | @request_mapping(path="/{name}/brew", method="POST", auth_required=False)
119 | async def http_brew(self, request):
120 | """
121 | ---
122 | description: Brew
123 | tags:
124 | - Recipe
125 | parameters:
126 | - name: "name"
127 | in: "path"
128 | description: "Recipe Id"
129 | required: true
130 | type: "string"
131 |
132 |
133 | responses:
134 | "200":
135 | description: successful operation
136 | """
137 | name = request.match_info["name"]
138 | await self.controller.brew(name)
139 | return web.Response(status=204)
140 |
141 | @request_mapping(path="/{id}/clone", method="POST", auth_required=False)
142 | async def http_clone(self, request):
143 | """
144 | ---
145 | description: Brew
146 | tags:
147 | - Recipe
148 | parameters:
149 | - name: "id"
150 | in: "path"
151 | description: "Recipe Id"
152 | required: true
153 | type: "string"
154 | - in: body
155 | name: body
156 | description: Recipe Data
157 | required: false
158 | schema:
159 | type: object
160 | responses:
161 | "200":
162 | description: successful operation
163 | """
164 | id = request.match_info["id"]
165 | data = await request.json()
166 |
167 | return web.json_response(
168 | dict(id=await self.controller.clone(id, data.get("name")))
169 | )
170 |
--------------------------------------------------------------------------------