├── 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 | 4 | 5 | 6 | 7 | 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 | 4 | 5 | 6 | 7 | 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 | 4 | 5 | 6 | 7 | 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 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /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 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 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 | 4 | 5 | 6 | 7 | 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 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /cbpi/static/calculator_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /cbpi/static/svg_icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | [![Build](https://github.com/PiBrewing/craftbeerpi4/actions/workflows/build.yml/badge.svg)](https://github.com/PiBrewing/craftbeerpi4/actions/workflows/build.yml) 4 | [![GitHub license](https://img.shields.io/github/license/PiBrewing/craftbeerpi4)](https://github.com/PiBrewing/craftbeerpi4/blob/master/LICENSE) 5 | ![GitHub issues](https://img.shields.io/github/issues-raw/PiBrewing/craftbeerpi4) 6 | [![GitHub Activity](https://img.shields.io/github/commit-activity/y/PiBrewing/craftbeerpi4.svg?label=commits)](https://github.com/PiBrewing/craftbeerpi4/commits) 7 | ![PyPI](https://img.shields.io/pypi/v/cbpi4) 8 | ![Happy Brewing](https://img.shields.io/badge/CraftBeerPi%204-Happy%20Brewing-%23FBB117) 9 | 10 |

11 | CraftBeerPi Logo 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 | [![contributors](https://contributors-img.web.app/image?repo=PiBrewing/craftbeerpi4)](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 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /cbpi/static/grain.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 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 | 4 | 5 | 6 | 7 | 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 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 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 | 4 | 5 | 6 | 7 | 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 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 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 | 4 | 5 | 6 | 7 | 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 | --------------------------------------------------------------------------------