Merge pull request #143 from PiBrewing/development

Development
This commit is contained in:
Alexander Vollkopf 2024-07-13 12:31:24 +02:00 committed by GitHub
commit 8b42600781
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 296 additions and 50 deletions

View file

@ -1,3 +1,3 @@
__version__ = "4.4.1.rc1"
__version__ = "4.4.3"
__codename__ = "Yeast Starter"

View file

@ -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"

View file

@ -11,6 +11,7 @@ mqtt_host: localhost
mqtt_port: 1883
mqtt_username: ""
mqtt_password: ""
mqtt_offset: false
username: cbpi
password: 123

View file

@ -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,

View file

@ -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:

View file

@ -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"))

View file

@ -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)

View file

@ -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__)

View file

@ -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):
"""

View file

@ -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

View file

@ -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 [] ),