diff --git a/cbpi/__init__.py b/cbpi/__init__.py index 291f229..b809b20 100644 --- a/cbpi/__init__.py +++ b/cbpi/__init__.py @@ -1,3 +1,3 @@ -__version__ = "4.4.1.rc1" +__version__ = "4.4.3" __codename__ = "Yeast Starter" diff --git a/cbpi/cli.py b/cbpi/cli.py index c4e21fa..8c99144 100644 --- a/cbpi/cli.py +++ b/cbpi/cli.py @@ -8,6 +8,11 @@ from cbpi.utils.utils import load_config from zipfile import ZipFile from cbpi.craftbeerpi import CraftBeerPi import os +try: + import pwd + module_pwd=True +except: + module_pwd=False import pkgutil import shutil import click @@ -165,7 +170,8 @@ class CraftBeerPiCli(): else: print("CraftBeerPi Autostart is {}OFF{}".format(Fore.RED,Style.RESET_ALL)) elif(name == "on"): - user=os.getlogin() + #user=os.getlogin() + user=pwd.getpwuid(os.getuid()).pw_name path="/usr/local/bin/cbpi" if os.path.exists("/home/"+user+"/.local/bin/cbpi") is True: path="/home/"+user+"/.local/bin/cbpi" diff --git a/cbpi/config/config.yaml b/cbpi/config/config.yaml index b15c777..eddc66d 100644 --- a/cbpi/config/config.yaml +++ b/cbpi/config/config.yaml @@ -11,6 +11,7 @@ mqtt_host: localhost mqtt_port: 1883 mqtt_username: "" mqtt_password: "" +mqtt_offset: false username: cbpi password: 123 diff --git a/cbpi/controller/upload_controller.py b/cbpi/controller/upload_controller.py index 2c54e4b..f3614dc 100644 --- a/cbpi/controller/upload_controller.py +++ b/cbpi/controller/upload_controller.py @@ -17,13 +17,12 @@ import os.path from os import listdir from os.path import isfile, join import json -import shortuuid +import math import yaml from ..api.step import StepMove, StepResult, StepState import re import base64 - class UploadController: def __init__(self, cbpi): @@ -70,9 +69,12 @@ class UploadController: return [] async def get_brewfather_recipes(self,offset=0): + limit = 50 + length = self.cbpi.config.get('brewfather_list_length',50) + repeat = True brewfather = True result=[] - self.url="https://api.brewfather.app/v1/recipes" + self.url="https://api.brewfather.app/v2/recipes" brewfather_user_id = self.cbpi.config.get("brewfather_user_id", None) if brewfather_user_id == "" or brewfather_user_id is None: brewfather = False @@ -84,25 +86,63 @@ class UploadController: if brewfather == True: encodedData = base64.b64encode(bytes(f"{brewfather_user_id}:{brewfather_api_key}", "ISO-8859-1")).decode("ascii") headers={"Authorization": "Basic %s" % encodedData} - parameters={"limit": 50, 'offset': offset} - async with aiohttp.ClientSession(headers=headers) as bf_session: - async with bf_session.get(self.url, params=parameters) as r: - bf_recipe_list = await r.json() - await bf_session.close() - - if bf_recipe_list: - for row in bf_recipe_list: - recipe_id = row['_id'] - name = row['name'] - element = {'value': recipe_id, 'label': name} - result.append(element) - return result - else: - return [] - - else: - return [] - + parameters={"limit": limit} + while repeat == True: + try: + async with aiohttp.ClientSession(headers=headers) as bf_session: + async with bf_session.get(self.url, params=parameters) as r: + if r.status == 429: + try: + seconds=int(r.headers['Retry-After']) + minutes=round(seconds/60) + except: + seconds=None + if not seconds: + logging.error("Too many requests to BF api. Try again later") + self.cbpi.notify("Error", "Too many requests to BF api. Try again later", NotificationType.ERROR) + else: + logging.error(f"Too many requests to BF api. Try in {minutes} minutes again.") + self.cbpi.notify("Error", f"Too many requests to BF api. Try in {minutes} minutes again.", NotificationType.ERROR) + repeat = False + logging.error(r.headers['Retry-After']) + else: + bf_recipe_list = await r.json() + await bf_session.close() + except Exception as e: + logging.error(e) + repeat = False + try: + if bf_recipe_list: + for row in bf_recipe_list: + recipe_id = row['_id'] + name = row['name'] + element = {'value': recipe_id, 'label': name} + result.append(element) + else: + repeat = False + except Exception as e: + logging.error(e) + try: + if len(bf_recipe_list) != limit: + repeat = False + else: + parameters={"limit": limit, 'start_after': recipe_id} + except Exception as e: + logging.error(e) + + try: + newlist = sorted(result, key=lambda d: d['label']) + listlength=len(newlist) + max=math.floor(listlength/length) + sortlist=[] + for i in range(0 , max+1): + sortlist.append({ 'value': i*length, 'label': i*length }) + return newlist, sortlist, length + except: + logging.error("Return empty BF recipe list") + sortlist=[{ 'value': 0, 'label': '0' }] + return result, sortlist, length + def get_creation_path(self): creation_path = self.cbpi.config.get("RECIPE_CREATION_PATH", "upload") @@ -738,7 +778,7 @@ class UploadController: brewfather = True result=[] - self.bf_url="https://api.brewfather.app/v1/recipes/" + Recipe_ID + self.bf_url="https://api.brewfather.app/v2/recipes/" + Recipe_ID brewfather_user_id = self.cbpi.config.get("brewfather_user_id", None) if brewfather_user_id == "" or brewfather_user_id is None: brewfather = False @@ -775,6 +815,21 @@ class UploadController: except: miscs = None + try: + fermentation_steps=bf_recipe['fermentation']['steps'] + except: + fermentation_steps=None + + if fermentation_steps is not None: + try: + step=fermentation_steps[0] + self.fermentation_step_temp=int(step['stepTemp']) + except: + self.fermentation_step_temp=None + + if self.fermentation_step_temp is not None and self.TEMP_UNIT != "C": + self.fermentation_step_temp = round((9.0 / 5.0 * float(self.fermentation_step_temp)+ 32)) + FirstWort = self.getFirstWort(hops, "bf") await self.create_recipe(RecipeName) @@ -1012,8 +1067,9 @@ class UploadController: cooldown_sensor = self.cbpi.config.get("steps_cooldown_sensor", None) if cooldown_sensor is None or cooldown_sensor == '': cooldown_sensor = self.boilkettle.sensor # fall back to boilkettle sensor if no other sensor is specified - step_timer = "" - step_temp = int(self.CoolDownTemp) + step_timer = "" + + step_temp = int(self.CoolDownTemp) if (self.fermentation_step_temp is None or self.fermentation_step_temp <= int(self.CoolDownTemp)) else self.fermentation_step_temp step_string = { "name": "Cooldown", "props": { "Kettle": self.boilid, diff --git a/cbpi/extension/ConfigUpdate/__init__.py b/cbpi/extension/ConfigUpdate/__init__.py index eba146a..ab532c2 100644 --- a/cbpi/extension/ConfigUpdate/__init__.py +++ b/cbpi/extension/ConfigUpdate/__init__.py @@ -8,7 +8,7 @@ import json from cbpi.api import * from cbpi.api.config import ConfigType from cbpi.api.base import CBPiBase -import glob +import glob, yaml from cbpi import __version__ logger = logging.getLogger(__name__) @@ -19,6 +19,12 @@ class ConfigUpdate(CBPiExtension): self.cbpi = cbpi self._task = asyncio.create_task(self.run()) + def append_to_yaml(self, file_path, data_to_append): + + with open(file_path[0], 'a+') as file: + file.seek(0) + yaml.dump(data_to_append, file, default_flow_style=False) + async def run(self): logging.info("Check Config for required changes") @@ -61,12 +67,13 @@ class ConfigUpdate(CBPiExtension): AddMashIn = self.cbpi.config.get("AddMashInStep", None) bfuserid = self.cbpi.config.get("brewfather_user_id", None) bfapikey = self.cbpi.config.get("brewfather_api_key", None) + bflistlength = self.cbpi.config.get("brewfather_list_length", None) RecipeCreationPath = self.cbpi.config.get("RECIPE_CREATION_PATH", None) BoilKettle = self.cbpi.config.get("BoilKettle", None) CONFIG_STATUS = self.cbpi.config.get("CONFIG_STATUS", None) self.version=__version__ current_grid = self.cbpi.config.get("current_grid", None) - + mqtt_offset=self.cbpi.static_config.get("mqtt_offset", None) if boil_temp is None: logger.info("INIT Boil Temp Setting") @@ -244,6 +251,21 @@ class ConfigUpdate(CBPiExtension): await self.cbpi.config.add("brewfather_api_key", "", type=ConfigType.STRING, description="Brewfather API Key", source="craftbeerpi") except: logger.warning('Unable to update config') + + ## Check if Brewfather API Key is in config + + if bflistlength is None: + logger.info("INIT Brewfather Recipe List Length") + try: + await self.cbpi.config.add("brewfather_list_length", 50, type=ConfigType.SELECT, description="Brewfather Recipe List length", + source="craftbeerpi", + options= [{"label": "5", "value": 5}, + {"label": "10", "value": 10}, + {"label": "25", "value": 25}, + {"label": "50", "value": 50}, + {"label": "100", "value": 100}]) + except: + logger.warning('Unable to update config') ## Check if Brewfather API Key is in config @@ -542,6 +564,16 @@ class ConfigUpdate(CBPiExtension): except Exception as e: logging.error(e) + if mqtt_offset is None: + logging.info("INIT MQTT Offset in static config") + try: + static_config_file=glob.glob(self.cbpi.config_folder.get_file_path('config.yaml')) + data_to_append = {'mqtt_offset': False} + self.append_to_yaml(static_config_file, data_to_append) + pass + except Exception as e: + logging.error(e) + logging.warning('Unable to update database') ## Check if influxdbname is in config if CONFIG_STATUS is None or CONFIG_STATUS != self.version: diff --git a/cbpi/extension/mashstep/__init__.py b/cbpi/extension/mashstep/__init__.py index 3948abb..3ea7f0e 100644 --- a/cbpi/extension/mashstep/__init__.py +++ b/cbpi/extension/mashstep/__init__.py @@ -536,7 +536,7 @@ class CooldownStep(CBPiStep): if time.time() >= self.next_check: self.next_check = time.time() + (self.Interval * 60) - cooldown_model = np.poly1d(np.polyfit(self.temp_array, self.time_array, 2)) + cooldown_model = np.polynomial.polynomial.Polynomial.fit(self.temp_array, self.time_array, 2) target_time=cooldown_model(self.target_temp) target_timestring= datetime.fromtimestamp(target_time) self.summary="ECT: {}".format(target_timestring.strftime("%H:%M")) diff --git a/cbpi/extension/mqtt_sensor/__init__.py b/cbpi/extension/mqtt_sensor/__init__.py index 68db819..9dc6d25 100644 --- a/cbpi/extension/mqtt_sensor/__init__.py +++ b/cbpi/extension/mqtt_sensor/__init__.py @@ -8,16 +8,17 @@ import json import time from datetime import datetime + @parameters([Property.Text(label="Topic", configurable=True, description="MQTT Topic"), - Property.Text(label="PayloadDictionary", configurable=True, default_value="", - description="Where to find msg in payload, leave blank for raw payload"), - Property.Kettle(label="Kettle", description="Reduced logging if Kettle is inactive / range warning in dashboard (only Kettle or Fermenter to be selected)"), - Property.Fermenter(label="Fermenter", description="Reduced logging if Fermenter is inactive / range warning in dashboard (only Kettle or Fermenter to be selected)"), - Property.Number(label="ReducedLogging", configurable=True, description="Reduced logging frequency in seconds if selected Kettle or Fermenter is inactive (default:60 sec | 0 disabled)"), - Property.Number(label="Timeout", configurable=True, unit="sec", - description="Timeout in seconds to send notification (default:60 | deactivated: 0)"), - Property.Number(label="TempRange", configurable=True, unit="degree", - description="Temp range in degree between reading and target temp of fermenter/kettle. Larger difference shows different color in dashboard (default:0 | deactivated: 0)")]) + Property.Text(label="PayloadDictionary", configurable=True, default_value="", + description="Where to find msg in payload, leave blank for raw payload"), + Property.Kettle(label="Kettle", description="Reduced logging if Kettle is inactive / range warning in dashboard (only Kettle or Fermenter to be selected)"), + Property.Fermenter(label="Fermenter", description="Reduced logging if Fermenter is inactive / range warning in dashboard (only Kettle or Fermenter to be selected)"), + Property.Number(label="ReducedLogging", configurable=True, description="Reduced logging frequency in seconds if selected Kettle or Fermenter is inactive (default:60 sec | 0 disabled)"), + Property.Number(label="Timeout", configurable=True, unit="sec", + description="Timeout in seconds to send notification (default:60 | deactivated: 0)"), + Property.Number(label="TempRange", configurable=True, unit="degree", + description="Temp range in degree between reading and target temp of fermenter/kettle. Larger difference shows different color in dashboard (default:0 | deactivated: 0)")]) class MQTTSensor(CBPiSensor): def __init__(self, cbpi, id, props): @@ -135,6 +136,135 @@ class MQTTSensor(CBPiSensor): async def on_stop(self): self.subscribed = self.cbpi.satellite.unsubscribe(self.Topic, self.on_message) +@parameters([Property.Text(label="Topic", configurable=True, description="MQTT Topic"), + Property.Text(label="PayloadDictionary", configurable=True, default_value="", + description="Where to find msg in payload, leave blank for raw payload"), + Property.Kettle(label="Kettle", description="Reduced logging if Kettle is inactive / range warning in dashboard (only Kettle or Fermenter to be selected)"), + Property.Fermenter(label="Fermenter", description="Reduced logging if Fermenter is inactive / range warning in dashboard (only Kettle or Fermenter to be selected)"), + Property.Number(label="Offset", configurable=True, description="Offset for MQTT Sensor (default is 0). !!! Use this only with caution as offset for MQTT sensor should be defined on Sensor side !!!"), + Property.Number(label="ReducedLogging", configurable=True, description="Reduced logging frequency in seconds if selected Kettle or Fermenter is inactive (default:60 sec | 0 disabled)"), + Property.Number(label="Timeout", configurable=True, unit="sec", + description="Timeout in seconds to send notification (default:60 | deactivated: 0)"), + Property.Number(label="TempRange", configurable=True, unit="degree", + description="Temp range in degree between reading and target temp of fermenter/kettle. Larger difference shows different color in dashboard (default:0 | deactivated: 0)")]) +class MQTTSensorOffset(CBPiSensor): + + def __init__(self, cbpi, id, props): + super(MQTTSensorOffset, self).__init__(cbpi, id, props) + self.Topic = self.props.get("Topic", None) + self.offset = float(self.props.get("Offset", 0)) + self.payload_text = self.props.get("PayloadDictionary", None) + if self.payload_text != None: + self.payload_text = self.payload_text.split('.') + self.subscribed = self.cbpi.satellite.subscribe(self.Topic, self.on_message) + self.value: float = 999 + self.timeout=int(self.props.get("Timeout", 60)) + self.temprange=float(self.props.get("TempRange", 0)) + self.starttime = time.time() + self.notificationsend = False + self.nextchecktime=self.starttime+self.timeout + self.lastdata=time.time() + self.lastlog=0 + self.sensor=self.get_sensor(self.id) + self.reducedfrequency=int(self.props.get("ReducedLogging", 60)) + if self.reducedfrequency < 0: + self.reducedfrequency = 0 + self.kettleid=self.props.get("Kettle", None) + self.fermenterid=self.props.get("Fermenter", None) + self.reducedlogging = True if self.kettleid or self.fermenterid else False + + if self.kettleid is not None and self.fermenterid is not None: + self.reducedlogging=False + self.cbpi.notify("MQTTSensor", "Sensor '" + str(self.sensor.name) + "' cant't have Fermenter and Kettle defined for reduced logging / range warning.", NotificationType.WARNING, action=[NotificationAction("OK", self.Confirm)]) + + async def Confirm(self, **kwargs): + self.nextchecktime = time.time() + self.timeout + self.notificationsend = False + pass + + async def message(self): + target_timestring= datetime.fromtimestamp(self.lastdata) + self.cbpi.notify("MQTTSensor Timeout", "Sensor '" + str(self.sensor.name) + "' did not respond. Last data received: "+target_timestring.strftime("%D %H:%M"), NotificationType.WARNING, action=[NotificationAction("OK", self.Confirm)]) + pass + + async def on_message(self, message): + val = json.loads(message.payload.decode()) + try: + if self.payload_text is not None: + for key in self.payload_text: + val = val.get(key, None) + + if isinstance(val, (int, float, str)): + self.value = float(val)+self.offset + self.push_update(self.value) + if self.reducedlogging == True: + await self.logvalue() + else: + logging.info("MQTTSensor {} regular logging".format(self.sensor.name)) + self.log_data(self.value) + self.lastlog = time.time() + + if self.timeout !=0: + self.nextchecktime = time.time() + self.timeout + self.notificationsend = False + self.lastdata=time.time() + except Exception as e: + logging.error("MQTT Sensor Error {}".format(e)) + + async def logvalue(self): + self.kettle = self.get_kettle(self.kettleid) if self.kettleid is not None else None + self.fermenter = self.get_fermenter(self.fermenterid) if self.fermenterid is not None else None + now=time.time() + if self.kettle is not None: + try: + kettlestatus=self.kettle.instance.state + except: + kettlestatus=False + if kettlestatus: + self.log_data(self.value) + logging.info("MQTTSensor {} Kettle Active".format(self.sensor.name)) + self.lastlog = time.time() + else: + logging.info("MQTTSensor {} Kettle Inactive".format(self.sensor.name)) + if self.reducedfrequency != 0: + if now >= self.lastlog + self.reducedfrequency: + self.log_data(self.value) + self.lastlog = time.time() + logging.info("Logged with reduced freqency") + pass + + if self.fermenter is not None: + try: + fermenterstatus=self.fermenter.instance.state + except: + fermenterstatus=False + if fermenterstatus: + self.log_data(self.value) + logging.info("MQTTSensor {} Fermenter Active".format(self.sensor.name)) + self.lastlog = time.time() + else: + logging.info("MQTTSensor {} Fermenter Inactive".format(self.sensor.name)) + if self.reducedfrequency != 0: + if now >= self.lastlog + self.reducedfrequency: + self.log_data(self.value) + self.lastlog = time.time() + logging.info("Logged with reduced freqency") + pass + + async def run(self): + while self.running: + if self.timeout !=0: + if time.time() > self.nextchecktime and self.notificationsend == False: + await self.message() + self.notificationsend=True + await asyncio.sleep(1) + + def get_state(self): + return dict(value=self.value) + + async def on_stop(self): + self.subscribed = self.cbpi.satellite.unsubscribe(self.Topic, self.on_message) + def setup(cbpi): ''' @@ -145,4 +275,7 @@ def setup(cbpi): :return: ''' if str(cbpi.static_config.get("mqtt", False)).lower() == "true": - cbpi.plugin.register("MQTTSensor", MQTTSensor) + if str(cbpi.static_config.get("mqtt_offset", False)).lower() == "false": + cbpi.plugin.register("MQTTSensor", MQTTSensor) + else: + cbpi.plugin.register("MQTTSensor", MQTTSensorOffset) diff --git a/cbpi/http_endpoints/http_system.py b/cbpi/http_endpoints/http_system.py index 7a556d2..3e1ff07 100644 --- a/cbpi/http_endpoints/http_system.py +++ b/cbpi/http_endpoints/http_system.py @@ -43,6 +43,7 @@ class SystemHttpEndpoints: fermentersteps=self.cbpi.fermenter.get_fermenter_steps(), config=self.cbpi.config.get_state(), notifications=self.cbpi.notification.get_state(), + bf_recipes=await self.cbpi.upload.get_brewfather_recipes(0), version=__version__, guiversion=version, codename=__codename__) diff --git a/cbpi/http_endpoints/http_upload.py b/cbpi/http_endpoints/http_upload.py index 2df6fed..9bf52d4 100644 --- a/cbpi/http_endpoints/http_upload.py +++ b/cbpi/http_endpoints/http_upload.py @@ -159,6 +159,23 @@ class UploadHttpEndpoints(): return web.json_response(bf_list) + @request_mapping(path='/bfupdate/', method="GET", auth_required=False) + async def get_bf_update(self, request): + """ + + --- + description: Get recipe list update from Brewfather App + tags: + - Upload + responses: + "200": + description: successful operation + """ + #offset = request.match_info['offset'] + bf_list = await self.controller.get_brewfather_recipes() + self.cbpi.ws.send(dict(topic="bfupdate", data=bf_list)) + return web.Response(status=200) + @request_mapping(path='/bf', method="POST", auth_required=False) async def create_bf_recipe(self, request): """ diff --git a/requirements.txt b/requirements.txt index 806ce15..42e0495 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,5 @@ typing-extensions>=4 -aiohttp==3.9.4 +aiohttp==3.9.5 aiohttp-auth==0.1.1 aiohttp-route-decorator==0.1.4 aiohttp-security==0.5.0 @@ -8,7 +8,7 @@ aiohttp-swagger==1.0.16 async-timeout==4.0.3 aiojobs==1.2.1 aiosqlite==0.17.0 -cryptography==42.0.5 +cryptography==42.0.8 pyopenssl==24.1.0 requests==2.32.2 voluptuous==0.14.2 @@ -16,12 +16,12 @@ pyfiglet==1.0.2 pandas==2.2.2 shortuuid==1.0.13 tabulate==0.9.0 -numpy==1.26.4 +numpy==2.0.0 cbpi4gui click==8.1.7 importlib_metadata==4.11.1 -aiomqtt==2.1.0 -psutil==5.9.8 +aiomqtt==2.2.0 +psutil==6.0.0 zipp>=0.5 colorama==0.4.6 pytest-aiohttp diff --git a/setup.py b/setup.py index 266bf84..fa17ff5 100644 --- a/setup.py +++ b/setup.py @@ -39,7 +39,7 @@ setup(name='cbpi4', long_description_content_type='text/markdown', install_requires=[ "typing-extensions>=4", - "aiohttp==3.9.4", + "aiohttp==3.9.5", "aiohttp-auth==0.1.1", "aiohttp-route-decorator==0.1.4", "aiohttp-security==0.5.0", @@ -48,7 +48,7 @@ setup(name='cbpi4', "async-timeout==4.0.3", "aiojobs==1.2.1 ", "aiosqlite==0.17.0", - "cryptography==42.0.5", + "cryptography==42.0.8", "pyopenssl==24.1.0", "requests==2.32.2", "voluptuous==0.14.2", @@ -56,13 +56,13 @@ setup(name='cbpi4', 'click==8.1.7', 'shortuuid==1.0.13', 'tabulate==0.9.0', - 'aiomqtt==2.1.0', + 'aiomqtt==2.2.0', 'inquirer==3.2.4', 'colorama==0.4.6', - 'psutil==5.9.8', + 'psutil==6.0.0', 'cbpi4gui', 'importlib_metadata', - 'numpy==1.26.4', + 'numpy==2.0.0', 'pandas==2.2.2'] + ( ['rpi-lgpio'] if raspberrypi else [] ) + ( ['systemd-python'] if localsystem == "Linux" else [] ),