From 49bb3071debbed62c981c2567252094e1776bae1 Mon Sep 17 00:00:00 2001 From: LBussy Date: Wed, 5 Aug 2020 18:15:36 -0500 Subject: [PATCH 01/22] Minor changes, format with black --- brewpi.py | 2485 +++++++++++++++++++++++++++++------------------------ 1 file changed, 1349 insertions(+), 1136 deletions(-) diff --git a/brewpi.py b/brewpi.py index 4cda30a..97d1e9d 100755 --- a/brewpi.py +++ b/brewpi.py @@ -33,7 +33,9 @@ # Standard Imports import _thread from distutils.version import LooseVersion -import urllib.request, urllib.parse, urllib.error +import urllib.request +import urllib.parse +import urllib.error import traceback import shutil from pprint import pprint @@ -46,9 +48,14 @@ import pwd import grp import serial +import git +import argparse import simplejson as json from configobj import ConfigObj -import socket, asyncio, sys +import socket +import asyncio +import sys +import Tilt from struct import pack, unpack, calcsize import temperatureProfile import programController as programmer @@ -64,25 +71,22 @@ from backgroundserial import BackGroundSerial import BrewConvert -if sys.version_info < (3, 7): # Check needed software dependencies - print("\nSorry, requires Python 3.7+.", file=sys.stderr) - sys.exit(1) - -#### ******************************************************************** +# ******************************************************************** #### -#### IMPORTANT NOTE: I don't care if you play with the code, but if -#### you do, please comment out the next lines. Otherwise I will -#### receive a notice for every mistake you make. +# IMPORTANT NOTE: I don't care if you play with the code, but if +# you do, please comment out the next lines. Otherwise I will +# receive a notice for every mistake you make. #### -#### ******************************************************************** +# ******************************************************************** #import sentry_sdk -#sentry_sdk.init("https://5644cfdc9bd24dfbaadea6bc867a8f5b@sentry.io/1803681") +# sentry_sdk.init("https://5644cfdc9bd24dfbaadea6bc867a8f5b@sentry.io/1803681") +hwVersion = None compatibleHwVersion = "0.2.4" # Change directory to where the script is -os.chdir(os.path.dirname(sys.argv[0])) +# os.chdir(os.path.dirname(sys.argv[0])) # Settings will be read from controller, initialize with same defaults as # controller. This is mainly to show what's expected. Will all be overwritten @@ -110,47 +114,117 @@ # installed (d) and available (h) deviceList = dict(listState="", installed=[], available=[]) -# Read in command line arguments -try: - opts, args = getopt.getopt(sys.argv[1:], "hc:sqkfld", ['help', 'config=', - 'status', 'quit', 'kill', 'force', 'log', 'dontrunfile', - 'checkstartuponly']) -except getopt.GetoptError: - print("Unknown parameter, available Options: --help, --config ,\n", - " --status, --quit, --kill, --force, --log,\n", - " --dontrunfile", file=sys.stderr) - sys.exit(1) - +version = "0.0.0" +branch = "unknown" +commit = "unknown" +localPath = os.path.dirname(os.path.realpath(__file__)) configFile = None +config = None +dontRunFilePath = None checkDontRunFile = False checkStartupOnly = False logToFiles = False +logPath = None +outputJson = None # Print JSON to logs +localJsonFileName = None +localCsvFileName = None +wwwJsonFileName = None +wwwCsvFileName = None +lastDay = None +day = None +thread = False +threads = [] +tilt = None +ispindel = None +tiltbridge = False + +# Timestamps to expire values +lastBbApi = 0 +timeoutBB = 300 +lastiSpindel = 0 +timeoutiSpindel = 1800 +lastTiltbridge = 0 +timeoutTiltbridge = 300 +# Keep track of time between new data requests +prevDataTime = 0 +prevTimeOut = 0 +prevLcdUpdate = 0 +prevSettingsUpdate = 0 + +serialCheckInterval = 0.5 # Blocking socket functions wait in seconds +phpSocket = None # Listening socket to communicate with PHP +serialConn = None # Serial connection to communicate with controller +bgSerialConn = None # For background serial processing, put whole lines in a queue + +# Initialize prevTempJson with base values: +prevTempJson = { + 'BeerTemp': 0, + 'FridgeTemp': 0, + 'BeerAnn': None, + 'FridgeAnn': None, + 'RoomTemp': None, + 'State': None, + 'BeerSet': 0, + 'FridgeSet': 0, +} + +# Default LCD text +lcdText = ['Script starting up.', ' ', ' ', ' '] +statusType = ['N/A', 'N/A', 'N/A', 'N/A'] +statusValue = ['N/A', 'N/A', 'N/A', 'N/A'] + + +def getGit(): + # Get the current script version + # version = os.popen('git describe --tags $(git rev-list --tags --max-count=1)').read().strip() + # branch = os.popen('git branch | grep \* | cut -d " " -f2').read().strip() + # commit = os.popen('git -C . log --oneline -n1').read().strip() + global localPath + global version + global branch + global commit + repo = git.Repo(localPath) + version = next((tag for tag in repo.tags if tag.commit == repo.head.commit), None) + branch = repo.active_branch.name + commit = str(repo.head.commit)[0:7] + + +def options(): # Parse command line options + global version + global configFile + + parser = argparse.ArgumentParser( + description="Main BrewPi script which communicates with the controller(s)") + parser.add_argument("-v", "--version", action="version", version=version) + parser.add_argument("-c", "--config", metavar="", + help="select config file to use", action="store") + parser.add_argument( + "-s", "--status", help="check running scripts", action='store_true') + parser.add_argument( + "-q", "--quit", help="send quit to all instances", action='store_true') + parser.add_argument( + "-k", "--kill", help="kill all instances", action='store_true') + parser.add_argument( + "-f", "--force", help="quit/kill others and keep this one", action='store_true') + parser.add_argument( + "-l", "--log", help="redirect output to log files", action='store_true') + parser.add_argument( + "-d", "--donotrun", help="check for do not run semaphore", action='store_true') + parser.add_argument( + "-o", "--check", help="exit after startup checks", action='store_true') + args = parser.parse_args() -for o, a in opts: - # Print help message for command line options - if o in ('-h', '--help'): - print("\nAvailable command line options:\n", - " --help: Print this help message\n", - " --config : Specify a config file to use. When omitted\n", - " settings/config.cf is used\n", - " --status: Check which scripts are already running\n", - " --quit: Ask all instances of BrewPi to quit by sending a message to\n", - " their socket\n", - " --kill: Kill all instances of BrewPi by sending SIGKILL\n", - " --force: Force quit/kill conflicting instances of BrewPi and keep this one\n", - " --log: Redirect stderr and stdout to log files\n", - " --dontrunfile: Check do_not_run_brewpi in www directory and quit if it exists\n", - " --checkstartuponly: Exit after startup checks, return 0 if startup is allowed", file=sys.stderr) - sys.exit(0) # Supply a config file - if o in ('-c', '--config'): - configFile = os.path.abspath(a) + if args.config: + configFile = os.path.abspath(args.config) if not os.path.exists(configFile): - print('ERROR: Config file {0} was not found.'.format(configFile), file=sys.stderr) + print('ERROR: Config file {0} was not found.'.format( + configFile), file=sys.stderr) sys.exit(1) + # Send quit instruction to all running instances of BrewPi - if o in ('-s', '--status'): + if args.status: allProcesses = BrewPiProcess.BrewPiProcesses() allProcesses.update() running = allProcesses.as_dict() @@ -159,21 +233,24 @@ else: print("No BrewPi scripts running.", file=sys.stderr) sys.exit(0) - # Quit/kill running instances, then keep this one - if o in ('-q', '--quit'): - logMessage("Asking all BrewPi processes to quit on their socket.") + + # Quit running instances + if args.quit: + print("Asking all BrewPi processes to quit on their socket.", file=sys.stderr) allProcesses = BrewPiProcess.BrewPiProcesses() allProcesses.quitAll() time.sleep(2) sys.exit(0) + # Send SIGKILL to all running instances of BrewPi - if o in ('-k', '--kill'): - logMessage("Killing all BrewPi processes.") + if args.kill: + print("Killing all BrewPi processes.", file=sys.stderr) allProcesses = BrewPiProcess.BrewPiProcesses() allProcesses.killAll() sys.exit(0) + # Close all existing instances of BrewPi by quit/kill and keep this one - if o in ('-f', '--force'): + if args.force: logMessage( "Closing all existing processes of BrewPi and keeping this one.") allProcesses = BrewPiProcess.BrewPiProcesses() @@ -181,68 +258,92 @@ allProcesses.quitAll() time.sleep(2) if len(allProcesses.update()) > 1: - print("Asking the other processes to quit did not work. Forcing them now.", file=sys.stderr) - # Redirect output of stderr and stdout to files in log directory - if o in ('-l', '--log'): - logToFiles = True - # Only start brewpi when the dontrunfile is not found - if o in ('-d', '--dontrunfile'): - checkDontRunFile = True - if o in ('-k', '--checkstartuponly'): - checkStartupOnly = True - -if not configFile: - configFile = '{0}settings/config.cfg'.format(util.addSlash(sys.path[0])) -config = util.readCfgWithDefaults(configFile) - -dontRunFilePath = '{0}do_not_run_brewpi'.format( - util.addSlash(config['wwwPath'])) - -# Check dont run file when it exists and exit it it does -if checkDontRunFile: - if os.path.exists(dontRunFilePath): - # Do not print anything or it will flood the logs - sys.exit(1) -else: - # This is here to exit with the semaphore anyway, but print notice - # This should only be hit when running interactively. - if os.path.exists(dontRunFilePath): - print("Semaphore exists, exiting.") + print( + "Asking the other processes to quit did not work. Forcing them now.", file=sys.stderr) + allProcesses.killAll() + time.sleep(2) + if len(allProcesses.update()) > 1: + print("Unable to kill existing BrewPi processes.", + file=sys.stderr) + sys.exit(0) + + # Redirect output of stderr and stdout to files in log directory + if args.log: + logToFiles = True + + # Only start brewpi when the dontrunfile is not found + if args.donotrun: + checkDontRunFile = True + + # Exit after startup checks + if args.check: + checkStartupOnly = True + + +def config(): # Load config file + global configFile + global config + global localPath + if not configFile: + configFile = '{0}settings/config.cfg'.format(util.addSlash(localPath)) + config = util.readCfgWithDefaults(configFile) + + +def checkDoNotRun(): # Check do not run file + global dontRunFilePath + global config + global checkDontRunFile + dir(config) + dontRunFilePath = '{0}do_not_run_brewpi'.format( + util.addSlash(config['wwwPath'])) + + # Check dont run file when it exists and exit it it does + if checkDontRunFile: + if os.path.exists(dontRunFilePath): + # Do not print anything or it will flood the logs + sys.exit(1) + else: + # This is here to exit with the semaphore anyway, but print notice + # This should only be hit when running interactively. + if os.path.exists(dontRunFilePath): + print("Semaphore exists, exiting.") + sys.exit(1) + + +def checkOthers(): # Check for other running brewpi + global checkDontRunFile + allProcesses = BrewPiProcess.BrewPiProcesses() + allProcesses.update() + myProcess = allProcesses.me() + if allProcesses.findConflicts(myProcess): + if not checkDontRunFile: + logMessage( + "A conflicting BrewPi is running. This instance will exit.") sys.exit(1) -# Check for other running instances of BrewPi that will cause conflicts with -# this instance -allProcesses = BrewPiProcess.BrewPiProcesses() -allProcesses.update() -myProcess = allProcesses.me() -if allProcesses.findConflicts(myProcess): - if not checkDontRunFile: - logMessage("A conflicting BrewPi is running. This instance will exit.") - sys.exit(1) - -if checkStartupOnly: - sys.exit(0) - -localJsonFileName = "" -localCsvFileName = "" -wwwJsonFileName = "" -wwwCsvFileName = "" -lastDay = "" -day = "" - -if logToFiles: - logPath = '{0}logs/'.format(util.addSlash(util.scriptPath())) - # Skip logging for this message - print("Logging to {0}.".format(logPath)) - print("Output will not be shown in console.") - # Append stderr, unbuffered - sys.stderr = Unbuffered(open(logPath + 'stderr.txt', 'a+')) - # Overwrite stdout, unbuffered - sys.stdout = Unbuffered(open(logPath + 'stdout.txt', 'w+')) - - -# Get www json setting with default -def getWwwSetting(settingName): + +def setUpLog(): # Set up log files + global logToFiles + global logPath + if logToFiles: + logPath = '{0}logs/'.format(util.addSlash(util.scriptPath())) + # Skip logging for this message + print("Logging to {0}.".format(logPath)) + print("Output will not be shown in console.") + # Append stderr, unbuffered + sys.stderr = Unbuffered(open(logPath + 'stderr.txt', 'a+')) + # Overwrite stdout, unbuffered + sys.stdout = Unbuffered(open(logPath + 'stdout.txt', 'w+')) + # Start the logs + logError('Starting BrewPi.') # Timestamp stderr + if logToFiles: + # Make sure we send a message to daemon + print('Starting BrewPi.', file=sys.__stdout__) + else: + logMessage('Starting BrewPi.') + + +def getWwwSetting(settingName): # Get www json setting with default setting = None wwwPath = util.addSlash(config['wwwPath']) userSettings = '{0}userSettings.json'.format(wwwPath) @@ -269,8 +370,7 @@ def getWwwSetting(settingName): return setting -# Check to see if a key exists in a dictionary -def checkKey(dict, key): +def checkKey(dict, key): # Check to see if a key exists in a dictionary if key in list(dict.keys()): return True else: @@ -330,13 +430,13 @@ def setFiles(): fileMode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH | stat.S_IROTH # 664 dirMode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH | stat.S_IROTH | stat.S_IXOTH # 775 if not os.path.exists(dataPath): - os.makedirs(dataPath) # Create path if it does not exist - os.chown(dataPath, uid, gid) # chown root directory - os.chmod(dataPath, dirMode) # chmod root directory + os.makedirs(dataPath) # Create path if it does not exist + os.chown(dataPath, uid, gid) # chown root directory + os.chmod(dataPath, dirMode) # chmod root directory for root, dirs, files in os.walk(dataPath): for dir in dirs: - os.chown(os.path.join(root, dir), uid, gid) # chown directories - os.chmod(dir, dirMode) # chmod directories + os.chown(os.path.join(root, dir), uid, gid) # chown directories + os.chmod(dir, dirMode) # chmod directories for file in files: if os.path.isfile(file): os.chown(os.path.join(root, file), uid, gid) # chown files @@ -350,13 +450,13 @@ def setFiles(): fileMode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH | stat.S_IROTH # 664 dirMode = stat.S_IRWXU | stat.S_IRWXG | stat.S_IROTH | stat.S_IXOTH | stat.S_IROTH | stat.S_IXOTH # 775 if not os.path.exists(wwwDataPath): - os.makedirs(wwwDataPath) # Create path if it does not exist - os.chown(wwwDataPath, uid, gid) # chown root directory - os.chmod(wwwDataPath, dirMode) # chmod root directory + os.makedirs(wwwDataPath) # Create path if it does not exist + os.chown(wwwDataPath, uid, gid) # chown root directory + os.chmod(wwwDataPath, dirMode) # chmod root directory for root, dirs, files in os.walk(wwwDataPath): for dir in dirs: - os.chown(os.path.join(root, dir), uid, gid) # chown directories - os.chmod(dir, dirMode) # chmod directories + os.chown(os.path.join(root, dir), uid, gid) # chown directories + os.chmod(dir, dirMode) # chmod directories for file in files: if os.path.isfile(file): os.chown(os.path.join(root, file), uid, gid) # chown files @@ -394,6 +494,7 @@ def setFiles(): def startBeer(beerName): + global config if config['dataLogging'] == 'active': setFiles() changeWwwSetting('beerName', beerName) @@ -450,14 +551,15 @@ def checkBluetooth(interface=0): type=socket.SOCK_RAW, proto=socket.BTPROTO_HCI) sock.setblocking(False) - sock.setsockopt(socket.SOL_HCI, socket.HCI_FILTER, pack("IIIh2x", 0xffffffff,0xffffffff,0xffffffff,0)) + sock.setsockopt(socket.SOL_HCI, socket.HCI_FILTER, pack( + "IIIh2x", 0xffffffff, 0xffffffff, 0xffffffff, 0)) try: sock.bind((interface,)) except OSError as exc: exc = OSError( - exc.errno, 'error while attempting to bind on ' - 'interface {!r}: {}'.format( - interface, exc.strerror)) + exc.errno, 'error while attempting to bind on ' + 'interface {!r}: {}'.format( + interface, exc.strerror)) exceptions.append(exc) except OSError as exc: if sock is not None: @@ -477,183 +579,38 @@ def checkBluetooth(interface=0): ', '.join(str(exc) for exc in exceptions))) return sock -# Bytes are read from nonblocking serial into this buffer and processed when -# the buffer contains a full line. -ser = util.setupSerial(config) -if not ser: - sys.exit(1) -prevTempJson = {} -thread = False -threads = [] -tilt = None -tiltbridge = False - - -# Initialize prevTempJson with base values: -if not prevTempJson: - prevTempJson = { - 'BeerTemp': 0, - 'FridgeTemp': 0, - 'BeerAnn': None, - 'FridgeAnn': None, - 'RoomTemp': None, - 'State': None, - 'BeerSet': 0, - 'FridgeSet': 0} - -# Initialize Tilt and start monitoring -if checkKey(config, 'tiltColor') and config['tiltColor'] != "": - if not checkBluetooth(): - logError("Configured for Tilt but no Bluetooth radio available.") - else: - import Tilt - tilt = Tilt.TiltManager(config['tiltColor'], 300, 10000, 0) - tilt.loadSettings() - tilt.start() - # Create prevTempJson for Tilt +def initTilt(): # Set up Tilt + global config + global tilt + if checkKey(config, 'tiltColor') and config['tiltColor'] != "": + if not checkBluetooth(): + logError("Configured for Tilt but no Bluetooth radio available.") + else: + tilt = Tilt.TiltManager(config['tiltColor'], 300, 10000, 0) + tilt.loadSettings() + tilt.start() + # Create prevTempJson for Tilt + prevTempJson.update({ + config['tiltColor'] + 'Temp': 0, + config['tiltColor'] + 'SG': 0, + config['tiltColor'] + 'Batt': 0 + }) + + +def initISpindel(): # Initialize iSpindel + global ispindel + global config + global prevTempJson + if checkKey(config, 'iSpindel') and config['iSpindel'] != "": + ispindel = True + # Create prevTempJson for iSpindel prevTempJson.update({ - config['tiltColor'] + 'Temp': 0, - config['tiltColor'] + 'SG': 0, - config['tiltColor'] + 'Batt': 0 + 'spinSG': 0, + 'spinBatt': 0, + 'spinTemp': 0 }) -# Initialise iSpindel and start monitoring -ispindel = None -if checkKey(config, 'iSpindel') and config['iSpindel'] != "": - ispindel = True - # Create prevTempJson for iSpindel - prevTempJson.update({ - 'spinSG': 0, - 'spinBatt': 0, - 'spinTemp': 0 - }) - - -# Start the logs -logError('Starting BrewPi.') # Timestamp stderr -if logToFiles: - # Make sure we send a message to daemon - print('Starting BrewPi.', file=sys.__stdout__) -else: - logMessage('Starting BrewPi.') - -# Output the current script version -version = os.popen( - 'git describe --tags $(git rev-list --tags --max-count=1)').read().strip() -branch = os.popen('git branch | grep \* | cut -d " " -f2').read().strip() -commit = os.popen('git -C . log --oneline -n1').read().strip() -logMessage('{0} ({1}) [{2}]'.format(version, branch, commit)) - -lcdText = ['Script starting up.', ' ', ' ', ' '] -statusType = ['N/A', 'N/A', 'N/A', 'N/A'] -statusValue = ['N/A', 'N/A', 'N/A', 'N/A'] - -if config['beerName'] == 'None': - logMessage("Logging is stopped.") -else: - logMessage("Starting '" + - urllib.parse.unquote(config['beerName']) + ".'") - -logMessage("Waiting 10 seconds for board to restart.") -# Wait for 10 seconds to allow an Uno to reboot -time.sleep(float(config.get('startupDelay', 10))) - -logMessage("Checking software version on controller.") -hwVersion = brewpiVersion.getVersionFromSerial(ser) -if hwVersion is None: - logMessage("ERROR: Cannot receive version number from controller.") - logMessage("Your controller is either not programmed or running a") - logMessage("very old version of BrewPi. Please upload a new version") - logMessage("of BrewPi to your controller.") - # Script will continue so you can at least program the controller - lcdText = ['Could not receive', 'ver from controller', - 'Please (re)program', 'your controller.'] -else: - logMessage("Found " + hwVersion.toExtendedString() + - " on port " + ser.name + ".") - if LooseVersion(hwVersion.toString()) < LooseVersion(compatibleHwVersion): - logMessage("Warning: Minimum BrewPi version compatible with this") - logMessage("script is {0} but version number received is".format( - compatibleHwVersion)) - logMessage("{0}.".format(hwVersion.toString())) - if int(hwVersion.log) != int(expandLogMessage.getVersion()): - logMessage("Warning: version number of local copy of logMessages.h") - logMessage("does not match log version number received from") - logMessage( - "controller. Controller version = {0}, local copy".format(hwVersion.log)) - logMessage("version = {0}.".format(str(expandLogMessage.getVersion()))) - -bg_ser = None - -if ser is not None: - ser.flush() - # Set up background serial processing, which will continuously read data - # from serial and put whole lines in a queue - bg_ser = BackGroundSerial(ser) - bg_ser.start() - # Request settings from controller, processed later when reply is received - bg_ser.write('s') # request control settings cs - bg_ser.write('c') # request control constants cc - bg_ser.write('v') # request control variables cv - # Answer from controller is received asynchronously later. - -# Create a listening socket to communicate with PHP -is_windows = sys.platform.startswith('win') -useInetSocket = bool(config.get('useInetSocket', is_windows)) -if useInetSocket: - s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - socketPort = config.get('socketPort', 6332) - s.bind((config.get('socketHost', 'localhost'), int(socketPort))) - logMessage('Bound to TCP socket on port %d ' % int(socketPort)) -else: - socketFile = util.addSlash(util.scriptPath()) + 'BEERSOCKET' - if os.path.exists(socketFile): - # If socket already exists, remove it - os.remove(socketFile) - s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) - s.bind(socketFile) # Bind BEERSOCKET - # Set owner and permissions for socket - fileMode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP # 660 - owner = 'brewpi' - group = 'www-data' - uid = pwd.getpwnam(owner).pw_uid - gid = grp.getgrnam(group).gr_gid - os.chown(socketFile, uid, gid) # chown socket - os.chmod(socketFile, fileMode) # chmod socket - -serialCheckInterval = 0.5 -s.setblocking(1) # Set socket functions to be blocking -s.listen(10) # Create a backlog queue for up to 10 connections -# Blocking socket functions wait 'serialCheckInterval' seconds -s.settimeout(serialCheckInterval) - -prevDataTime = 0 # Keep track of time between new data requests -prevTimeOut = time.time() -prevLcdUpdate = time.time() -prevSettingsUpdate = time.time() - -# Timestamps to expire values -lastBbApi = 0 -timeoutBB = 300 -lastiSpindel = 0 -timeoutiSpindel = 1800 -lastTiltbridge = 0 -timeoutTiltbridge = 300 - -startBeer(config['beerName']) # Set up files and prep for run - -# Log full JSON if logJson is True in config, none if not set at all, -# else just a ping -outputJson = None -if checkKey(config, 'logJson'): - if config['logJson'] == 'True': - outputJson = True - else: - outputJson = False - def renameTempKey(key): rename = { @@ -675,713 +632,956 @@ def renameTempKey(key): return rename.get(key, key) -# Allow script loop to run -run = 1 +def setSocket(): # Create a listening socket to communicate with PHP + global phpSocket + global serialCheckInterval + is_windows = sys.platform.startswith('win') + useInetSocket = bool(config.get('useInetSocket', is_windows)) + if useInetSocket: + phpSocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + phpSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + socketPort = config.get('socketPort', 6332) + phpSocket.bind( + (config.get('socketHost', 'localhost'), int(socketPort))) + logMessage('Bound to TCP socket on port %d ' % int(socketPort)) + else: + socketFile = util.addSlash(util.scriptPath()) + 'BEERSOCKET' + if os.path.exists(socketFile): + # If socket already exists, remove it + os.remove(socketFile) + phpSocket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + phpSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + phpSocket.bind(socketFile) # Bind BEERSOCKET + # Set owner and permissions for socket + fileMode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP # 660 + owner = 'brewpi' + group = 'www-data' + uid = pwd.getpwnam(owner).pw_uid + gid = grp.getgrnam(group).gr_gid + os.chown(socketFile, uid, gid) # chown socket + os.chmod(socketFile, fileMode) # chmod socket + # Set socket behavior + phpSocket.setblocking(1) # Set socket functions to be blocking + phpSocket.listen(10) # Create a backlog queue for up to 10 connections + # Timeout wait 'serialCheckInterval' seconds + phpSocket.settimeout(serialCheckInterval) + + +def startLogs(): # Log startup messages + global config + global version + global branch + global commit + global outputJson + + # Output the current script version + logMessage('{0} ({1}) [{2}]'.format(version, branch, commit)) + + # Log JSON: + # True = Full + # False = Terse message + # None = No JSON + if checkKey(config, 'logJson'): + if config['logJson'] == 'True': + outputJson = True + else: + outputJson = False + + if config['beerName'] == 'None': + logMessage("Not currently logging.") + else: + logMessage("Starting '" + + urllib.parse.unquote(config['beerName']) + ".'") -try: - while run: - bc = BrewConvert.BrewConvert() - if config['dataLogging'] == 'active': - # Check whether it is a new day - lastDay = day - day = time.strftime("%Y%m%d") - if lastDay != day: - logMessage("New day, creating new JSON file.") - setFiles() - if os.path.exists(dontRunFilePath): - # Allow stopping script via semaphore - logMessage("Semaphore detected, exiting.") - run = 0 - - # Wait for incoming socket connections. - # When nothing is received, socket.timeout will be raised after - # serialCheckInterval seconds. Serial receive will be done then. - # When messages are expected on serial, the timeout is raised 'manually' - - try: # Process socket messages - conn, addr = s.accept() - conn.setblocking(1) - - # Blocking receive, times out in serialCheckInterval - message = conn.recv(4096).decode(encoding="cp437") - - if "=" in message: # Split to message/value if message has an '=' - messageType, value = message.split("=", 1) - else: - messageType = message - value = "" - - if messageType == "ack": # Acknowledge request - conn.send("ack".encode('utf-8')) - elif messageType == "lcd": # LCD contents requested - conn.send(json.dumps(lcdText).encode('utf-8')) - elif messageType == "getMode": # Echo mode setting - conn.send(cs['mode']).encode('utf-8') - elif messageType == "getFridge": # Echo fridge temperature setting - conn.send(json.dumps(cs['fridgeSet']).encode('utf-8')) - elif messageType == "getBeer": # Echo beer temperature setting - conn.send(json.dumps(cs['beerSet']).encode('utf-8')) - elif messageType == "getControlConstants": # Echo control constants - conn.send(json.dumps(cc).encode('utf-8')) - elif messageType == "getControlSettings": # Echo control settings - if cs['mode'] == "p": - profileFile = util.addSlash( - util.scriptPath()) + 'settings/tempProfile.csv' - with open(profileFile, 'r') as prof: - cs['profile'] = prof.readline().split(",")[-1].rstrip("\n") - cs['dataLogging'] = config['dataLogging'] - conn.send(json.dumps(cs).encode('utf-8')) - elif messageType == "getControlVariables": # Echo control variables - conn.send(json.dumps(cv).encode('utf-8')) - elif messageType == "refreshControlConstants": # Request control constants from controller - bg_ser.write("c") - raise socket.timeout - elif messageType == "refreshControlSettings": # Request control settings from controller - bg_ser.write("s") - raise socket.timeout - elif messageType == "refreshControlVariables": # Request control variables from controller - bg_ser.write("v") - raise socket.timeout - elif messageType == "loadDefaultControlSettings": - bg_ser.write("S") - raise socket.timeout - elif messageType == "loadDefaultControlConstants": - bg_ser.write("C") - raise socket.timeout - elif messageType == "setBeer": # New constant beer temperature received - try: - newTemp = float(value) - except ValueError: - logMessage("Cannot convert temperature '" + - value + "' to float.") - continue - if cc['tempSetMin'] <= newTemp <= cc['tempSetMax']: - cs['mode'] = 'b' - # Round to 2 dec, python will otherwise produce 6.999999999 - cs['beerSet'] = round(newTemp, 2) - bg_ser.write( - "j{mode:b, beerSet:" + json.dumps(cs['beerSet']) + "}") - logMessage("Beer temperature set to {0} degrees by web.".format( - str(cs['beerSet']))) - raise socket.timeout # Go to serial communication to update controller - else: - logMessage( - "Beer temperature setting {0} is outside of allowed".format(str(newTemp))) - logMessage("range {0} - {1}. These limits can be changed in".format( - str(cc['tempSetMin']), str(cc['tempSetMax']))) - logMessage("advanced settings.") - elif messageType == "setFridge": # New constant fridge temperature received - try: - newTemp = float(value) - except ValueError: - logMessage( - "Cannot convert temperature '{0}' to float.".format(value)) - continue - if cc['tempSetMin'] <= newTemp <= cc['tempSetMax']: - cs['mode'] = 'f' - cs['fridgeSet'] = round(newTemp, 2) - bg_ser.write("j{mode:f, fridgeSet:" + - json.dumps(cs['fridgeSet']) + "}") - logMessage("Fridge temperature set to {0} degrees by web.".format( - str(cs['fridgeSet']))) - raise socket.timeout # Go to serial communication to update controller +def startSerial(): # Start controller + global config + global serialConn + global bgSerialConn + global hwVersion + global compatibleHwVersion + + # Bytes are read from nonblocking serial into this buffer and processed when + # the buffer contains a full line. + serialConn = util.setupSerial(config) + if not serialConn: + sys.exit(1) + else: + # Wait for 10 seconds to allow an Uno to reboot + logMessage("Waiting 10 seconds for board to restart.") + time.sleep(float(config.get('startupDelay', 10))) + + logMessage("Checking software version on controller.") + hwVersion = brewpiVersion.getVersionFromSerial(serialConn) + if hwVersion is None: + logMessage("ERROR: Cannot receive version number from controller.") + logMessage("Your controller is either not programmed or running a") + logMessage("very old version of BrewPi. Please upload a new version") + logMessage("of BrewPi to your controller.") + # Script will continue so you can at least program the controller + lcdText = ['Could not receive', 'ver from controller', + 'Please (re)program', 'your controller.'] + else: + logMessage("Found " + hwVersion.toExtendedString() + + " on port " + serialConn.name + ".") + if LooseVersion(hwVersion.toString()) < LooseVersion(compatibleHwVersion): + logMessage("Warning: Minimum BrewPi version compatible with this") + logMessage("script is {0} but version number received is".format( + compatibleHwVersion)) + logMessage("{0}.".format(hwVersion.toString())) + if int(hwVersion.log) != int(expandLogMessage.getVersion()): + logMessage("Warning: version number of local copy of logMessages.h") + logMessage("does not match log version number received from") + logMessage( + "controller. Controller version = {0}, local copy".format(hwVersion.log)) + logMessage("version = {0}.".format( + str(expandLogMessage.getVersion()))) + + if serialConn is not None: + serialConn.flush() + # Set up background serial processing, which will continuously read data + # from serial and put whole lines in a queue + bgSerialConn = BackGroundSerial(serialConn) + bgSerialConn.start() + # Request settings from controller, processed later when reply is received + bgSerialConn.write('s') # request control settings cs + bgSerialConn.write('c') # request control constants cc + bgSerialConn.write('v') # request control variables cv + # Answer from controller is received asynchronously later. + + # Keep track of time between new data requests + prevDataTime = 0 + prevTimeOut = time.time() + prevLcdUpdate = time.time() + prevSettingsUpdate = time.time() + startBeer(config['beerName']) # Set up files and prep for run + + +def loop(): # Main program loop + global config + global hwVersion + global lastDay + global day + global lcdText + global statusType + global statusValue + global cs + global cc + global cv + global prevTempJson + global deviceList + global dontRunFilePath + global lastBbApi + global timeoutBB + global lastiSpindel + global timeoutiSpindel + global lastTiltbridge + global timeoutTiltbridge + global phpSocket + global serialConn + global bgSerialConn + global prevDataTime + global prevTimeOut + global prevLcdUpdate + global prevSettingsUpdate + global serialCheckInterval + global tilt + global tiltbridge + global ispindel + + bc = BrewConvert.BrewConvert() + run = True # Allow script loop to run + + try: # Main loop + while run: + if config['dataLogging'] == 'active': + # Check whether it is a new day + lastDay = day + day = time.strftime("%Y%m%d") + if lastDay != day: + logMessage("New day, creating new JSON file.") + setFiles() + + if os.path.exists(dontRunFilePath): + # Allow stopping script via semaphore + logMessage("Semaphore detected, exiting.") + run = False + + # Wait for incoming phpSocket connections. If nothing is received, + # socket.timeout will be raised after serialCheckInterval seconds. + # bgSerialConn receive will then process. If messages are expected + # on serial, the timeout is raised explicitly. + + try: # Process socket messages + phpConn, addr = phpSocket.accept() + phpConn.setblocking(1) + + # Blocking receive, times out in serialCheckInterval + message = phpConn.recv(4096).decode(encoding="cp437") + + if "=" in message: # Split to message/value if message has an '=' + messageType, value = message.split("=", 1) else: - logMessage( - "Fridge temperature setting {0} is outside of allowed".format(str(newTemp))) - logMessage("range {0} - {1}. These limits can be changed in".format( - str(cc['tempSetMin']), str(cc['tempSetMax']))) - logMessage("advanced settings.") - elif messageType == "setOff": # Control mode set to OFF - cs['mode'] = 'o' - bg_ser.write("j{mode:o}") - logMessage("Temperature control disabled.") - raise socket.timeout - elif messageType == "setParameters": - # Receive JSON key:value pairs to set parameters on the controller - try: - decoded = json.loads(value) - bg_ser.write("j" + json.dumps(decoded)) - if 'tempFormat' in decoded: - # Change in web interface settings too - changeWwwSetting('tempFormat', decoded['tempFormat']) - except json.JSONDecodeError: - logMessage("ERROR: Invalid JSON parameter. String received:") - logMessage(value) - raise socket.timeout - elif messageType == "stopScript": # Exit instruction received. Stop script. - # Voluntary shutdown. - logMessage('Stop message received on socket.') - sys.stdout.flush() - # Also log stop back to daemon - if logToFiles: - print('Stop message received on socket.', file=sys.__stdout__) - run = 0 - # Write a file to prevent the daemon from restarting the script - wwwPath = util.addSlash(config['wwwPath']) - dontRunFilePath = '{0}do_not_run_brewpi'.format(wwwPath) - util.createDontRunFile(dontRunFilePath) - elif messageType == "quit": # Quit but do not write semaphore - # Quit instruction received. Probably sent by another brewpi - # script instance - logMessage("Quit message received on socket.") - run = 0 - # Leave dontrunfile alone. - # This instruction is meant to restart the script or replace - # it with another instance. - continue - elif messageType == "eraseLogs": # Erase stderr and stdout - open(util.scriptPath() + '/logs/stderr.txt', 'wb').close() - open(util.scriptPath() + '/logs/stdout.txt', 'wb').close() - logMessage("Log files erased.") - logError("Log files erased.") - continue - elif messageType == "interval": # New interval received - newInterval = int(value) - if 5 < newInterval < 5000: + messageType = message + value = "" + + if messageType == "ack": # Acknowledge request + phpConn.send("ack".encode('utf-8')) + elif messageType == "lcd": # LCD contents requested + phpConn.send(json.dumps(lcdText).encode('utf-8')) + elif messageType == "getMode": # Echo mode setting + phpConn.send(cs['mode']).encode('utf-8') + elif messageType == "getFridge": # Echo fridge temperature setting + phpConn.send(json.dumps(cs['fridgeSet']).encode('utf-8')) + elif messageType == "getBeer": # Echo beer temperature setting + phpConn.send(json.dumps(cs['beerSet']).encode('utf-8')) + elif messageType == "getControlConstants": # Echo control constants + phpConn.send(json.dumps(cc).encode('utf-8')) + elif messageType == "getControlSettings": # Echo control settings + if cs['mode'] == "p": + profileFile = util.addSlash( + util.scriptPath()) + 'settings/tempProfile.csv' + with open(profileFile, 'r') as prof: + cs['profile'] = prof.readline().split( + ",")[-1].rstrip("\n") + cs['dataLogging'] = config['dataLogging'] + phpConn.send(json.dumps(cs).encode('utf-8')) + elif messageType == "getControlVariables": # Echo control variables + phpConn.send(json.dumps(cv).encode('utf-8')) + elif messageType == "refreshControlConstants": # Request control constants from controller + bgSerialConn.write("c") + raise socket.timeout + elif messageType == "refreshControlSettings": # Request control settings from controller + bgSerialConn.write("s") + raise socket.timeout + elif messageType == "refreshControlVariables": # Request control variables from controller + bgSerialConn.write("v") + raise socket.timeout + elif messageType == "loadDefaultControlSettings": + bgSerialConn.write("S") + raise socket.timeout + elif messageType == "loadDefaultControlConstants": + bgSerialConn.write("C") + raise socket.timeout + elif messageType == "setBeer": # New constant beer temperature received try: - config = util.configSet( - configFile, 'interval', float(newInterval)) + newTemp = float(value) except ValueError: + logMessage("Cannot convert temperature '" + + value + "' to float.") + continue + if cc['tempSetMin'] <= newTemp <= cc['tempSetMax']: + cs['mode'] = 'b' + # Round to 2 dec, python will otherwise produce 6.999999999 + cs['beerSet'] = round(newTemp, 2) + bgSerialConn.write( + "j{mode:b, beerSet:" + json.dumps(cs['beerSet']) + "}") + logMessage("Beer temperature set to {0} degrees by web.".format( + str(cs['beerSet']))) + raise socket.timeout # Go to serial communication to update controller + else: logMessage( - "Cannot convert interval '{0}' to float.".format(value)) + "Beer temperature setting {0} is outside of allowed".format(str(newTemp))) + logMessage("range {0} - {1}. These limits can be changed in".format( + str(cc['tempSetMin']), str(cc['tempSetMax']))) + logMessage("advanced settings.") + elif messageType == "setFridge": # New constant fridge temperature received + try: + newTemp = float(value) + except ValueError: + logMessage( + "Cannot convert temperature '{0}' to float.".format(value)) continue - logMessage("Interval changed to {0} seconds.".format( - str(newInterval))) - elif messageType == "startNewBrew": # New beer name - newName = value - result = startNewBrew(newName) - conn.send(json.dumps(result).encode('utf-8')) - elif messageType == "pauseLogging": # Pause logging - result = pauseLogging() - conn.send(json.dumps(result).encode('utf-8')) - elif messageType == "stopLogging": # Stop logging - result = stopLogging() - conn.send(json.dumps(result).encode('utf-8')) - elif messageType == "resumeLogging": # Resume logging - result = resumeLogging() - conn.send(json.dumps(result).encode('utf-8')) - elif messageType == "dateTimeFormatDisplay": # Change date time format - config = util.configSet(configFile, 'dateTimeFormatDisplay', value) - changeWwwSetting('dateTimeFormatDisplay', value) - logMessage("Changing date format config setting: " + value) - elif messageType == "setActiveProfile": # Get and process beer profile - # Copy the profile CSV file to the working directory - logMessage("Setting profile '%s' as active profile." % value) - config = util.configSet(configFile, 'profileName', value) - changeWwwSetting('profileName', value) - profileSrcFile = util.addSlash( - config['wwwPath']) + "data/profiles/" + value + ".csv" - profileDestFile = util.addSlash( - util.scriptPath()) + 'settings/tempProfile.csv' - profileDestFileOld = profileDestFile + '.old' - try: - if os.path.isfile(profileDestFile): - if os.path.isfile(profileDestFileOld): - os.remove(profileDestFileOld) - os.rename(profileDestFile, profileDestFileOld) - shutil.copy(profileSrcFile, profileDestFile) - # For now, store profile name in header row (in an additional - # column) - with open(profileDestFile, 'r') as original: - line1 = original.readline().rstrip("\n") - rest = original.read() - with open(profileDestFile, 'w') as modified: - modified.write(line1 + "," + value + "\n" + rest) - except IOError as e: # Catch all exceptions and report back an error - error = "I/O Error(%d) updating profile: %s." % (e.errno, - e.strerror) - conn.send(error) - logMessage(error) - else: - conn.send("Profile successfully updated.".encode('utf-8')) - if cs['mode'] is not 'p': - cs['mode'] = 'p' - bg_ser.write("j{mode:p}") - logMessage("Profile mode enabled.") + if cc['tempSetMin'] <= newTemp <= cc['tempSetMax']: + cs['mode'] = 'f' + cs['fridgeSet'] = round(newTemp, 2) + bgSerialConn.write("j{mode:f, fridgeSet:" + + json.dumps(cs['fridgeSet']) + "}") + logMessage("Fridge temperature set to {0} degrees by web.".format( + str(cs['fridgeSet']))) raise socket.timeout # Go to serial communication to update controller - elif messageType == "programController" or messageType == "programArduino": # Reprogram controller - if bg_ser is not None: - bg_ser.stop() - if ser is not None: - if ser.isOpen(): - ser.close() # Close serial port before programming - ser = None - try: - programParameters = json.loads(value) - hexFile = programParameters['fileName'] - boardType = programParameters['boardType'] - restoreSettings = programParameters['restoreSettings'] - restoreDevices = programParameters['restoreDevices'] - programmer.programController(config, boardType, hexFile, { - 'settings': restoreSettings, 'devices': restoreDevices}) - logMessage( - "New program uploaded to controller, script will restart.") - except json.JSONDecodeError: - logMessage( - "ERROR. Cannot decode programming parameters: " + value) - logMessage("Restarting script without programming.") - - # Restart the script when done. This replaces this process with - # the new one - time.sleep(5) # Give the controller time to reboot - python3 = sys.executable - os.execl(python3, python3, *sys.argv) - elif messageType == "refreshDeviceList": # Request devices from controller - deviceList['listState'] = "" # Invalidate local copy - if value.find("readValues") != -1: - bg_ser.write("d{r:1}") # Request installed devices - # Request available, but not installed devices - bg_ser.write("h{u:-1,v:1}") - else: - bg_ser.write("d{}") # Request installed devices - # Request available, but not installed devices - bg_ser.write("h{u:-1}") - elif messageType == "getDeviceList": # Echo device list - if deviceList['listState'] in ["dh", "hd"]: - response = dict(board=hwVersion.board, - shield=hwVersion.shield, - deviceList=deviceList, - pinList=pinList.getPinList(hwVersion.board, hwVersion.shield)) - conn.send(json.dumps(response).encode('utf-8')) - else: - conn.send("device-list-not-up-to-date") - elif messageType == "applyDevice": # Change device settings - try: - # Load as JSON to check syntax - configStringJson = json.loads(value) - except json.JSONDecodeError: - logMessage( - "ERROR. Invalid JSON parameter string received: {0}".format(value)) + else: + logMessage( + "Fridge temperature setting {0} is outside of allowed".format(str(newTemp))) + logMessage("range {0} - {1}. These limits can be changed in".format( + str(cc['tempSetMin']), str(cc['tempSetMax']))) + logMessage("advanced settings.") + elif messageType == "setOff": # Control mode set to OFF + cs['mode'] = 'o' + bgSerialConn.write("j{mode:o}") + logMessage("Temperature control disabled.") + raise socket.timeout + elif messageType == "setParameters": + # Receive JSON key:value pairs to set parameters on the controller + try: + decoded = json.loads(value) + bgSerialConn.write("j" + json.dumps(decoded)) + if 'tempFormat' in decoded: + # Change in web interface settings too + changeWwwSetting( + 'tempFormat', decoded['tempFormat']) + except json.JSONDecodeError: + logMessage( + "ERROR: Invalid JSON parameter. String received:") + logMessage(value) + raise socket.timeout + elif messageType == "stopScript": # Exit instruction received. Stop script. + # Voluntary shutdown. + logMessage('Stop message received on socket.') + sys.stdout.flush() + # Also log stop back to daemon + if logToFiles: + print('Stop message received on socket.', + file=sys.__stdout__) + run = False + # Write a file to prevent the daemon from restarting the script + util.createDontRunFile(dontRunFilePath) + elif messageType == "quit": # Quit but do not write semaphore + # Quit instruction received. Probably sent by another brewpi + # script instance + logMessage("Quit message received on socket.") + run = False + # Leave dontrunfile alone. + # This instruction is meant to restart the script or replace + # it with another instance. continue - bg_ser.write("U{0}".format(json.dumps(configStringJson))) - deviceList['listState'] = "" # Invalidate local copy - elif messageType == "writeDevice": # Configure a device - try: - # Load as JSON to check syntax - configStringJson = json.loads(value) - except json.JSONDecodeError: - logMessage( - "ERROR: invalid JSON parameter string received: " + value) + elif messageType == "eraseLogs": # Erase stderr and stdout + open(util.scriptPath() + '/logs/stderr.txt', 'wb').close() + open(util.scriptPath() + '/logs/stdout.txt', 'wb').close() + logMessage("Log files erased.") + logError("Log files erased.") continue - bg_ser.write("d" + json.dumps(configStringJson)) - elif messageType == "getVersion": # Get firmware version from controller - if hwVersion: - response = hwVersion.__dict__ - # Replace LooseVersion with string, because it is not - # JSON serializable - response['version'] = hwVersion.toString() - else: - response = {} - conn.send(json.dumps(response).encode('utf-8')) - elif messageType == "resetController": # Erase EEPROM - logMessage("Resetting controller to factory defaults.") - bg_ser.write("E") - elif messageType == "api": # External API Received - - # Receive an API message in JSON key:value pairs - # conn.send("Ok") + elif messageType == "interval": # New interval received + newInterval = int(value) + if 5 < newInterval < 5000: + try: + config = util.configSet( + configFile, 'interval', float(newInterval)) + except ValueError: + logMessage( + "Cannot convert interval '{0}' to float.".format(value)) + continue + logMessage("Interval changed to {0} seconds.".format( + str(newInterval))) + elif messageType == "startNewBrew": # New beer name + newName = value + result = startNewBrew(newName) + phpConn.send(json.dumps(result).encode('utf-8')) + elif messageType == "pauseLogging": # Pause logging + result = pauseLogging() + phpConn.send(json.dumps(result).encode('utf-8')) + elif messageType == "stopLogging": # Stop logging + result = stopLogging() + phpConn.send(json.dumps(result).encode('utf-8')) + elif messageType == "resumeLogging": # Resume logging + result = resumeLogging() + phpConn.send(json.dumps(result).encode('utf-8')) + elif messageType == "dateTimeFormatDisplay": # Change date time format + config = util.configSet( + configFile, 'dateTimeFormatDisplay', value) + changeWwwSetting('dateTimeFormatDisplay', value) + logMessage("Changing date format config setting: " + value) + elif messageType == "setActiveProfile": # Get and process beer profile + # Copy the profile CSV file to the working directory + logMessage( + "Setting profile '%s' as active profile." % value) + config = util.configSet(configFile, 'profileName', value) + changeWwwSetting('profileName', value) + profileSrcFile = util.addSlash( + config['wwwPath']) + "data/profiles/" + value + ".csv" + profileDestFile = util.addSlash( + util.scriptPath()) + 'settings/tempProfile.csv' + profileDestFileOld = profileDestFile + '.old' + try: + if os.path.isfile(profileDestFile): + if os.path.isfile(profileDestFileOld): + os.remove(profileDestFileOld) + os.rename(profileDestFile, profileDestFileOld) + shutil.copy(profileSrcFile, profileDestFile) + # For now, store profile name in header row (in an additional + # column) + with open(profileDestFile, 'r') as original: + line1 = original.readline().rstrip("\n") + rest = original.read() + with open(profileDestFile, 'w') as modified: + modified.write(line1 + "," + value + "\n" + rest) + except IOError as e: # Catch all exceptions and report back an error + error = "I/O Error(%d) updating profile: %s." % (e.errno, + e.strerror) + phpConn.send(error) + logMessage(error) + else: + phpConn.send( + "Profile successfully updated.".encode('utf-8')) + if cs['mode'] is not 'p': + cs['mode'] = 'p' + bgSerialConn.write("j{mode:p}") + logMessage("Profile mode enabled.") + raise socket.timeout # Go to serial communication to update controller + elif messageType == "programController" or messageType == "programArduino": # Reprogram controller + if bgSerialConn is not None: + bgSerialConn.stop() + if serialConn is not None: + if serialConn.isOpen(): + serialConn.close() # Close serial port before programming + serialConn = None + try: + programParameters = json.loads(value) + hexFile = programParameters['fileName'] + boardType = programParameters['boardType'] + restoreSettings = programParameters['restoreSettings'] + restoreDevices = programParameters['restoreDevices'] + programmer.programController(config, boardType, hexFile, { + 'settings': restoreSettings, 'devices': restoreDevices}) + logMessage( + "New program uploaded to controller, script will restart.") + except json.JSONDecodeError: + logMessage( + "ERROR. Cannot decode programming parameters: " + value) + logMessage("Restarting script without programming.") + + # Restart the script when done. This replaces this process with + # the new one + time.sleep(5) # Give the controller time to reboot + python3 = sys.executable + os.execl(python3, python3, *sys.argv) + elif messageType == "refreshDeviceList": # Request devices from controller + deviceList['listState'] = "" # Invalidate local copy + if value.find("readValues") != -1: + # Request installed devices + bgSerialConn.write("d{r:1}") + # Request available, but not installed devices + bgSerialConn.write("h{u:-1,v:1}") + else: + bgSerialConn.write("d{}") # Request installed devices + # Request available, but not installed devices + bgSerialConn.write("h{u:-1}") + elif messageType == "getDeviceList": # Echo device list + if deviceList['listState'] in ["dh", "hd"]: + response = dict(board=hwVersion.board, + shield=hwVersion.shield, + deviceList=deviceList, + pinList=pinList.getPinList(hwVersion.board, hwVersion.shield)) + phpConn.send(json.dumps(response).encode('utf-8')) + else: + phpConn.send("device-list-not-up-to-date") + elif messageType == "applyDevice": # Change device settings + try: + # Load as JSON to check syntax + configStringJson = json.loads(value) + except json.JSONDecodeError: + logMessage( + "ERROR. Invalid JSON parameter string received: {0}".format(value)) + continue + bgSerialConn.write("U{0}".format( + json.dumps(configStringJson))) + deviceList['listState'] = "" # Invalidate local copy + elif messageType == "writeDevice": # Configure a device + try: + # Load as JSON to check syntax + configStringJson = json.loads(value) + except json.JSONDecodeError: + logMessage( + "ERROR: invalid JSON parameter string received: " + value) + continue + bgSerialConn.write("d" + json.dumps(configStringJson)) + elif messageType == "getVersion": # Get firmware version from controller + if hwVersion: + response = hwVersion.__dict__ + # Replace LooseVersion with string, because it is not + # JSON serializable + response['version'] = hwVersion.toString() + else: + response = {} + phpConn.send(json.dumps(response).encode('utf-8')) + elif messageType == "resetController": # Erase EEPROM + logMessage("Resetting controller to factory defaults.") + bgSerialConn.write("E") + elif messageType == "api": # External API Received + # Receive an API message in JSON key:value pairs + # phpConn.send("Ok") + try: + api = json.loads(value) - try: - api = json.loads(value) + if checkKey(api, 'api_name'): + apiKey = api['api_name'] - if checkKey(api, 'api_name'): - apiKey = api['api_name'] + # BEGIN: Process a Brew Bubbles API POST + if apiKey == "Brew Bubbles": # Received JSON from Brew Bubbles + # Log received line if true, false is short message, none = mute + if outputJson == True: + logMessage( + "API BB JSON Recvd: " + json.dumps(api)) + elif outputJson == False: + logMessage( + "API Brew Bubbles JSON received.") + else: + pass # Don't log JSON messages + + # Set time of last update + lastBbApi = timestamp = time.time() + + # Handle vessel temp conversion + apiTemp = 0 + if cc['tempFormat'] == api['temp_unit']: + apiTemp = api['temp'] + elif cc['tempFormat'] == 'F': + apiTemp = bc.convert(api['temp'], 'C', 'F') + else: + apiTemp = bc.convert(api['temp'], 'F', 'C') + + # Handle ambient temp conversion + apiAmbient = 0 + if cc['tempFormat'] == api['temp_unit']: + apiAmbient = api['ambient'] + elif cc['tempFormat'] == 'F': + apiAmbient = bc.convert( + api['ambient'], 'C', 'F') + else: + apiAmbient = bc.convert( + api['ambient'], 'F', 'C') + + # Update prevTempJson if keys exist + if checkKey(prevTempJson, 'bbbpm'): + prevTempJson['bbbpm'] = api['bpm'] + prevTempJson['bbamb'] = apiAmbient + prevTempJson['bbves'] = apiTemp + # Else, append values to prevTempJson + else: + prevTempJson.update({ + 'bbbpm': api['bpm'], + 'bbamb': apiAmbient, + 'bbves': apiTemp + }) + # END: Process a Brew Bubbles API POST - # BEGIN: Process a Brew Bubbles API POST - if apiKey == "Brew Bubbles": # Received JSON from Brew Bubbles - # Log received line if true, false is short message, none = mute - if outputJson == True: - logMessage("API BB JSON Recvd: " + json.dumps(api)) - elif outputJson == False: - logMessage("API Brew Bubbles JSON received.") else: - pass # Don't log JSON messages + logMessage( + "WARNING: Unknown API key received in JSON:") + logMessage(value) - # Set time of last update - lastBbApi = timestamp = time.time() + # Begin: iSpindel Processing + # iSpindel + elif checkKey(api, 'name') and checkKey(api, 'ID') and checkKey(api, 'gravity'): - # Handle vessel temp conversion - apiTemp = 0 - if cc['tempFormat'] == api['temp_unit']: - apiTemp = api['temp'] - elif cc['tempFormat'] == 'F': - apiTemp = bc.convert(api['temp'], 'C', 'F') - else: - apiTemp = bc.convert(api['temp'], 'F', 'C') - - # Handle ambient temp conversion - apiAmbient = 0 - if cc['tempFormat'] == api['temp_unit']: - apiAmbient = api['ambient'] - elif cc['tempFormat'] == 'F': - apiAmbient = bc.convert(api['ambient'], 'C', 'F') - else: - apiAmbient = bc.convert(api['ambient'], 'F', 'C') - - # Update prevTempJson if keys exist - if checkKey(prevTempJson, 'bbbpm'): - prevTempJson['bbbpm'] = api['bpm'] - prevTempJson['bbamb'] = apiAmbient - prevTempJson['bbves'] = apiTemp - # Else, append values to prevTempJson - else: - prevTempJson.update({ - 'bbbpm': api['bpm'], - 'bbamb': apiAmbient, - 'bbves': apiTemp - }) - # END: Process a Brew Bubbles API POST + if ispindel is not None and config['iSpindel'] == api['name']: - else: - logMessage("WARNING: Unknown API key received in JSON:") - logMessage(value) - - # Begin: iSpindel Processing - elif checkKey(api, 'name') and checkKey(api, 'ID') and checkKey(api, 'gravity'): # iSpindel + # Log received line if true, false is short message, none = mute + if outputJson: + logMessage( + "API iSpindel JSON Recvd: " + json.dumps(api)) + elif not outputJson: + logMessage("API iSpindel JSON received.") + else: + pass # Don't log JSON messages + + # Set time of last update + lastiSpindel = timestamp = time.time() + + # Convert to proper temp unit + _temp = 0 + if cc['tempFormat'] == api['temp_units']: + _temp = api['temperature'] + elif cc['tempFormat'] == 'F': + _temp = bc.convert( + api['temperature'], 'C', 'F') + else: + _temp = bc.convert( + api['temperature'], 'F', 'C') + + # Update prevTempJson if keys exist + if checkKey(prevTempJson, 'battery'): + prevTempJson['spinBatt'] = api['battery'] + prevTempJson['spinSG'] = api['gravity'] + prevTempJson['spinTemp'] = _temp + + # Else, append values to prevTempJson + else: + prevTempJson.update({ + 'spinBatt': api['battery'], + 'spinSG': api['gravity'], + 'spinTemp': _temp + }) + + elif not ispindel: + logError('iSpindel packet received but no iSpindel configuration exists in {0}settings/config.cfg'.format( + util.addSlash(sys.path[0]))) - if ispindel is not None and config['iSpindel'] == api['name']: + else: + logError('Received iSpindel packet not matching config in {0}settings/config.cfg'.format( + util.addSlash(sys.path[0]))) + # End: iSpindel Processing + # Begin: Tiltbridge Processing + elif checkKey(api, 'mdns_id') and checkKey(api, 'tilts'): + # Received JSON from Tiltbridge # Log received line if true, false is short message, none = mute - if outputJson: - logMessage("API iSpindel JSON Recvd: " + json.dumps(api)) - elif not outputJson: - logMessage("API iSpindel JSON received.") + if outputJson == True: + logMessage("API TB JSON Recvd: " + + json.dumps(api)) + elif outputJson == False: + logMessage("API Tiltbridge JSON received.") else: pass # Don't log JSON messages - # Set time of last update - lastiSpindel = timestamp = time.time() - - # Convert to proper temp unit - _temp = 0 - if cc['tempFormat'] == api['temp_units']: - _temp = api['temperature'] - elif cc['tempFormat'] == 'F': - _temp = bc.convert(api['temperature'], 'C', 'F') - else: - _temp = bc.convert(api['temperature'], 'F', 'C') - - # Update prevTempJson if keys exist - if checkKey(prevTempJson, 'battery'): - prevTempJson['spinBatt'] = api['battery'] - prevTempJson['spinSG'] = api['gravity'] - prevTempJson['spinTemp'] = _temp - - # Else, append values to prevTempJson - else: - prevTempJson.update({ - 'spinBatt': api['battery'], - 'spinSG': api['gravity'], - 'spinTemp': _temp - }) - - elif not ispindel: - logError('iSpindel packet received but no iSpindel configuration exists in {0}settings/config.cfg'.format( - util.addSlash(sys.path[0]))) + # Loop through (value) and match config["tiltColor"] + for t in api: + if t == "tilts": + for c in api['tilts']: + if c == config["tiltColor"]: + # Found, turn off regular Tilt + if tiltbridge == False: + logMessage( + "Turned on Tiltbridge.") + tiltbridge = True + try: + tilt + except NameError: + tilt = None + if tilt is not None: # If we are running a Tilt, stop it + logMessage( + "Stopping Tilt.") + tilt.stop() + tilt = None + + # Set time of last update + lastTiltbridge = timestamp = time.time() + _temp = api['tilts'][config['tiltColor']]['temp'] + if cc['tempFormat'] == 'C': + _temp = round( + bc.convert(_temp, 'F', 'C'), 1) + prevTempJson[config["tiltColor"] + + 'Temp'] = round(_temp, 1) + prevTempJson[config["tiltColor"] + 'SG'] = float( + api['tilts'][config['tiltColor']]['gravity']) + # TODO: prevTempJson[config["tiltColor"] + 'Batt'] = api['tilts'][config['tiltColor']]['batt'] + prevTempJson[config["tiltColor"] + + 'Batt'] = None + # END: Tiltbridge Processing else: - logError('Received iSpindel packet not matching config in {0}settings/config.cfg'.format( - util.addSlash(sys.path[0]))) - # End: iSpindel Processing - - # Begin: Tiltbridge Processing - elif checkKey(api, 'mdns_id') and checkKey(api, 'tilts'): - # Received JSON from Tiltbridge - # Log received line if true, false is short message, none = mute - if outputJson == True: - logMessage("API TB JSON Recvd: " + json.dumps(api)) - elif outputJson == False: - logMessage("API Tiltbridge JSON received.") - else: - pass # Don't log JSON messages - - # Loop through (value) and match config["tiltColor"] - for t in api: - if t == "tilts": - for c in api['tilts']: - if c == config["tiltColor"]: - # Found, turn off regular Tilt - if tiltbridge == False: - logMessage("Turned on Tiltbridge.") - tiltbridge = True - try: - tilt - except NameError: - tilt = None - if tilt is not None: # If we are running a Tilt, stop it - logMessage("Stopping Tilt.") - tilt.stop() - tilt = None - - # Set time of last update - lastTiltbridge = timestamp = time.time() - _temp = api['tilts'][config['tiltColor']]['temp'] - if cc['tempFormat'] == 'C': - _temp = round(bc.convert(_temp, 'F', 'C'), 1) - prevTempJson[config["tiltColor"] + 'Temp'] = round(_temp, 1) - prevTempJson[config["tiltColor"] + 'SG'] = float(api['tilts'][config['tiltColor']]['gravity']) - # TODO: prevTempJson[config["tiltColor"] + 'Batt'] = api['tilts'][config['tiltColor']]['batt'] - prevTempJson[config["tiltColor"] + 'Batt'] = None - - # END: Tiltbridge Processing + logError( + "Received API message, however no matching configuration exists.") + except json.JSONDecodeError: + logError( + "Invalid JSON received from API. String received:") + logError(value) + except Exception as e: + logError( + "Unknown error processing API. String received:") + logError(value) + elif messageType == "statusText": # Status contents requested + status = {} + statusIndex = 0 + + # Get any items pending for the status box + # Javascript will determine what/how to display + + if cc['tempFormat'] == 'C': + tempSuffix = "℃" else: - logError("Received API message, however no matching configuration exists.") - - except json.JSONDecodeError: - logError( - "Invalid JSON received from API. String received:") - logError(value) - except Exception as e: - logError("Unknown error processing API. String received:") - logError(value) + tempSuffix = "℉" - elif messageType == "statusText": # Status contents requested - status = {} - statusIndex = 0 - - # Get any items pending for the status box - # Javascript will determine what/how to display - - if cc['tempFormat'] == 'C': - tempSuffix = "℃" - else: - tempSuffix = "℉" - - # Begin: Brew Bubbles Items - if checkKey(prevTempJson, 'bbbpm'): - status[statusIndex] = {} - statusType = "Airlock: " - statusValue = str(round(prevTempJson['bbbpm'], 1)) + " bpm" - status[statusIndex].update({statusType: statusValue}) - statusIndex = statusIndex + 1 - if checkKey(prevTempJson, 'bbamb'): - if not int(prevTempJson['bbamb']) == -100: # filter out disconnected sensors + # Begin: Brew Bubbles Items + if checkKey(prevTempJson, 'bbbpm'): status[statusIndex] = {} - statusType= "Ambient Temp: " - statusValue = str(round(prevTempJson['bbamb'], 1)) + tempSuffix + statusType = "Airlock: " + statusValue = str( + round(prevTempJson['bbbpm'], 1)) + " bpm" status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 - if checkKey(prevTempJson, 'bbves'): - if not int(prevTempJson['bbves']) == -100: # filter out disconnected sensors - status[statusIndex] = {} - statusType = "Vessel Temp: " - statusValue = str(round(prevTempJson['bbves'], 1)) + tempSuffix - status[statusIndex].update({statusType: statusValue}) - statusIndex = statusIndex + 1 - # End: Brew Bubbles Items - - # Begin: Tilt Items - if tilt or tiltbridge: - # if not config['dataLogging'] == 'active': # Only display SG in status when not logging data - if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: # Use as a check to see if it's online - if checkKey(prevTempJson, config['tiltColor'] + 'SG'): - if prevTempJson[config['tiltColor'] + 'SG'] is not None: + if checkKey(prevTempJson, 'bbamb'): + # filter out disconnected sensors + if not int(prevTempJson['bbamb']) == -100: + status[statusIndex] = {} + statusType = "Ambient Temp: " + statusValue = str( + round(prevTempJson['bbamb'], 1)) + tempSuffix + status[statusIndex].update( + {statusType: statusValue}) + statusIndex = statusIndex + 1 + if checkKey(prevTempJson, 'bbves'): + # filter out disconnected sensors + if not int(prevTempJson['bbves']) == -100: + status[statusIndex] = {} + statusType = "Vessel Temp: " + statusValue = str( + round(prevTempJson['bbves'], 1)) + tempSuffix + status[statusIndex].update( + {statusType: statusValue}) + statusIndex = statusIndex + 1 + # End: Brew Bubbles Items + + # Begin: Tilt Items + if tilt or tiltbridge: + # if not config['dataLogging'] == 'active': # Only display SG in status when not logging data + # Use as a check to see if it's online + if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: + if checkKey(prevTempJson, config['tiltColor'] + 'SG'): + if prevTempJson[config['tiltColor'] + 'SG'] is not None: + status[statusIndex] = {} + statusType = "Tilt SG: " + statusValue = str( + prevTempJson[config['tiltColor'] + 'SG']) + status[statusIndex].update( + {statusType: statusValue}) + statusIndex = statusIndex + 1 + if checkKey(prevTempJson, config['tiltColor'] + 'Batt'): + if prevTempJson[config['tiltColor'] + 'Batt'] is not None: + if not prevTempJson[config['tiltColor'] + 'Batt'] == 0: + status[statusIndex] = {} + statusType = "Tilt Batt Age: " + statusValue = str( + round(prevTempJson[config['tiltColor'] + 'Batt'], 1)) + " wks" + status[statusIndex].update( + {statusType: statusValue}) + statusIndex = statusIndex + 1 + # and (statusIndex <= 3): + if checkKey(prevTempJson, config['tiltColor'] + 'Temp'): + if prevTempJson[config['tiltColor'] + 'Temp'] is not None: + if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: + status[statusIndex] = {} + statusType = "Tilt Temp: " + statusValue = str( + round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix + status[statusIndex].update( + {statusType: statusValue}) + statusIndex = statusIndex + 1 + # End: Tilt Items + + # Begin: iSpindel Items + if ispindel is not None: + # if config['dataLogging'] == 'active': # Only display SG in status when not logging data + if checkKey(prevTempJson, 'spinSG'): + if prevTempJson['spinSG'] is not None: status[statusIndex] = {} - statusType = "Tilt SG: " - statusValue = str(prevTempJson[config['tiltColor'] + 'SG']) - status[statusIndex].update({statusType: statusValue}) + statusType = "iSpindel SG: " + statusValue = str(prevTempJson['spinSG']) + status[statusIndex].update( + {statusType: statusValue}) statusIndex = statusIndex + 1 - if checkKey(prevTempJson, config['tiltColor'] + 'Batt'): - if prevTempJson[config['tiltColor'] + 'Batt'] is not None: - if not prevTempJson[config['tiltColor'] + 'Batt'] == 0: + if checkKey(prevTempJson, 'spinBatt'): + if prevTempJson['spinBatt'] is not None: status[statusIndex] = {} - statusType = "Tilt Batt Age: " - statusValue = str(round(prevTempJson[config['tiltColor'] + 'Batt'], 1)) + " wks" - status[statusIndex].update({statusType: statusValue}) + statusType = "iSpindel Batt: " + statusValue = str( + round(prevTempJson['spinBatt'], 1)) + "VDC" + status[statusIndex].update( + {statusType: statusValue}) statusIndex = statusIndex + 1 - if checkKey(prevTempJson, config['tiltColor'] + 'Temp'): # and (statusIndex <= 3): - if prevTempJson[config['tiltColor'] + 'Temp'] is not None: - if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: + if checkKey(prevTempJson, 'spinTemp'): + if prevTempJson['spinTemp'] is not None: status[statusIndex] = {} - statusType = "Tilt Temp: " - statusValue = str(round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix - status[statusIndex].update({statusType: statusValue}) + statusType = "iSpindel Temp: " + statusValue = str( + round(prevTempJson['spinTemp'], 1)) + tempSuffix + status[statusIndex].update( + {statusType: statusValue}) statusIndex = statusIndex + 1 - # End: Tilt Items + # End: iSpindel Items - # Begin: iSpindel Items - if ispindel is not None: - # if config['dataLogging'] == 'active': # Only display SG in status when not logging data - if checkKey(prevTempJson, 'spinSG'): - if prevTempJson['spinSG'] is not None: - status[statusIndex] = {} - statusType = "iSpindel SG: " - statusValue = str(prevTempJson['spinSG']) - status[statusIndex].update({statusType: statusValue}) - statusIndex = statusIndex + 1 - if checkKey(prevTempJson, 'spinBatt'): - if prevTempJson['spinBatt'] is not None: - status[statusIndex] = {} - statusType = "iSpindel Batt: " - statusValue = str(round(prevTempJson['spinBatt'], 1)) + "VDC" - status[statusIndex].update({statusType: statusValue}) - statusIndex = statusIndex + 1 - if checkKey(prevTempJson, 'spinTemp'): - if prevTempJson['spinTemp'] is not None: - status[statusIndex] = {} - statusType = "iSpindel Temp: " - statusValue = str(round(prevTempJson['spinTemp'], 1)) + tempSuffix - status[statusIndex].update({statusType: statusValue}) - statusIndex = statusIndex + 1 - # End: iSpindel Items - - conn.send(json.dumps(status).encode('utf-8')) - - else: # Invalid message received - logMessage("ERROR. Received invalid message on socket: " + message) - - if (time.time() - prevTimeOut) < serialCheckInterval: - continue - else: # Raise exception to check serial for data immediately - raise socket.timeout - - except socket.timeout: # Do serial communication and update settings every SerialCheckInterval - prevTimeOut = time.time() - - if hwVersion is None: # Do nothing if we cannot read version - # Controller has not been recognized - continue - - if(time.time() - prevLcdUpdate) > 5: # Request new LCD value - prevLcdUpdate += 5 # Give the controller some time to respond - bg_ser.write('l') - - if(time.time() - prevSettingsUpdate) > 60: # Request Settings from controller - # Controller should send updates on changes, this is a periodic - # update to ensure it is up to date - prevSettingsUpdate += 5 # Give the controller some time to respond - bg_ser.write('s') - - # If no new data has been received for serialRequestInteval seconds - if (time.time() - prevDataTime) >= float(config['interval']): - if prevDataTime == 0: # First time through set the previous time - prevDataTime = time.time() - prevDataTime += 5 # Give the controller some time to respond to prevent requesting twice - bg_ser.write("t") # Request new from controller - prevDataTime += 5 # Give the controller some time to respond to prevent requesting twice - - # Controller not responding - elif (time.time() - prevDataTime) > float(config['interval']) + 2 * float(config['interval']): - logMessage( - "ERROR: Controller is not responding to new data requests.") - - while True: # Read lines from controller - line = bg_ser.read_line() - message = bg_ser.read_message() - if line is None and message is None: - break - if line is not None: - try: - if line[0] == 'T': # Temp info received - # Store time of last new data for interval check - prevDataTime = time.time() - - if config['dataLogging'] == 'paused' or config['dataLogging'] == 'stopped': - continue # Skip if logging is paused or stopped - - # Process temperature line - newData = json.loads(line[2:]) - # Copy/rename keys - for key in newData: - prevTempJson[renameTempKey(key)] = newData[key] - - # If we are running Tilt, get current values - if (tilt is not None) and (tiltbridge is not None): - # Check each of the Tilt colors - for color in Tilt.TILT_COLORS: - # Only log the Tilt if the color matches the config - if color == config["tiltColor"]: - tiltValue = tilt.getValue() - if tiltValue is not None: - _temp = tiltValue.temperature - if cc['tempFormat'] == 'C': - _temp = bc.convert(_temp, 'F', 'C') - - prevTempJson[color + - 'Temp'] = round(_temp, 2) - prevTempJson[color + - 'SG'] = round(tiltValue.gravity, 3) - prevTempJson[color + - 'Batt'] = round(tiltValue.battery, 3) - else: - prevTempJson[color + 'Temp'] = None - prevTempJson[color + 'SG'] = None - prevTempJson[color + 'Batt'] = None - - # Expire old BB keypairs - if (time.time() - lastBbApi) > timeoutBB: - if checkKey(prevTempJson, 'bbbpm'): - del prevTempJson['bbbpm'] - if checkKey(prevTempJson, 'bbamb'): - del prevTempJson['bbamb'] - if checkKey(prevTempJson, 'bbves'): - del prevTempJson['bbves'] - - # Expire old iSpindel keypairs - if (time.time() - lastiSpindel) > timeoutiSpindel: - if checkKey(prevTempJson, 'spinSG'): - prevTempJson['spinSG'] = None - if checkKey(prevTempJson, 'spinBatt'): - prevTempJson['spinBatt'] = None - if checkKey(prevTempJson, 'spinTemp'): - prevTempJson['spinTemp'] = None - - # Expire old Tiltbridge values - if ((time.time() - lastTiltbridge) > timeoutTiltbridge) and tiltbridge == True: - tiltbridge = False # Turn off Tiltbridge in case we switched to BT - logMessage("Turned off Tiltbridge.") - if checkKey(prevTempJson, color + 'Temp'): - prevTempJson[color + 'Temp'] = None - if checkKey(prevTempJson, color + 'SG'): - prevTempJson[color + 'SG'] = None - if checkKey(prevTempJson, color + 'Batt'): - prevTempJson[color + 'Batt'] = None - - # Get newRow - newRow = prevTempJson + phpConn.send(json.dumps(status).encode('utf-8')) + else: # Invalid message received + logMessage( + "ERROR. Received invalid message on socket: " + message) - # Log received line if true, false is short message, none = mute - if outputJson == True: # Log full JSON - logMessage("Update: " + json.dumps(newRow)) - elif outputJson == False: # Log only a notice - logMessage('New JSON received from controller.') - else: # Don't log JSON messages - pass + if (time.time() - prevTimeOut) < serialCheckInterval: + continue + else: # Raise exception to check serial for data immediately + raise socket.timeout - # Add row to JSON file - # Handle if we are running Tilt or iSpindel - if checkKey(config, 'tiltColor'): - brewpiJson.addRow( - localJsonFileName, newRow, config['tiltColor'], None) - elif checkKey(config, 'iSpindel'): - brewpiJson.addRow( - localJsonFileName, newRow, None, config['iSpindel']) - else: - brewpiJson.addRow( - localJsonFileName, newRow, None, None) + except socket.timeout: # Do serial communication and update settings every SerialCheckInterval + prevTimeOut = time.time() - # Copy to www dir. Do not write directly to www dir to - # prevent blocking www file. - shutil.copyfile(localJsonFileName, wwwJsonFileName) + if hwVersion is None: # Do nothing if we cannot read version + # Controller has not been recognized + continue - # Check if CSV file exists, if not do a header - if not os.path.exists(localCsvFileName): + if(time.time() - prevLcdUpdate) > 5: # Request new LCD value + prevLcdUpdate += 5 # Give the controller some time to respond + bgSerialConn.write('l') + + if(time.time() - prevSettingsUpdate) > 60: # Request Settings from controller + # Controller should send updates on changes, this is a periodic + # update to ensure it is up to date + prevSettingsUpdate += 5 # Give the controller some time to respond + bgSerialConn.write('s') + + # If no new data has been received for serialRequestInteval seconds + if (time.time() - prevDataTime) >= float(config['interval']): + if prevDataTime == 0: # First time through set the previous time + prevDataTime = time.time() + prevDataTime += 5 # Give the controller some time to respond to prevent requesting twice + bgSerialConn.write("t") # Request new from controller + prevDataTime += 5 # Give the controller some time to respond to prevent requesting twice + + # Controller not responding + elif (time.time() - prevDataTime) > float(config['interval']) + 2 * float(config['interval']): + logMessage( + "ERROR: Controller is not responding to new data requests.") + + while True: # Read lines from controller + line = bgSerialConn.read_line() + message = bgSerialConn.read_message() + + if line is None and message is None: # We raised serial.error but have no messages + break + if line is not None: # We have a message to process + try: + if line[0] == 'T': # Temp info received + # Store time of last new data for interval check + prevDataTime = time.time() + + if config['dataLogging'] == 'paused' or config['dataLogging'] == 'stopped': + continue # Skip if logging is paused or stopped + + # Process temperature line + newData = json.loads(line[2:]) + # Copy/rename keys + for key in newData: + prevTempJson[renameTempKey( + key)] = newData[key] + + # If we are running Tilt, get current values + if (tilt is not None) and (tiltbridge is not None): + # Check each of the Tilt colors + for color in Tilt.TILT_COLORS: + # Only log the Tilt if the color matches the config + if color == config["tiltColor"]: + tiltValue = tilt.getValue() + if tiltValue is not None: + _temp = tiltValue.temperature + if cc['tempFormat'] == 'C': + _temp = bc.convert( + _temp, 'F', 'C') + + prevTempJson[color + + 'Temp'] = round(_temp, 2) + prevTempJson[color + + 'SG'] = round(tiltValue.gravity, 3) + prevTempJson[color + + 'Batt'] = round(tiltValue.battery, 3) + else: + prevTempJson[color + + 'Temp'] = None + prevTempJson[color + + 'SG'] = None + prevTempJson[color + + 'Batt'] = None + + # Expire old BB keypairs + if (time.time() - lastBbApi) > timeoutBB: + if checkKey(prevTempJson, 'bbbpm'): + del prevTempJson['bbbpm'] + if checkKey(prevTempJson, 'bbamb'): + del prevTempJson['bbamb'] + if checkKey(prevTempJson, 'bbves'): + del prevTempJson['bbves'] + + # Expire old iSpindel keypairs + if (time.time() - lastiSpindel) > timeoutiSpindel: + if checkKey(prevTempJson, 'spinSG'): + prevTempJson['spinSG'] = None + if checkKey(prevTempJson, 'spinBatt'): + prevTempJson['spinBatt'] = None + if checkKey(prevTempJson, 'spinTemp'): + prevTempJson['spinTemp'] = None + + # Expire old Tiltbridge values + if ((time.time() - lastTiltbridge) > timeoutTiltbridge) and tiltbridge == True: + tiltbridge = False # Turn off Tiltbridge in case we switched to BT + logMessage("Turned off Tiltbridge.") + if checkKey(prevTempJson, color + 'Temp'): + prevTempJson[color + 'Temp'] = None + if checkKey(prevTempJson, color + 'SG'): + prevTempJson[color + 'SG'] = None + if checkKey(prevTempJson, color + 'Batt'): + prevTempJson[color + 'Batt'] = None + + # Get newRow + newRow = prevTempJson + + # Log received JSON if true, false is short message, none = mute + if outputJson == True: # Log full JSON + logMessage("Update: " + json.dumps(newRow)) + elif outputJson == False: # Log only a notice + logMessage( + 'New JSON received from controller.') + else: # Don't log JSON messages + pass + + # Add row to JSON file + # Handle if we are running Tilt or iSpindel + if checkKey(config, 'tiltColor'): + brewpiJson.addRow( + localJsonFileName, newRow, config['tiltColor'], None) + elif checkKey(config, 'iSpindel'): + brewpiJson.addRow( + localJsonFileName, newRow, None, config['iSpindel']) + else: + brewpiJson.addRow( + localJsonFileName, newRow, None, None) + + # Copy to www dir. Do not write directly to www dir to + # prevent blocking www file. + shutil.copyfile( + localJsonFileName, wwwJsonFileName) + + # Check if CSV file exists, if not do a header + if not os.path.exists(localCsvFileName): + csvFile = open(localCsvFileName, "a") + delim = ',' + sepSemaphore = "SEP=" + delim + '\r\n' + lineToWrite = sepSemaphore # Has to be first line + try: + lineToWrite += ('Timestamp' + delim + + 'Beer Temp' + delim + + 'Beer Set' + delim + + 'Beer Annot' + delim + + 'Chamber Temp' + delim + + 'Chamber Set' + delim + + 'Chamber Annot' + delim + + 'Room Temp' + delim + + 'State') + + # If we are configured to run a Tilt + if tilt: + # Write out Tilt Temp and SG Values + for color in Tilt.TILT_COLORS: + # Only log the Tilt if the color is correct according to config + if color == config["tiltColor"]: + if prevTempJson.get(color + 'Temp') is not None: + lineToWrite += (delim + + color + 'Tilt SG') + + # If we are configured to run an iSpindel + if ispindel: + lineToWrite += (delim + + 'iSpindel SG') + + lineToWrite += '\r\n' + csvFile.write + csvFile.write(lineToWrite) + csvFile.close() + + except IOError as e: + logMessage( + "Unknown error: %s" % str(e)) + + # Now write data to csv file as well csvFile = open(localCsvFileName, "a") delim = ',' - sepSemaphore = "SEP=" + delim + '\r\n' - lineToWrite = sepSemaphore # Has to be first line try: - lineToWrite += ('Timestamp' + delim + - 'Beer Temp' + delim + - 'Beer Set' + delim + - 'Beer Annot' + delim + - 'Chamber Temp' + delim + - 'Chamber Set' + delim + - 'Chamber Annot' + delim + - 'Room Temp' + delim + - 'State') + lineToWrite = (time.strftime("%Y-%m-%d %H:%M:%S") + delim + + json.dumps(newRow['BeerTemp']) + delim + + json.dumps(newRow['BeerSet']) + delim + + json.dumps(newRow['BeerAnn']) + delim + + json.dumps(newRow['FridgeTemp']) + delim + + json.dumps(newRow['FridgeSet']) + delim + + json.dumps(newRow['FridgeAnn']) + delim + + json.dumps(newRow['RoomTemp']) + delim + + json.dumps(newRow['State'])) # If we are configured to run a Tilt if tilt: @@ -1389,188 +1589,201 @@ def renameTempKey(key): for color in Tilt.TILT_COLORS: # Only log the Tilt if the color is correct according to config if color == config["tiltColor"]: - if prevTempJson.get(color + 'Temp') is not None: - lineToWrite += (delim + - color + 'Tilt SG') + if prevTempJson.get(color + 'SG') is not None: + lineToWrite += (delim + json.dumps( + prevTempJson[color + 'SG'])) # If we are configured to run an iSpindel if ispindel: - lineToWrite += (delim + 'iSpindel SG') + lineToWrite += (delim + + json.dumps(newRow['spinSG'])) lineToWrite += '\r\n' - csvFile.write csvFile.write(lineToWrite) - csvFile.close() - - except IOError as e: + except KeyError as e: logMessage( - "Unknown error: %s" % str(e)) - - # Now write data to csv file as well - csvFile = open(localCsvFileName, "a") - delim = ',' - try: - lineToWrite = (time.strftime("%Y-%m-%d %H:%M:%S") + delim + - json.dumps(newRow['BeerTemp']) + delim + - json.dumps(newRow['BeerSet']) + delim + - json.dumps(newRow['BeerAnn']) + delim + - json.dumps(newRow['FridgeTemp']) + delim + - json.dumps(newRow['FridgeSet']) + delim + - json.dumps(newRow['FridgeAnn']) + delim + - json.dumps(newRow['RoomTemp']) + delim + - json.dumps(newRow['State'])) - - # If we are configured to run a Tilt - if tilt: - # Write out Tilt Temp and SG Values - for color in Tilt.TILT_COLORS: - # Only log the Tilt if the color is correct according to config - if color == config["tiltColor"]: - if prevTempJson.get(color + 'SG') is not None: - lineToWrite += (delim + json.dumps( - prevTempJson[color + 'SG'])) - - # If we are configured to run an iSpindel - if ispindel: - lineToWrite += (delim + - json.dumps(newRow['spinSG'])) - - lineToWrite += '\r\n' - csvFile.write(lineToWrite) - except KeyError as e: - logMessage( - "KeyError in line from controller: %s" % str(e)) + "KeyError in line from controller: %s" % str(e)) - csvFile.close() - shutil.copyfile(localCsvFileName, wwwCsvFileName) - elif line[0] == 'D': # Debug message received - # Should already been filtered out, but print anyway here. - logMessage( - "Finding a debug message here should not be possible.") - logMessage("Line received was: {0}".format(line)) - elif line[0] == 'L': # LCD content received - prevLcdUpdate = time.time() - lcdText = json.loads(line[2:]) - lcdText[1] = lcdText[1].replace(lcdText[1][18], "°") - lcdText[2] = lcdText[2].replace(lcdText[2][18], "°") - elif line[0] == 'C': # Control constants received - cc = json.loads(line[2:]) - # Update the json with the right temp format for the web page - if 'tempFormat' in cc: - changeWwwSetting('tempFormat', cc['tempFormat']) - elif line[0] == 'S': # Control settings received - prevSettingsUpdate = time.time() - cs = json.loads(line[2:]) - # Do not print this to the log file. This is requested continuously. - elif line[0] == 'V': # Control variables received - cv = json.loads(line[2:]) - elif line[0] == 'N': # Version number received - # Do nothing, just ignore - pass - elif line[0] == 'h': # Available devices received - deviceList['available'] = json.loads(line[2:]) - oldListState = deviceList['listState'] - deviceList['listState'] = oldListState.strip('h') + "h" - logMessage("Available devices received: " + - json.dumps(deviceList['available'])) - elif line[0] == 'd': # Installed devices received - deviceList['installed'] = json.loads(line[2:]) - oldListState = deviceList['listState'] - deviceList['listState'] = oldListState.strip('d') + "d" - logMessage("Installed devices received: " + - json.dumps(deviceList['installed'])) - elif line[0] == 'U': # Device update received - logMessage("Device updated to: " + line[2:]) - else: # Unknown message received + csvFile.close() + shutil.copyfile( + localCsvFileName, wwwCsvFileName) + elif line[0] == 'D': # Debug message received + # Should already been filtered out, but print anyway here. + logMessage( + "Finding a debug message here should not be possible.") + logMessage( + "Line received was: {0}".format(line)) + elif line[0] == 'L': # LCD content received + prevLcdUpdate = time.time() + lcdText = json.loads(line[2:]) + lcdText[1] = lcdText[1].replace( + lcdText[1][18], "°") + lcdText[2] = lcdText[2].replace( + lcdText[2][18], "°") + elif line[0] == 'C': # Control constants received + cc = json.loads(line[2:]) + # Update the json with the right temp format for the web page + if 'tempFormat' in cc: + changeWwwSetting( + 'tempFormat', cc['tempFormat']) + elif line[0] == 'S': # Control settings received + prevSettingsUpdate = time.time() + cs = json.loads(line[2:]) + # Do not print this to the log file. This is requested continuously. + elif line[0] == 'V': # Control variables received + cv = json.loads(line[2:]) + elif line[0] == 'N': # Version number received + # Do nothing, just ignore + pass + elif line[0] == 'h': # Available devices received + deviceList['available'] = json.loads(line[2:]) + oldListState = deviceList['listState'] + deviceList['listState'] = oldListState.strip( + 'h') + "h" + logMessage("Available devices received: " + + json.dumps(deviceList['available'])) + elif line[0] == 'd': # Installed devices received + deviceList['installed'] = json.loads(line[2:]) + oldListState = deviceList['listState'] + deviceList['listState'] = oldListState.strip( + 'd') + "d" + logMessage("Installed devices received: " + + json.dumps(deviceList['installed'])) + elif line[0] == 'U': # Device update received + logMessage("Device updated to: " + line[2:]) + else: # Unknown message received + logMessage( + "Cannot process line from controller: " + line) + # End of processing a line + except json.decoder.JSONDecodeError as e: + logMessage("JSON decode error: %s" % str(e)) + logMessage("Line received was: " + line) + + if message is not None: # Other (debug?) message received + try: + pass # I don't think we need to log this + # expandedMessage = expandLogMessage.expandLogMessage(message) + # logMessage("Controller debug message: " + expandedMessage) + except Exception as e: + # Catch all exceptions, because out of date file could + # cause errors logMessage( - "Cannot process line from controller: " + line) - # End of processing a line - except json.decoder.JSONDecodeError as e: - logMessage("JSON decode error: %s" % str(e)) - logMessage("Line received was: " + line) + "Error while expanding log message: '" + message + "'" + str(e)) + + if cs['mode'] == 'p': # Check for update from temperature profile + newTemp = temperatureProfile.getNewTemp(util.scriptPath()) + if newTemp != cs['beerSet']: + cs['beerSet'] = newTemp + # If temperature has to be updated send settings to controller + bgSerialConn.write( + "j{beerSet:" + json.dumps(cs['beerSet']) + "}") + + except ConnectionError as e: + type, value, traceback = sys.exc_info() + fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] + logError("Caught a socket error.") + logError("Error info:") + logError("\tError: ({0}): '{1}'".format( + getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) + logError("\tType: {0}".format(type)) + logError("\tFilename: {0}".format(fname)) + logError("\tLineNo: {0}".format(traceback.tb_lineno)) + logMessage("Caught a socket error, exiting.") + sys.stderr.close() + run = False # This should let the loop exit gracefully + + except KeyboardInterrupt: + print() # Simply a visual hack if we are running via command line + logMessage("Detected keyboard interrupt, exiting.") + + except Exception as e: + type, value, traceback = sys.exc_info() + fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] + logError("Caught an unexpected exception.") + logError("Error info:") + logError("\tError: ({0}): '{1}'".format( + getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) + logError("\tType: {0}".format(type)) + logError("\tFilename: {0}".format(fname)) + logError("\tLineNo: {0}".format(traceback.tb_lineno)) + logMessage("Caught an unexpected exception, exiting.") + + +def shutdown(): # Process a graceful shutdown + global bgSerialConn + global tilt + global thread + global serialConn + global conn - if message is not None: # Other (debug?) message received - try: - pass # I don't think we need to log this - # expandedMessage = expandLogMessage.expandLogMessage(message) - # logMessage("Controller debug message: " + expandedMessage) - except Exception as e: - # Catch all exceptions, because out of date file could - # cause errors - logMessage( - "Error while expanding log message: '" + message + "'" + str(e)) - - if cs['mode'] == 'p': # Check for update from temperature profile - newTemp = temperatureProfile.getNewTemp(util.scriptPath()) - if newTemp != cs['beerSet']: - cs['beerSet'] = newTemp - # If temperature has to be updated send settings to controller - bg_ser.write("j{beerSet:" + json.dumps(cs['beerSet']) + "}") - - except ConnectionError as e: - type, value, traceback = sys.exc_info() - fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] - logError("Caught a socket error.") - logError("Error info:") - logError("\tError: ({0}): '{1}'".format(getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) - logError("\tType: {0}".format(type)) - logError("\tFilename: {0}".format(fname)) - logError("\tLineNo: {0}".format(traceback.tb_lineno)) - logMessage("Caught a socket error, exiting.") - sys.stderr.close() - run = 0 # This should let the loop exit gracefully - -except KeyboardInterrupt: - print() # Simply a visual hack if we are running via command line - logMessage("Detected keyboard interrupt, exiting.") - run = 0 # This should let the loop exit gracefully - -except Exception as e: - type, value, traceback = sys.exc_info() - fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] - logError("Caught an unexpected exception.") - logError("Error info:") - logError("\tError: ({0}): '{1}'".format(getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) - logError("\tType: {0}".format(type)) - logError("\tFilename: {0}".format(fname)) - logError("\tLineNo: {0}".format(traceback.tb_lineno)) - logMessage("Caught an unexpected exception, exiting.") - run = 0 # This should let the loop exit gracefully - -# Process a graceful shutdown: - -try: bg_ser -except NameError: bg_ser = None -if bg_ser is not None: # If we are running background serial, stop it - logMessage("Stopping background serial processing.") - bg_ser.stop() - -try: tilt -except NameError: tilt = None -if tilt is not None: # If we are running a Tilt, stop it - logMessage("Stopping Tilt.") - tilt.stop() - -try: thread -except NameError: thread = None -if thread is not None: # Allow any spawned threads to quit - for thread in threads: - logMessage("Waiting for threads to finish.") - _thread.join() - -try: ser -except NameError: ser = None -if ser is not None: # If we opened a serial port, close it - if ser.isOpen(): - logMessage("Closing port.") - ser.close() # Close port - -try: conn -except NameError: conn = None -if conn is not None: # Close any open socket - logMessage("Closing open sockets.") - conn.shutdown(socket.SHUT_RDWR) # Close socket - conn.close() - -logMessage("Exiting.") -sys.exit(0) # Exit script + try: + bgSerialConn # If we are running background serial, stop it + except NameError: + bgSerialConn = None + if bgSerialConn is not None: + logMessage("Stopping background serial processing.") + bgSerialConn.stop() + + try: + tilt # If we are running a Tilt, stop it + except NameError: + tilt = None + if tilt is not None: + logMessage("Stopping Tilt.") + tilt.stop() + + try: + thread # Allow any spawned threads to quit + except NameError: + thread = None + if thread is not None: + for thread in threads: + logMessage("Waiting for threads to finish.") + _thread.join() + + try: + serialConn # If we opened a serial port, close it + except NameError: + serialConn = None + if serialConn is not None: + if serialConn.isOpen(): + logMessage("Closing port.") + serialConn.close() # Close port + + try: + conn # Close any open socket + except NameError: + conn = None + if conn is not None: + logMessage("Closing open sockets.") + phpConn.shutdown(socket.SHUT_RDWR) # Close socket + phpConn.close() + + +def main(): + global checkStartupOnly + global config + # os.chdir(os.path.dirname(os.path.realpath(__file__))) + getGit() # Retrieve git (version) information + options() # Parse command line options + config() # Load config file + checkDoNotRun() # Check do not run file + checkOthers() # Check for other running brewpi + if checkStartupOnly: + sys.exit(0) + setUpLog() # Set up log files + setSocket() # Set up listening socket for PHP + startLogs() # Start log file(s) + initTilt() # Set up Tilt + initISpindel() # Initialize iSpindel + startSerial() # Begin serial connections + + loop() # Main processing loop + shutdown() # Process gracefull shutdown + logMessage("Exiting.") + + +if __name__ == "__main__": + # execute only if run as a script + main() + sys.exit(0) # Exit script From 07d229d00a637554b1d08820ddba03cc9d777475 Mon Sep 17 00:00:00 2001 From: lbussy Date: Sun, 16 Aug 2020 14:49:31 -0500 Subject: [PATCH 02/22] Major refactor, add tests --- BrewPiUtil.py | 356 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 302 insertions(+), 54 deletions(-) diff --git a/BrewPiUtil.py b/BrewPiUtil.py index 88592b6..6db248f 100755 --- a/BrewPiUtil.py +++ b/BrewPiUtil.py @@ -41,7 +41,7 @@ import grp from psutil import process_iter as ps from time import sleep, strftime -import configobj +from configobj import ConfigObj, ParseError import BrewPiSocket import autoSerial import BrewPiProcess @@ -58,113 +58,293 @@ def addSlash(path): return path -def readCfgWithDefaults(cfg): +def readCfgWithDefaults(configFile = None): """ Reads a config file with the default config file as fallback Params: - cfg: string, path to cfg file - defaultCfg: string, path to defaultConfig file. + configFile: string, path to config file (defaults to {scriptpath}/settings/config.cfg) Returns: ConfigObj of settings """ - if not cfg: - cfg = '{0}settings/config.cfg'.format(addSlash(path[0])) - # Added to fix default config file detection for multi-chamber - if cfg: - defaultCfg = '{0}/defaults.cfg'.format(os.path.dirname(cfg)) + # Get settings folder + settings = '{0}settings/'.format(scriptPath()) - # Conditional line added to fix default config file detection for multi-chamber - if not defaultCfg: - defaultCfg = '{0}settings/defaults.cfg'.format(addSlash(scriptPath())) + # The configFile is always a named file rather than the default one + if not configFile: + configFile = '{0}config.cfg'.format(settings) - config = configobj.ConfigObj(defaultCfg) + # Now grab the default config file + defaultFile = '{0}defaults.cfg'.format(settings) - if cfg: + error = 0 + try: + defCfg = ConfigObj(defaultFile, file_error=True) + except ParseError: + error = 1 + logError("Could not parse default config file:") + logError("{0}".format(defaultFile)) + except IOError: + error = 1 + logError("Could not open default config file:") + logError("{0}".format(defaultFile)) + + # Write default.cfg file if it's missing + if error: + defCfg = ConfigObj() + defCfg.filename = defaultFile + defCfg['scriptPath'] = '/home/brewpi/' + defCfg['wwwPath'] = '/var/www/html/' + defCfg['port'] = 'auto' + defCfg['altport'] = None + defCfg['boardType'] = 'arduino' + defCfg['beerName'] = 'My BrewPi Remix Run' + defCfg['interval'] = '120.0' + defCfg['dataLogging'] = 'active' + defCfg['logJson'] = True + defCfg.write() + + if configFile: try: - userConfig = configobj.ConfigObj(cfg) - config.merge(userConfig) - except configobj.ParseError: - logMessage( - "ERROR: Could not parse user config file {0}.".format(cfg)) + userConfig = ConfigObj(configFile, file_error=True) + defCfg.merge(userConfig) + except ParseError: + error = 1 + logError("Could not parse user config file:") + logError("{0}".format(configFile)) except IOError: - logMessage( - "Could not open user config file {0}. Using default config file.".format(cfg)) - return config + error = 1 + logMessage("No user config file found:") + logMessage("{0}".format(configFile)) + logMessage("Using default configuration.") + + # Fix pathnames + defCfg['scriptPath'] = addSlash(defCfg['scriptPath']) + defCfg['wwwPath'] = addSlash(defCfg['wwwPath']) + return defCfg + + +def configSet(settingName, value, configFile = None): + """ + Merge in new or updated configuration + + Params: + settingName: Name of setting to write + value: Value of setting to write + configFile (optional): Name of configuration file (defaults to config.cfg) + Returns: + ConfigObj of current settings + """ + settings = '{0}settings/'.format(scriptPath()) + newConfigFile = "{0}config.cfg".format(settings) + fileExists = True + + # If we passed a configFile + if configFile: + # Check for the file + if not os.path.isfile(configFile): + # If there's no configFile, assume a "/" in it means a valid path at least + if "/" in configFile: + # Path seems legit, create it later + fileExists = False + else: + # Maybe it was a simple filename, add path and try again + configFile = "{0}{1}".format(settings, configFile) + if not os.path.isfile(configFile): + # Still no dice + fileExists = False + # No configFile passed + else: + # Path/File exists + fileExists = True + # No config file passed + else: + # Default to config.cfg in settings directory + configFile = newConfigFile + if not os.path.isfile(configFile): + fileExists = False + + # If there's no config file, create it and set permissions + if not fileExists: + try: + # Create the file + open(configFile, 'a').close() + # chmod 660 the new file + fileMode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP + os.chmod(configFile, fileMode) + logMessage("Config file {0} did not exist, created new file.".format(configFile)) + except: + logError("Unable to create '{0}'.".format(configFile)) + return readCfgWithDefaults() -def configSet(configFile, settingName, value): - if not os.path.isfile(configFile): - logMessage("Config file {0} does not exist.".format(configFile)) - logMessage("Creating with defaults") + # Add or update the setting try: - config = configobj.ConfigObj(configFile) + comment = "File created or updated on {0}\n#".format(strftime("%Y-%m-%d %H:%M:%S")) + config = ConfigObj() + config = readCfgWithDefaults() + config.initial_comment = [comment] + config.filename = configFile config[settingName] = value config.write() + except ParseError: + logError("Invalid configuration settings: '{0}': '{1}'".format(settingName, value)) + except IOError as e: + logError("I/O error({0}) while setting permissions on:".format(e.errno)) + logError("{0}:".format(configFile)) + logError("{0}.".format(e.strerror)) + logError("You are not running as root or brewpi, or your") + logError("permissions are not set correctly. To fix this, run:") + logError("sudo {0}utils/doPerms.sh".format(scriptPath())) + # chmod 660 the config file just in case + try: fileMode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP os.chmod(configFile, fileMode) except IOError as e: - logMessage( - "I/O error({0}) while updating {1}: {2}\n".format(e.errno, configFile, e.strerror)) - logMessage("Your permissions likely are not set correctly. To fix\n", - "this, run 'sudo ./fixPermissions.sh' from your Tools directory.") - return readCfgWithDefaults(configFile) # Return updated ConfigObj + logError("I/O error({0}) while setting permissions on:".format(e.errno)) + logError("{0}:".format(configFile)) + logError("{0}.".format(e.strerror)) + logError("You are not running as root or brewpi, or your") + logError("permissions are not set correctly. To fix this, run:") + logError("sudo {0}utils/doPerms.sh".format(scriptPath())) + return readCfgWithDefaults(configFile) # Return (hopefully updated) ConfigObj + + +def scriptPath(): + """ + Return the path of this file + + Params: + None + + Returns: + Path of module with trailing slash + """ + retVal = "" + if frozen(): + retVal = os.path.dirname( + str(sys.executable, sys.getfilesystemencoding())) + try: + retVal = os.path.dirname( + str(os.path.abspath(__file__), sys.getfilesystemencoding())) + except: + retVal = os.getcwd() + + return addSlash(retVal) + + +def frozen(): + """ + Returns whether we are frozen via py2exe + + Params: + None + + Returns: + True if executing a frozen script, False if not + """ + return hasattr(sys, "frozen") def printStdErr(*objs): - print(*objs, file=sys.stderr) + """ + Prints the values to environment's sys.stderr with flush + """ + print(*objs, file=sys.stderr, flush=True) def printStdOut(*objs): - print(*objs, file=sys.stdout) + """ + Prints the values to environment's sys.stdout with flush + """ + print(*objs, file=sys.stdout, flush=True) def logMessage(*objs): """ - Prints a timestamped message to stdout + Prints a timestamped information message to stdout """ - printStdOut(strftime("%Y-%m-%d %H:%M:%S "), *objs) + if 'USE_TIMESTAMP_LOG' in os.environ: + printStdOut(strftime("%Y-%m-%d %H:%M:%S [N]"), *objs) + else: + printStdOut(*objs) + + +def logWarn(*objs): + """ + Prints a timestamped warning message to stdout + """ + if 'USE_TIMESTAMP_LOG' in os.environ: + printStdOut(strftime("%Y-%m-%d %H:%M:%S [W]"), *objs) + else: + printStdOut(*objs) def logError(*objs): """ Prints a timestamped message to stderr """ - printStdErr(strftime("%Y-%m-%d %H:%M:%S "), *objs) + if 'USE_TIMESTAMP_LOG' in os.environ: + printStdOut(strftime("%Y-%m-%d %H:%M:%S [E]"), *objs) + else: + printStdOut(*objs) -def scriptPath(): - """ - Return the path of BrewPiUtil.py. __file__ only works in modules, not in the main script. - That is why this function is needed. +def removeDontRunFile(path = None): """ - return os.path.dirname(os.path.abspath(__file__)) + Removes the semaphore file which prevents script processing + Returns: + True: File deleted + False: Unable to delete file + None: File does not exist + """ + if not path: + config = readCfgWithDefaults() + path = "{0}do_not_run_brewpi".format(config['wwwPath']) -def removeDontRunFile(path='/var/www/html/do_not_run_brewpi'): if os.path.isfile(path): try: os.remove(path) - if not platform.startswith('win'): # Daemon not available - print("\nBrewPi script will restart automatically.") + # Daemon not available under Windows + if not platform.startswith('win'): + logMessage("BrewPi script will restart automatically.") return None return True + except IOError as e: + logError("I/O error({0}) while setting deleting:".format(e.errno)) + logError("{0}:".format(path)) + logError("{0}.".format(e.strerror)) + logError("You are not running as root or brewpi, or your") + logError("permissions are not set correctly. To fix this, run:") + logError("sudo {0}utils/doPerms.sh".format(scriptPath())) except: - print("\nUnable to remove {0}.".format(path)) + logError("Unable to remove {0}.".format(path)) return False else: - print("\n{0} does not exist.".format(path)) + logMessage("{0} does not exist.".format(path)) return None -def createDontRunFile(path='/var/www/html/do_not_run_brewpi'): +def createDontRunFile(path = None): + """ + Creates the semaphore file which prevents script processing + + Returns: + True: File created + False: Unable to create file + None: File already exists + """ + if not path: + config = readCfgWithDefaults() + path = "{0}do_not_run_brewpi".format(config['wwwPath']) + if not os.path.isfile(path): try: - with open(path, 'w'): - os.utime(path, None) - # Set owner and permissions for file + open(path, 'a').close() + # chmod 660 the new file - make sure www-data can delete it fileMode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP # 660 owner = 'brewpi' group = 'www-data' @@ -172,14 +352,21 @@ def createDontRunFile(path='/var/www/html/do_not_run_brewpi'): gid = grp.getgrnam(group).gr_gid os.chown(path, uid, gid) # chown file os.chmod(path, fileMode) # chmod file - # File creation successful + logMessage("Semaphore {0} created.".format(path)) return True + except IOError as e: + logError("I/O error({0}) while setting creating:".format(e.errno)) + logError("{0}:".format(path)) + logError("{0}.".format(e.strerror)) + logError("You are not running as root or brewpi, or your") + logError("permissions are not set correctly. To fix this, run:") + logError("sudo {0}utils/doPerms.sh".format(scriptPath())) except: # File creation failure - print("\nUnable to create {0}.".format(path)) + logError("Unable to create {0}.".format(path)) return False else: - # print("\nFile already exists at {0}.".format(path)) + logMessage("Semaphore {0} exists.".format(path)) return None @@ -366,3 +553,64 @@ def writelines(self, datas): self.stream.flush() def __getattr__(self, attr): return getattr(self.stream, attr) + + +def main(): + # Test the methods and classes + # + from pprint import pprint as pp + path = "/home/brewpi" + obj = ConfigObj() + # + # addSlash() + print("Testing addslash(): Passing '{0}' returning '{1}'".format(path, addSlash(path))) + # readCfgWithDefaults() + obj = readCfgWithDefaults() + print("Testing readCfgWithDefaults():") + pp(obj) + # configSet() + oldObj = readCfgWithDefaults() + oldValue = oldObj['altport'] + newObj = configSet('altport', 'Foo') + newValue = newObj['altport'] + discard = configSet('altport', oldObj['altport']) + print("Testing configSet():\n\tOld altport = {0}\n\tNew altport = {1}\n\tReturned to original value after test.".format(oldValue, newValue)) + # scriptPath() + print("Testing: scriptPath() = {0}".format(scriptPath())) + # frozen() + print("Testing: frozen() = {0}".format(frozen())) + # printStdErr() + printStdErr("Testing printStdErr().") + # printStdErr() + printStdOut("Testing printStdOut().") + # Test Date/time stamp messages: + resetenv = False + # Using timestamps - Leave it like we found it + if not 'USE_TIMESTAMP_LOG' in os.environ: + resetenv = True + os.environ['USE_TIMESTAMP_LOG'] = 'True' + # logMessage() + logMessage("Testing logMessage().") + # logError() + logWarn("Testing logWarn().") + # logError() + logError("Testing logError().") + if resetenv: + del os.environ['USE_TIMESTAMP_LOG'] + # do_not_run_brewpi - Leave it like we found it + print("Testing do_not_run_brewpi:") + if not os.path.isfile(obj['wwwPath']): + createDontRunFile() + removeDontRunFile() + else: + removeDontRunFile() + createDontRunFile() + # def findSerialPort(bootLoader, my_port=None): + # def setupSerial(config, baud_rate=57600, time_out=1.0, wtime_out=1.0, noLog=False): + # def stopThisChamber(scriptPath = '/home/brewpi/', wwwPath = '/var/www/html/'): + # def asciiToUnicode(s): + +if __name__ == "__main__": + # Execute tests if run as a script + main() + sys.exit(0) # Exit script From af30459a79f0249a38fe975ddaa0106a615eb204 Mon Sep 17 00:00:00 2001 From: lbussy Date: Sun, 16 Aug 2020 14:50:30 -0500 Subject: [PATCH 03/22] Config timestamps, support refactor --- brewpi.py | 194 +++++++++++++++++++++++++--------------------- utils/doBrewPi.sh | 4 +- 2 files changed, 108 insertions(+), 90 deletions(-) diff --git a/brewpi.py b/brewpi.py index 97d1e9d..0ddf9c4 100755 --- a/brewpi.py +++ b/brewpi.py @@ -60,9 +60,7 @@ import temperatureProfile import programController as programmer import brewpiJson -from BrewPiUtil import Unbuffered -from BrewPiUtil import logMessage -from BrewPiUtil import logError +from BrewPiUtil import Unbuffered, logMessage, logError, addSlash, readCfgWithDefaults import BrewPiUtil as util import brewpiVersion import pinList @@ -117,7 +115,6 @@ version = "0.0.0" branch = "unknown" commit = "unknown" -localPath = os.path.dirname(os.path.realpath(__file__)) configFile = None config = None dontRunFilePath = None @@ -180,12 +177,11 @@ def getGit(): # version = os.popen('git describe --tags $(git rev-list --tags --max-count=1)').read().strip() # branch = os.popen('git branch | grep \* | cut -d " " -f2').read().strip() # commit = os.popen('git -C . log --oneline -n1').read().strip() - global localPath global version global branch global commit - repo = git.Repo(localPath) - version = next((tag for tag in repo.tags if tag.commit == repo.head.commit), None) + repo = git.Repo(util.scriptPath()) + version = (next((tag for tag in reversed(repo.tags)), None)) branch = repo.active_branch.name commit = str(repo.head.commit)[0:7] @@ -209,6 +205,8 @@ def options(): # Parse command line options "-f", "--force", help="quit/kill others and keep this one", action='store_true') parser.add_argument( "-l", "--log", help="redirect output to log files", action='store_true') + parser.add_argument( + "-t", "--datetime", help="prepend log entries with date/time stamp", action='store_true') parser.add_argument( "-d", "--donotrun", help="check for do not run semaphore", action='store_true') parser.add_argument( @@ -267,25 +265,26 @@ def options(): # Parse command line options file=sys.stderr) sys.exit(0) - # Redirect output of stderr and stdout to files in log directory - if args.log: - logToFiles = True + # Redirect output of stderr and stdout to files in log directory + if args.log: + logToFiles = True + + # Redirect output of stderr and stdout to files in log directory + if args.datetime: + os.environ['USE_TIMESTAMP_LOG'] = 'True' - # Only start brewpi when the dontrunfile is not found - if args.donotrun: - checkDontRunFile = True + # Only start brewpi when the dontrunfile is not found + if args.donotrun: + checkDontRunFile = True - # Exit after startup checks - if args.check: - checkStartupOnly = True + # Exit after startup checks + if args.check: + checkStartupOnly = True def config(): # Load config file global configFile global config - global localPath - if not configFile: - configFile = '{0}settings/config.cfg'.format(util.addSlash(localPath)) config = util.readCfgWithDefaults(configFile) @@ -326,7 +325,7 @@ def setUpLog(): # Set up log files global logToFiles global logPath if logToFiles: - logPath = '{0}logs/'.format(util.addSlash(util.scriptPath())) + logPath = '{0}logs/'.format(util.scriptPath()) # Skip logging for this message print("Logging to {0}.".format(logPath)) print("Output will not be shown in console.") @@ -418,7 +417,7 @@ def setFiles(): # Concatenate directory names for the data beerFileName = config['beerName'] dataPath = '{0}data/{1}/'.format( - util.addSlash(util.scriptPath()), beerFileName) + util.scriptPath(), beerFileName) wwwDataPath = '{0}data/{1}/'.format( util.addSlash(config['wwwPath']), beerFileName) @@ -503,8 +502,8 @@ def startBeer(beerName): def startNewBrew(newName): global config if len(newName) > 1: - config = util.configSet(configFile, 'beerName', newName) - config = util.configSet(configFile, 'dataLogging', 'active') + config = util.configSet('beerName', newName, configFile) + config = util.configSet('dataLogging', 'active', configFile) startBeer(newName) logMessage("Restarted logging for beer '%s'." % newName) return {'status': 0, 'statusMessage': "Successfully switched to new brew '%s'. " % urllib.parse.unquote(newName) + @@ -517,8 +516,8 @@ def startNewBrew(newName): def stopLogging(): global config logMessage("Stopped data logging temp control continues.") - config = util.configSet(configFile, 'beerName', None) - config = util.configSet(configFile, 'dataLogging', 'stopped') + config = util.configSet('beerName', None, configFile) + config = util.configSet('dataLogging', 'stopped', configFile) changeWwwSetting('beerName', None) return {'status': 0, 'statusMessage': "Successfully stopped logging."} @@ -527,7 +526,7 @@ def pauseLogging(): global config logMessage("Paused logging data, temp control continues.") if config['dataLogging'] == 'active': - config = util.configSet(configFile, 'dataLogging', 'paused') + config = util.configSet('dataLogging', 'paused', configFile) return {'status': 0, 'statusMessage': "Successfully paused logging."} else: return {'status': 1, 'statusMessage': "Logging already paused or stopped."} @@ -537,7 +536,7 @@ def resumeLogging(): global config logMessage("Continued logging data.") if config['dataLogging'] == 'paused': - config = util.configSet(configFile, 'dataLogging', 'active') + config = util.configSet('dataLogging', 'active', configFile) return {'status': 0, 'statusMessage': "Successfully continued logging."} else: return {'status': 1, 'statusMessage': "Logging was not paused."} @@ -645,7 +644,7 @@ def setSocket(): # Create a listening socket to communicate with PHP (config.get('socketHost', 'localhost'), int(socketPort))) logMessage('Bound to TCP socket on port %d ' % int(socketPort)) else: - socketFile = util.addSlash(util.scriptPath()) + 'BEERSOCKET' + socketFile = util.scriptPath() + 'BEERSOCKET' if os.path.exists(socketFile): # If socket already exists, remove it os.remove(socketFile) @@ -701,60 +700,81 @@ def startSerial(): # Start controller global hwVersion global compatibleHwVersion - # Bytes are read from nonblocking serial into this buffer and processed when - # the buffer contains a full line. - serialConn = util.setupSerial(config) - if not serialConn: - sys.exit(1) - else: - # Wait for 10 seconds to allow an Uno to reboot - logMessage("Waiting 10 seconds for board to restart.") - time.sleep(float(config.get('startupDelay', 10))) - - logMessage("Checking software version on controller.") - hwVersion = brewpiVersion.getVersionFromSerial(serialConn) - if hwVersion is None: - logMessage("ERROR: Cannot receive version number from controller.") - logMessage("Your controller is either not programmed or running a") - logMessage("very old version of BrewPi. Please upload a new version") - logMessage("of BrewPi to your controller.") - # Script will continue so you can at least program the controller - lcdText = ['Could not receive', 'ver from controller', - 'Please (re)program', 'your controller.'] - else: - logMessage("Found " + hwVersion.toExtendedString() + - " on port " + serialConn.name + ".") - if LooseVersion(hwVersion.toString()) < LooseVersion(compatibleHwVersion): - logMessage("Warning: Minimum BrewPi version compatible with this") - logMessage("script is {0} but version number received is".format( - compatibleHwVersion)) - logMessage("{0}.".format(hwVersion.toString())) - if int(hwVersion.log) != int(expandLogMessage.getVersion()): - logMessage("Warning: version number of local copy of logMessages.h") - logMessage("does not match log version number received from") - logMessage( - "controller. Controller version = {0}, local copy".format(hwVersion.log)) - logMessage("version = {0}.".format( - str(expandLogMessage.getVersion()))) + try: + # Bytes are read from nonblocking serial into this buffer and processed when + # the buffer contains a full line. + serialConn = util.setupSerial(config) + if not serialConn: + sys.exit(1) + else: + # Wait for 10 seconds to allow an Uno to reboot + logMessage("Waiting 10 seconds for board to restart.") + time.sleep(float(config.get('startupDelay', 10))) + + logMessage("Checking software version on controller.") + hwVersion = brewpiVersion.getVersionFromSerial(serialConn) + if hwVersion is None: + logMessage("ERROR: Cannot receive version number from controller.") + logMessage("Your controller is either not programmed or running a") + logMessage("very old version of BrewPi. Please upload a new version") + logMessage("of BrewPi to your controller.") + # Script will continue so you can at least program the controller + lcdText = ['Could not receive', 'ver from controller', + 'Please (re)program', 'your controller.'] + else: + logMessage("Found " + hwVersion.toExtendedString() + + " on port " + serialConn.name + ".") + if LooseVersion(hwVersion.toString()) < LooseVersion(compatibleHwVersion): + logMessage("Warning: Minimum BrewPi version compatible with this") + logMessage("script is {0} but version number received is".format( + compatibleHwVersion)) + logMessage("{0}.".format(hwVersion.toString())) + if int(hwVersion.log) != int(expandLogMessage.getVersion()): + logMessage("Warning: version number of local copy of logMessages.h") + logMessage("does not match log version number received from") + logMessage( + "controller. Controller version = {0}, local copy".format(hwVersion.log)) + logMessage("version = {0}.".format( + str(expandLogMessage.getVersion()))) + + if serialConn is not None: + serialConn.flush() + # Set up background serial processing, which will continuously read data + # from serial and put whole lines in a queue + bgSerialConn = BackGroundSerial(serialConn) + bgSerialConn.start() + # Request settings from controller, processed later when reply is received + bgSerialConn.write('s') # request control settings cs + bgSerialConn.write('c') # request control constants cc + bgSerialConn.write('v') # request control variables cv + # Answer from controller is received asynchronously later. + + # Keep track of time between new data requests + prevDataTime = 0 + prevTimeOut = time.time() + prevLcdUpdate = time.time() + prevSettingsUpdate = time.time() + startBeer(config['beerName']) # Set up files and prep for run - if serialConn is not None: - serialConn.flush() - # Set up background serial processing, which will continuously read data - # from serial and put whole lines in a queue - bgSerialConn = BackGroundSerial(serialConn) - bgSerialConn.start() - # Request settings from controller, processed later when reply is received - bgSerialConn.write('s') # request control settings cs - bgSerialConn.write('c') # request control constants cc - bgSerialConn.write('v') # request control variables cv - # Answer from controller is received asynchronously later. - - # Keep track of time between new data requests - prevDataTime = 0 - prevTimeOut = time.time() - prevLcdUpdate = time.time() - prevSettingsUpdate = time.time() - startBeer(config['beerName']) # Set up files and prep for run + except KeyboardInterrupt: + print() # Simply a visual hack if we are running via command line + logMessage("Detected keyboard interrupt, exiting.") + shutdown() + sys.exit(0) + + except Exception as e: + type, value, traceback = sys.exc_info() + fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] + logError("Caught an unexpected exception.") + logError("Error info:") + logError("\tError: ({0}): '{1}'".format( + getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) + logError("\tType: {0}".format(type)) + logError("\tFilename: {0}".format(fname)) + logError("\tLineNo: {0}".format(traceback.tb_lineno)) + logMessage("Caught an unexpected exception, exiting.") + shutdown() + sys.exit(1) def loop(): # Main program loop @@ -839,8 +859,7 @@ def loop(): # Main program loop phpConn.send(json.dumps(cc).encode('utf-8')) elif messageType == "getControlSettings": # Echo control settings if cs['mode'] == "p": - profileFile = util.addSlash( - util.scriptPath()) + 'settings/tempProfile.csv' + profileFile = util.scriptPath() + 'settings/tempProfile.csv' with open(profileFile, 'r') as prof: cs['profile'] = prof.readline().split( ",")[-1].rstrip("\n") @@ -956,7 +975,7 @@ def loop(): # Main program loop if 5 < newInterval < 5000: try: config = util.configSet( - configFile, 'interval', float(newInterval)) + 'interval', float(newInterval), configFile) except ValueError: logMessage( "Cannot convert interval '{0}' to float.".format(value)) @@ -978,19 +997,18 @@ def loop(): # Main program loop phpConn.send(json.dumps(result).encode('utf-8')) elif messageType == "dateTimeFormatDisplay": # Change date time format config = util.configSet( - configFile, 'dateTimeFormatDisplay', value) + 'dateTimeFormatDisplay', value, configFile) changeWwwSetting('dateTimeFormatDisplay', value) logMessage("Changing date format config setting: " + value) elif messageType == "setActiveProfile": # Get and process beer profile # Copy the profile CSV file to the working directory logMessage( "Setting profile '%s' as active profile." % value) - config = util.configSet(configFile, 'profileName', value) + config = util.configSet('profileName', value, configFile) changeWwwSetting('profileName', value) profileSrcFile = util.addSlash( config['wwwPath']) + "data/profiles/" + value + ".csv" - profileDestFile = util.addSlash( - util.scriptPath()) + 'settings/tempProfile.csv' + profileDestFile = util.scriptPath() + 'settings/tempProfile.csv' profileDestFileOld = profileDestFile + '.old' try: if os.path.isfile(profileDestFile): diff --git a/utils/doBrewPi.sh b/utils/doBrewPi.sh index ec30cc0..73a3c8b 100755 --- a/utils/doBrewPi.sh +++ b/utils/doBrewPi.sh @@ -1,4 +1,4 @@ - #!/bin/bash +#!/bin/bash # Copyright (C) 2018, 2019 Lee C. Bussy (@LBussy) @@ -49,7 +49,7 @@ loop() { while : do if (python3 -u "$script" --checkstartuponly --dontrunfile); then - python3 -u "$script" --log + USE_TIMESTAMP_LOG python3 -u "$script" --log --datetime else sleep 1 fi From ed62078e4a5cae8cd5ef5842ed4cc669a95b3ff7 Mon Sep 17 00:00:00 2001 From: lbussy Date: Sun, 16 Aug 2020 18:19:55 -0500 Subject: [PATCH 04/22] Fix path and logging --- BrewPiUtil.py | 5 +++++ brewpi.py | 26 ++++++++++++++++++-------- utils/doBrewPi.sh | 4 ++-- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/BrewPiUtil.py b/BrewPiUtil.py index 6db248f..7380511 100755 --- a/BrewPiUtil.py +++ b/BrewPiUtil.py @@ -39,6 +39,7 @@ import stat import pwd import grp +import git from psutil import process_iter as ps from time import sleep, strftime from configobj import ConfigObj, ParseError @@ -232,6 +233,10 @@ def scriptPath(): except: retVal = os.getcwd() + # Git root is our home path + git_repo = git.Repo(retVal, search_parent_directories=True) + retVal = git_repo.git.rev_parse("--show-toplevel") + return addSlash(retVal) diff --git a/brewpi.py b/brewpi.py index 0ddf9c4..97f26f4 100755 --- a/brewpi.py +++ b/brewpi.py @@ -189,6 +189,8 @@ def getGit(): def options(): # Parse command line options global version global configFile + global checkStartupOnly + global logToFiles parser = argparse.ArgumentParser( description="Main BrewPi script which communicates with the controller(s)") @@ -476,7 +478,7 @@ def setFiles(): localJsonFileName = '{0}{1}.json'.format(dataPath, jsonFileName) - # Handle if we are runing Tilt or iSpindel + # Handle if we are running Tilt or iSpindel if checkKey(config, 'tiltColor'): brewpiJson.newEmptyFile(localJsonFileName, config['tiltColor'], None) elif checkKey(config, 'iSpindel'): @@ -652,13 +654,21 @@ def setSocket(): # Create a listening socket to communicate with PHP phpSocket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) phpSocket.bind(socketFile) # Bind BEERSOCKET # Set owner and permissions for socket - fileMode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP # 660 - owner = 'brewpi' - group = 'www-data' - uid = pwd.getpwnam(owner).pw_uid - gid = grp.getgrnam(group).gr_gid - os.chown(socketFile, uid, gid) # chown socket - os.chmod(socketFile, fileMode) # chmod socket + try: + fileMode = stat.S_IRUSR | stat.S_IWUSR | stat.S_IRGRP | stat.S_IWGRP # 660 + owner = 'brewpi' + group = 'www-data' + uid = pwd.getpwnam(owner).pw_uid + gid = grp.getgrnam(group).gr_gid + os.chown(socketFile, uid, gid) # chown socket + os.chmod(socketFile, fileMode) # chmod socket + except IOError as e: + logError("Error({0}) while setting permissions on:".format(e.errno)) + logError("{0}:".format(socketFile)) + logError("{0}.".format(e.strerror)) + logError("You are not running as root or brewpi, or your") + logError("permissions are not set correctly. To fix this, run:") + logError("sudo {0}utils/doPerms.sh".format(util.scriptPath())) # Set socket behavior phpSocket.setblocking(1) # Set socket functions to be blocking phpSocket.listen(10) # Create a backlog queue for up to 10 connections diff --git a/utils/doBrewPi.sh b/utils/doBrewPi.sh index 73a3c8b..ab00896 100755 --- a/utils/doBrewPi.sh +++ b/utils/doBrewPi.sh @@ -48,8 +48,8 @@ loop() { while : do - if (python3 -u "$script" --checkstartuponly --dontrunfile); then - USE_TIMESTAMP_LOG python3 -u "$script" --log --datetime + if (python3 -u "$script" --check --donotrun); then + USE_TIMESTAMP_LOG=true python3 -u "$script" --log --datetime else sleep 1 fi From 5ff5cb4db6ee7ba80cfb8de4a9476ced3d1b3a56 Mon Sep 17 00:00:00 2001 From: lbussy Date: Sat, 3 Oct 2020 18:04:12 -0500 Subject: [PATCH 05/22] Escape json modes --- brewpi.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/brewpi.py b/brewpi.py index 97f26f4..97438d0 100755 --- a/brewpi.py +++ b/brewpi.py @@ -904,7 +904,7 @@ def loop(): # Main program loop # Round to 2 dec, python will otherwise produce 6.999999999 cs['beerSet'] = round(newTemp, 2) bgSerialConn.write( - "j{mode:b, beerSet:" + json.dumps(cs['beerSet']) + "}") + "j{mode:\"b\", beerSet:" + json.dumps(cs['beerSet']) + "}") logMessage("Beer temperature set to {0} degrees by web.".format( str(cs['beerSet']))) raise socket.timeout # Go to serial communication to update controller @@ -924,7 +924,7 @@ def loop(): # Main program loop if cc['tempSetMin'] <= newTemp <= cc['tempSetMax']: cs['mode'] = 'f' cs['fridgeSet'] = round(newTemp, 2) - bgSerialConn.write("j{mode:f, fridgeSet:" + + bgSerialConn.write("j{mode:\"f\", fridgeSet:" + json.dumps(cs['fridgeSet']) + "}") logMessage("Fridge temperature set to {0} degrees by web.".format( str(cs['fridgeSet']))) @@ -937,7 +937,7 @@ def loop(): # Main program loop logMessage("advanced settings.") elif messageType == "setOff": # Control mode set to OFF cs['mode'] = 'o' - bgSerialConn.write("j{mode:o}") + bgSerialConn.write("j{mode:\"o\"}") logMessage("Temperature control disabled.") raise socket.timeout elif messageType == "setParameters": @@ -1043,7 +1043,7 @@ def loop(): # Main program loop "Profile successfully updated.".encode('utf-8')) if cs['mode'] is not 'p': cs['mode'] = 'p' - bgSerialConn.write("j{mode:p}") + bgSerialConn.write("j{mode:\"p\"}") logMessage("Profile mode enabled.") raise socket.timeout # Go to serial communication to update controller elif messageType == "programController" or messageType == "programArduino": # Reprogram controller @@ -1815,3 +1815,4 @@ def main(): # execute only if run as a script main() sys.exit(0) # Exit script + From 4cb0b1dc2d903f26bbf6167ef045f67398aa0592 Mon Sep 17 00:00:00 2001 From: lbussy Date: Fri, 9 Oct 2020 17:10:10 -0500 Subject: [PATCH 06/22] Add ./venv/ --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 83dc9e1..40e868c 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,7 @@ requirements.txt !.gitignore .idea/ utils/downloads/ +venv/ # Compiled Python files ======= From 45ade4f224ce1defdb39586b514500b7a7e32896 Mon Sep 17 00:00:00 2001 From: lbussy Date: Fri, 9 Oct 2020 17:11:42 -0500 Subject: [PATCH 07/22] mend --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 40e868c..5f21024 100644 --- a/.gitignore +++ b/.gitignore @@ -14,9 +14,9 @@ requirements.txt .idea/ utils/downloads/ venv/ +.gitconfig # Compiled Python files -======= *.py[co] __pycache__ From cd0b050043aaf7278b1cf32347b7cd0bb3a58a47 Mon Sep 17 00:00:00 2001 From: lbussy Date: Fri, 9 Oct 2020 17:17:17 -0500 Subject: [PATCH 08/22] Re-write Tilt.py --- Tilt.py | 711 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 446 insertions(+), 265 deletions(-) diff --git a/Tilt.py b/Tilt.py index e1c49d9..ed4fd1c 100755 --- a/Tilt.py +++ b/Tilt.py @@ -1,37 +1,243 @@ #!/usr/bin/python3 -# Copyright (C) 2019 Lee C. Bussy (@LBussy) - -# This file is part of LBussy's BrewPi Script Remix (BrewPi-Script-RMX). -# -# BrewPi Script RMX is free software: you can redistribute it and/or -# modify it under the terms of the GNU General Public License as -# published by the Free Software Foundation, either version 3 of the -# License, or (at your option) any later version. -# -# BrewPi Script RMX is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -# General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with BrewPi Tilt RMX. If not, see . - -from sys import exit +import sys +from os.path import dirname, abspath, exists, isfile, getmtime +from csv import reader +from time import time, sleep +from configparser import ConfigParser +import subprocess import asyncio -import aioblescan as aios +import argparse +import re import datetime -from time import time, sleep -from os.path import dirname, abspath, isfile, getmtime, exists import threading -import numpy -from csv import reader -from configparser import ConfigParser +import aioblescan as aiobs from struct import unpack import json +import numpy + +import sentry_sdk +sentry_sdk.init("https://5644cfdc9bd24dfbaadea6bc867a8f5b@sentry.io/1803681") + +# Tilt format based on iBeacon format and filter includes Apple iBeacon +# identifier portion (4c000215) as well as Tilt specific uuid preamble +# (a495) +TILT = '4c000215a495' +TILT_COLORS = [ + 'Red', 'Green', 'Black', 'Purple', 'Orange', 'Blue', 'Yellow', 'Pink' +] +opts = None + + +class TiltManager: + """ + Manages the monitoring of all Tilts and storing the read values + """ + threads = [] + + def __init__(self, color=None, averagingPeriod=0, medianWindow=0, dev_id=0): + """ + Initializes TiltManager class with default values + + :param color: Tilt color to be managed + :param averagingPeriod: Time period in seconds for noise smoothing + :param medianWindow: Median filter setting in number of entries + :param dev_id: Device ID of the local Bluetooth device to use + """ + self.tilt = None + self.color = color + self.dev_id = dev_id + self.averagingPeriod = averagingPeriod + self.medianWindow = medianWindow + if color == None: + self.tilt = [None] * len(TILT_COLORS) + for i in range(len(TILT_COLORS)): + self.tilt[i] = Tilt(TILT_COLORS[i], averagingPeriod, medianWindow) + else: + self.tilt = Tilt(color, averagingPeriod, medianWindow) + self.conn = None + self.btctrl = None + self.event_loop = None + self.mysocket = None + self.fac = None + + def loadSettings(self): + """ + Load Settings from config file + + Overrides values given at creation. This needs to be called before + the start function is called. + + :return: None + """ + + myDir = dirname(abspath(__file__)) + filename = '{0}/settings/tiltsettings.ini'.format(myDir) + + if exists(filename) and isfile(filename): + try: + config = ConfigParser() + config.read(filename) + + # BT Device ID + try: + self.dev_id = config.getint("Manager", "DeviceID") + except: + pass + + # Time period for noise smoothing + try: + self.averagingPeriod = config.getint( + "Manager", "AvgWindow") + except: + pass + + # Median filter setting + try: + self.medianWindow = config.getint("Manager", "MedWindow") + except: + pass + + except Exception as e: + print('WARNING: Config file cannot be read: ({0}): {1}'.format( + filename, e.message)) + + else: + print('INFO: Config file does not exist: {0}'.format(filename)) + + def tiltName(self, uuid): + """ + Return Tilt color given UUID + + :param uuid: UUID from BLEacon + :return: Tilt color + """ + + return { + 'a495bb10c5b14b44b5121370f02d74de': 'Red', + 'a495bb20c5b14b44b5121370f02d74de': 'Green', + 'a495bb30c5b14b44b5121370f02d74de': 'Black', + 'a495bb40c5b14b44b5121370f02d74de': 'Purple', + 'a495bb50c5b14b44b5121370f02d74de': 'Orange', + 'a495bb60c5b14b44b5121370f02d74de': 'Blue', + 'a495bb70c5b14b44b5121370f02d74de': 'Yellow', + 'a495bb80c5b14b44b5121370f02d74de': 'Pink' + }.get(uuid) + def storeValue(self, color, temperature, gravity, battery): + """ + Store Tilt values + + :param temperature: Temperature value to be stored + :param gravity: Gravity value to be stored + :param battery: Battery age value to be stored + :return: None + """ + if isinstance(self.tilt, list): + for i in range(len(TILT_COLORS)): + if color == TILT_COLORS[i]: + self.tilt[i].setValues( + color, temperature, gravity, battery) + else: + self.tilt.setValues(color, temperature, gravity, battery) + + def getValue(self, color): + """ + Retrieve Tilt value + + :return: Tilt value + """ + if isinstance(self.tilt, list): + for i in range(len(TILT_COLORS)): + if TILT_COLORS[i] == color: + returnValue = self.tilt[i].getValues(color) + else: + returnValue = self.tilt.getValues(color) + return returnValue + + def decode(self, packet): + # Tilt format based on iBeacon format and filter includes Apple iBeacon + # identifier portion (4c000215) as well as Tilt specific uuid preamble (a495) + + global TILT + data = {} + raw_data = packet.retrieve('Payload for mfg_specific_data') + if raw_data: + pckt = raw_data[0].val + payload = raw_data[0].val.hex() + mfg_id = payload[0:12] + rssi = packet.retrieve('rssi') + mac = packet.retrieve("peer") + if mfg_id == TILT: + data['uuid'] = payload[8:40] + data['major'] = unpack('>H', pckt[20:22])[0] + data['minor'] = unpack('>H', pckt[22:24])[0] + data['tx_power'] = unpack('>b', pckt[24:25])[0] + data['rssi'] = rssi[-1].val + data['mac'] = mac[-1].val + return json.dumps(data).encode('utf-8') + + def blecallback(self, data): + """ + Callback method for the Bluetooth process + + :return: None + """ + packet = aiobs.HCI_Event() + packet.decode(data) + response = self.decode(packet) + if response: + tiltdata = json.loads(response.decode('utf-8', 'ignore')) + color = self.tiltName(tiltdata['uuid']) + gravity = int(tiltdata['minor']) / 1000 + temperature = int(tiltdata['major']) + battery = int(tiltdata['tx_power']) + self.storeValue(color, temperature, gravity, battery) + + def start(self): + """ + Starts the BLE scanning thread + + :return: None + """ + + self.event_loop = asyncio.get_event_loop() + # First create and configure a raw socket + self.mysocket = aiobs.create_bt_socket(self.dev_id) -TILT_COLORS = ['Red', 'Green', 'Black', 'Purple', 'Orange', 'Blue', 'Yellow', 'Pink'] + # Create a connection with the STREAM socket + self.fac = self.event_loop._create_connection_transport( + self.mysocket, aiobs.BLEScanRequester, None, None) + # Start it + self.conn, self.btctrl = self.event_loop.run_until_complete(self.fac) + # Attach your processing + self.btctrl.process = self.blecallback + # Probe + self.btctrl.send_scan_request() + + thread = threading.Thread(target=self.event_loop.run_forever) + self.threads.append(thread) + thread.start() + + return + + def stop(self): + """ + Stop the BLE scanning thread + + :return: None + """ + self.btctrl.stop_scan_request() + command = aiobs.HCI_Cmd_LE_Advertise(enable=False) + self.btctrl.send_command(command) + + asyncio.gather(*asyncio.Task.all_tasks()).cancel() + for thread in self.threads: + self.event_loop.call_soon_threadsafe(self.event_loop.stop) + thread.join() + + self.event_loop.close() + return class TiltValue: @@ -63,7 +269,6 @@ class Tilt: Handles calibration, storing of values and smoothing of read values. """ - color = None values = None lock = None averagingPeriod = 0 @@ -79,7 +284,7 @@ def __init__(self, color, averagingPeriod=0, medianWindow=0): self.averagingPeriod = averagingPeriod self.medianWindow = medianWindow self.values = [] - self.calibrate() + self.calibrate(color) self.calibrationDataTime = { 'temperature': 0, 'temperature_checked': 0, @@ -87,20 +292,20 @@ def __init__(self, color, averagingPeriod=0, medianWindow=0): 'gravity_checked': 0 } - def calibrate(self): + def calibrate(self, color): """ Load/reload calibration functions """ # Check for temperature function. If none, then not changed since # last load. - self.tempFunction = self.tiltCal("temperature") + self.tempFunction = self.tiltCal("temperature", color) if self.tempFunction is not None: self.tempCal = self.tempFunction # Check for gravity function. If none, then not changed since last # load. - self.gravityFunction = self.tiltCal("gravity") + self.gravityFunction = self.tiltCal("gravity", color) if self.gravityFunction is not None: self.gravCal = self.gravityFunction @@ -112,7 +317,7 @@ def setValues(self, color, temperature, gravity, battery): enabled """ self.cleanValues() - self.calibrate() + self.calibrate(color) calTemp = self.tempCal(temperature) calGrav = self.gravCal(gravity) # tx_power will be -59 every 5 seconds in order to allow iOS @@ -121,58 +326,59 @@ def setValues(self, color, temperature, gravity, battery): battery = 0 self.values.append(TiltValue(color, calTemp, calGrav, battery)) - def getValues(self): + def getValues(self, color): """ - Returns the temperature, gravity & battery values of the Tilt + Returns the temperature, gravity & battery values of a given Tilt This will be the latest read value unless averaging / median has been enabled """ + colorValues = [] returnValue = None - if len(self.values) > 0: - if self.medianWindow == 0: - returnValue = self.averageValues() - else: - returnValue = self.medianValues(self.medianWindow) + if len(self.values) > 0: + for i in range(len(self.values)): + if self.values[i].color == color: + temperature = self.values[i].temperature + gravity = self.values[i].gravity + battery = self.values[i].battery + colorValues.append( + TiltValue(color, temperature, gravity, battery)) + if len(colorValues) > 0: + if self.medianWindow == 0: + returnValue = self.averageValues(color, colorValues) + else: + returnValue = self.medianValues(color, colorValues) self.cleanValues() return returnValue - def averageValues(self): + def averageValues(self, color, values): """ Average all the stored values in the Tilt class (except battery) :return: Averaged values """ - returnValue = None - if len(self.values) > 0: + if len(values) > 0: returnValue = TiltValue('', 0, 0, 0) - for value in self.values: + for value in values: returnValue.temperature += value.temperature returnValue.gravity += value.gravity # Average values - returnValue.temperature /= len(self.values) - returnValue.gravity /= len(self.values) + returnValue.temperature /= len(values) + returnValue.gravity /= len(values) # Round values returnValue.temperature = returnValue.temperature returnValue.gravity = returnValue.gravity # Make sure battery returns only real values (> 0) - returnValue.battery = self.getBatteryValue() + returnValue.battery = getBatteryValue(values, color) return returnValue - def getBatteryValue(self): - batteryValues = [] - for i in range(len(self.values)): - values = self.values[i] - batteryValues.append(values.battery) - return max(batteryValues) - - def medianValues(self, window=3): + def medianValues(self, color, values, window=3): """ Use a median method across the stored values to reduce noise @@ -183,21 +389,20 @@ def medianValues(self, window=3): averaged :return: Median value """ - # Ensure there are enough values to do a median filter, if not shrink # window temporarily - if len(self.values) < window: - window = len(self.values) + if len(values) < window: + window = len(values) returnValue = TiltValue('', 0, 0, 0) # sidebars = (window - 1) / 2 medianValueCount = 0 - for i in range(len(self.values) - (window - 1)): + for i in range(len(values) - (window - 1)): # Work out range of values to do median. At start and end of # assessment, need to pad with start and end values. - medianValues = self.values[i:i + window] + medianValues = values[i:i + window] medianValuesTemp = [] medianValuesGravity = [] @@ -208,8 +413,10 @@ def medianValues(self, window=3): medianValuesGravity.append(medianValue.gravity) # Add the median value to the running total. - returnValue.temperature += numpy.median(numpy.array(medianValuesTemp)) - returnValue.gravity += numpy.median(numpy.array(medianValuesGravity)) + returnValue.temperature += numpy.median( + numpy.array(medianValuesTemp)) + returnValue.gravity += numpy.median( + numpy.array(medianValuesGravity)) # Increase count medianValueCount += 1 @@ -223,17 +430,26 @@ def medianValues(self, window=3): returnValue.gravity = returnValue.gravity # Now just get the max of battery to filter out 0's - returnValue.battery = self.getBatteryValue() + returnValue.battery = self.getBatteryValue(values, color) return returnValue + def getBatteryValue(self, values, color): + batteryValues = [] + if len(values) > 0: + for i in range(len(values)): + if values[i].color == color: + batteryValues.append(values[i].battery) + return max(batteryValues) + else: + return 0 + def cleanValues(self): """ Clean out stale values that are beyond the desired window :return: None, operates on values in class """ - nowTime = datetime.datetime.now() for value in self.values: @@ -244,14 +460,13 @@ def cleanValues(self): # this condition we can stop searching. break - def tiltCal(self, which): + def tiltCal(self, which, color): """ Loads settings from file and create the calibration functions :param which: Which value (gravity or temperature) is to be processed :return: The calibration function to be called """ - # Default time in seconds to wait before checking config files to see if # calibration data has changed. DATA_REFRESH_WINDOW = 60 @@ -261,7 +476,8 @@ def tiltCal(self, which): csvFile = None path = dirname(abspath(__file__)) configDir = '{0}/settings/'.format(path) - filename = '{0}{1}.{2}'.format(configDir, which.upper(), self.color.lower()) + filename = '{0}{1}.{2}'.format( + configDir, which.upper(), color.lower()) lastChecked = self.calibrationDataTime.get(which + "_checked", 0) if (int(time()) - lastChecked) < DATA_REFRESH_WINDOW: @@ -290,10 +506,10 @@ def tiltCal(self, which): csvFile.close() except IOError: print('Tilt ({0}): {1}: No calibration data ({2})'.format( - self.color, which.capitalize(), filename)) + color, which.capitalize(), filename)) except Exception as e: print('ERROR: Tilt ({0}): Unable to initialise {1} calibration data ({2}) - {3}'.format( - self.color, which.capitalize(), filename, e.message)) + color, which.capitalize(), filename, e)) # Attempt to close the file if csvFile is not None: # Close file @@ -301,209 +517,145 @@ def tiltCal(self, which): # If more than one values, use Polyfill if len(actualValues) >= 1: - poly = numpy.poly1d(numpy.polyfit(originalValues, actualValues, deg=min(3, len(x)-1))) - returnFunction = lambda x: poly(x) - print('Tilt ({0}): Initialized {1} Calibration: Polyfill'.format(self.color, which.capitalize())) + poly = numpy.poly1d(numpy.polyfit( + originalValues, actualValues, deg=min(3, len(x)-1))) + + def returnFunction(x): return poly(x, color) + print('Tilt ({0}): Initialized {1} Calibration: Polyfill'.format( + color, which.capitalize())) else: - returnFunction = lambda x: x + def returnFunction(x): return x return returnFunction -class TiltManager: - """ - Manages the monitoring of all Tilts and storing the read values +def check_mac(mac): """ - threads = [] - - def __init__(self, color, averagingPeriod = 0, medianWindow = 0, dev_id = 0): - """ - Initializes TiltManager class with default values - - :param color: Tilt color to be managed - :param averagingPeriod: Time period in seconds for noise smoothing - :param medianWindow: Median filter setting in number of entries - :param dev_id: Device ID of the local Bluetooth device to use - """ - - self.color = color - self.dev_id = dev_id - self.averagingPeriod = averagingPeriod - self.medianWindow = medianWindow - self.tilt = Tilt(color, self.averagingPeriod, self.medianWindow) - self.conn = None - self.btctrl = None - self.event_loop = None - self.mysocket = None - self.fac = None - - def loadSettings(self): - """ - Load Settings from config file - - Overrides values given at creation. This needs to be called before - the start function is called. - - :return: None - """ - - myDir = dirname(abspath(__file__)) - filename = '{0}/settings/tiltsettings.ini'.format(myDir) - - if exists(filename) and isfile(filename): - try: - config = ConfigParser() - config.read(filename) - - # BT Device ID - try: - self.dev_id = config.getint("Manager", "DeviceID") - except: - pass - - # Time period for noise smoothing - try: - self.averagingPeriod = config.getint("Manager", "AvgWindow") - except: - pass - - # Median filter setting - try: - self.medianWindow = config.getint("Manager", "MedWindow") - except: - pass - - except Exception as e: - print('\nWARNING: Config file does not exist or cannot be read: ({0}): {1}\n'.format(filename, e.message)) - - else: - print('\nWARNING: Config file does not exist: {0}\n'.format(filename)) - - def tiltName(self, uuid): - """ - Return Tilt color given UUID - - :param uuid: UUID from BLEacon - :return: Tilt color - """ - - return { - 'a495bb10c5b14b44b5121370f02d74de': 'Red', - 'a495bb20c5b14b44b5121370f02d74de': 'Green', - 'a495bb30c5b14b44b5121370f02d74de': 'Black', - 'a495bb40c5b14b44b5121370f02d74de': 'Purple', - 'a495bb50c5b14b44b5121370f02d74de': 'Orange', - 'a495bb60c5b14b44b5121370f02d74de': 'Blue', - 'a495bb70c5b14b44b5121370f02d74de': 'Yellow', - 'a495bb80c5b14b44b5121370f02d74de': 'Pink' - }.get(uuid) - - def storeValue(self, color, temperature, gravity, battery): - """ - Store Tilt values - - :param temperature: Temperature value to be stored - :param gravity: Gravity value to be stored - :param battery: Battery age value to be stored - :return: None - """ - - self.tilt.setValues(color, temperature, gravity, battery) - - def getValue(self): - """ - Retrieve Tilt value - - :return: Tilt value - """ - - returnValue = self.tilt.getValues() - return returnValue - - def decode(self, packet): - # Tilt format based on iBeacon format and filter includes Apple iBeacon - # identifier portion (4c000215) as well as Tilt specific uuid preamble (a495) - - TILT = '4c000215a495' - - data = {} - - raw_data = packet.retrieve('Payload for mfg_specific_data') - if raw_data: - pckt = raw_data[0].val - payload = raw_data[0].val.hex() - mfg_id = payload[0:12] - rssi = packet.retrieve('rssi') - mac = packet.retrieve("peer") - if mfg_id == TILT: - data['uuid'] = payload[8:40] - data['major'] = unpack('>H', pckt[20:22])[0] # Temperature in degrees F - data['minor'] = unpack('>H', pckt[22:24])[0] # Specific gravity x1000 - # tx_power is weeks since battery change (0-152 when converted - # to unsigned 8 bit integer) and other TBD operation codes - data['tx_power'] = unpack('>b', pckt[24:25])[0] - data['rssi'] = rssi[-1].val - data['mac'] = mac[-1].val - - return json.dumps(data).encode('utf-8') - - def blecallback(self, data): - packet = aios.HCI_Event() - packet.decode(data) - response = self.decode(packet) - if response: - tiltdata = json.loads(response.decode('utf-8', 'ignore')) - color = self.tiltName(tiltdata['uuid']) - gravity = int(tiltdata['minor']) / 1000 # Specific gravity - temperature = int(tiltdata['major'] ) # Temperature in degrees F - battery = int(tiltdata['tx_power']) # Battery age - self.storeValue(color, temperature, gravity, battery) - - def stop(self): - """ - Stop the BLE scanning thread - - :return: None - """ - - self.btctrl.stop_scan_request() - command = aios.HCI_Cmd_LE_Advertise(enable=False) - self.btctrl.send_command(command) - - asyncio.gather(*asyncio.Task.all_tasks()).cancel() - for thread in self.threads: - self.event_loop.call_soon_threadsafe(self.event_loop.stop) - thread.join() - - self.event_loop.close() - return - - def start(self): - """ - Starts the BLE scanning thread + Checks mac from command line arguments - :return: None - """ + :param val: String representation of a valid mac address, e.g.: + e8:ae:6b:42:cc:20 + """ + try: + if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", mac.lower()): + return mac.lower() + except: + pass + raise argparse.ArgumentTypeError("{}} is not a MAC address".format(mac)) + + +def parseArgs(): + parser = argparse.ArgumentParser( + description="Track Tilt BLEacon packets") + parser.add_argument( + "-r", + "--raw", + action='store_true', + default=False, + help="dump raw HCI packet data for Tilts") + parser.add_argument( + "-j", + "--json", + action='store_true', + default=False, + help="display Tilt data in JSON format") + parser.add_argument( + "-m", + "--mac", + type=check_mac, + action='append', + help="filter Tilts by this/these MAC address(es)") + parser.add_argument( + "-d", + "--hci", + type=int, + default=0, + help="select the hci device to use (default 0, i.e. hci0)") + parser.add_argument( + "-c", + "--color", + type=str, + default=None, + help="filter by this Tilt color") + parser.add_argument( + "-a", + "--average", + type=int, + default=None, + help="seconds window for averaging") + parser.add_argument( + "-n", + "--median", + type=int, + default=None, + help="number of entries in median window") + try: + opts = parser.parse_args() + return opts + except Exception as e: + parser.error("Error: " + str(e)) + sys.exit() - self.event_loop = asyncio.get_event_loop() - # First create and configure a raw socket - self.mysocket = aios.create_bt_socket(self.dev_id) - # Create a connection with the STREAM socket - self.fac = self.event_loop._create_connection_transport(self.mysocket, aios.BLEScanRequester, None, None) - # Start it - self.conn, self.btctrl = self.event_loop.run_until_complete(self.fac) - # Attach your processing - self.btctrl.process = self.blecallback - # Probe - self.btctrl.send_scan_request() +def checkSetcap() -> (bool, str, str): + """ + Checks setcap environment - thread = threading.Thread(target=self.event_loop.run_forever) - self.threads.append(thread) - thread.start() + :return bool: Status of setcap environment + :return str: Base executable + :return str: getcap values + """ + try: + base_executable = subprocess.check_output( + ["readlink", "-e", sys.executable]).strip().decode("utf-8") + except FileNotFoundError: + # readlink doesn't exist + return False, "", "" + except subprocess.CalledProcessError: + # readlink failed + return False, "", "" - return + try: + getcap_values = subprocess.check_output( + ["getcap", base_executable]).strip().decode("utf-8") + getcap_values = getcap_values.split(' = ')[1] + except IndexError: + # No capabilities exist + return False, base_executable, "" + except FileNotFoundError: + # getcap doesn't exist on this system (e.g. MacOS) + return False, base_executable, "" + except subprocess.CalledProcessError: + # setcap -v failed + return False, base_executable, "" + + # We have to check for three things: + # 1. That the executable has cap_net_admin + # 2. That the executable has cap_net_raw + # 3. That the executable has +eip (inheritable permissions) + # + # The output of getcap should look like this: + # b'/usr/bin/python3.6 = cap_net_admin,cap_net_raw+eip\n' + # + # I think we only need cap_net_raw+eip + # + # sudo setcap 'CAP_NET_RAW+eip CAP_NET_ADMIN+eip' /usr/bin/python3.7 + + cap_net_admin_missing = True + cap_net_raw_missing = True + cap_eip_unset = True + + # if getcap_values.find("cap_net_admin") != -1: + # cap_net_admin_missing = False + if getcap_values.find("cap_net_raw") != -1: + cap_net_raw_missing = False + if getcap_values.find("+eip") != -1: + cap_eip_unset = False + + if cap_net_raw_missing or cap_eip_unset: + return False, base_executable, getcap_values + return True, base_executable, getcap_values def main(): @@ -512,24 +664,52 @@ def main(): :return: None """ + print("\nTilt BLEacon test.") + global opts + opts = parseArgs() + tiltColorName = None + averaging = 300 + median = 10000 + device_id = 0 + + # Check that Python has the correct capabilities set + hasCaps, pythonPath, getCapValues = checkSetcap() + if not hasCaps: + print("\nERROR: Missing cap flags on python executable.\nExecutable:\t{}\nCap Values:\t{}\n".format( + pythonPath, getCapValues)) + return + + if opts.color: + tiltColor = opts.color.title() + tiltColorName = opts.color.title() + else: + tiltColor = None + tiltColorName = "All" + + if opts.median: + median = opts.median + + if opts.average: + average = opts.average - tiltColor = 'Purple' + if opts.hci: + device_id = opts.hci - #tilt = TiltManager(tiltColor, 300, 10000, 0) - tilt = TiltManager(tiltColor, 300, 10000, 0) + tilt = TiltManager(tiltColor, averaging, median, device_id) tilt.loadSettings() tilt.start() try: - print("\nReporting Tilt values every 5 seconds. Ctrl-C to stop.") + print( + "\nReporting {} Tilt values every 5 seconds. Ctrl-C to stop.".format(tiltColor)) while 1: # If we are running Tilt, get current values if tilt: sleep(5) # Check each of the Tilt colors for color in TILT_COLORS: - if color == tiltColor: - tiltValue = tilt.getValue() + if color == tiltColor or tiltColor == None: + tiltValue = tilt.getValue(color) if tiltValue is not None: temperature = round(tiltValue.temperature, 2) gravity = round(tiltValue.gravity, 3) @@ -537,7 +717,8 @@ def main(): print("{0}: Temp = {1}°F, Gravity = {2}, Battery = {3} weeks old.".format( color, temperature, gravity, battery)) else: - print("\nColor {0} report: No results returned.".format(color)) + print( + "Color {0} report: No results returned.".format(color)) except KeyboardInterrupt: print('\nKeyboard interrupt.') From 4e5a60f0216f33c2a58dda5ed6ccc64dc7cf6c49 Mon Sep 17 00:00:00 2001 From: lbussy Date: Fri, 9 Oct 2020 17:25:28 -0500 Subject: [PATCH 09/22] Add compatibility with new Tilt.py --- brewpi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/brewpi.py b/brewpi.py index 97438d0..c6c6197 100755 --- a/brewpi.py +++ b/brewpi.py @@ -1477,7 +1477,7 @@ def loop(): # Main program loop for color in Tilt.TILT_COLORS: # Only log the Tilt if the color matches the config if color == config["tiltColor"]: - tiltValue = tilt.getValue() + tiltValue = tilt.getValue(color) if tiltValue is not None: _temp = tiltValue.temperature if cc['tempFormat'] == 'C': From a449f358f8f33f1f1b743fd91efdb00c9ff2ea70 Mon Sep 17 00:00:00 2001 From: lbussy Date: Sun, 13 Dec 2020 13:28:42 -0600 Subject: [PATCH 10/22] (re)Fix scriptPath() --- BrewPiUtil.py | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/BrewPiUtil.py b/BrewPiUtil.py index 7380511..6a945c3 100755 --- a/BrewPiUtil.py +++ b/BrewPiUtil.py @@ -223,21 +223,7 @@ def scriptPath(): Returns: Path of module with trailing slash """ - retVal = "" - if frozen(): - retVal = os.path.dirname( - str(sys.executable, sys.getfilesystemencoding())) - try: - retVal = os.path.dirname( - str(os.path.abspath(__file__), sys.getfilesystemencoding())) - except: - retVal = os.getcwd() - - # Git root is our home path - git_repo = git.Repo(retVal, search_parent_directories=True) - retVal = git_repo.git.rev_parse("--show-toplevel") - - return addSlash(retVal) + return addSlash(os.path.dirname(os.path.abspath(__file__))) def frozen(): From bbc2377ee888279e5d24f13a3ca787eef1fc6423 Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 14 Dec 2020 11:57:38 -0600 Subject: [PATCH 11/22] Further Tilt.py cleanup --- Tilt.py | 81 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/Tilt.py b/Tilt.py index ed4fd1c..d12ea81 100755 --- a/Tilt.py +++ b/Tilt.py @@ -1,5 +1,10 @@ #!/usr/bin/python3 +# Requires the following pip packages: +# aioblescan, numpy, libatlas-base-dev +# Requires setcap in order to run without root: +# sudo setcap cap_net_raw+eip $(eval readlink -f `which python3`) + import sys from os.path import dirname, abspath, exists, isfile, getmtime from csv import reader @@ -16,23 +21,26 @@ import json import numpy -import sentry_sdk -sentry_sdk.init("https://5644cfdc9bd24dfbaadea6bc867a8f5b@sentry.io/1803681") +# DEBUG: +# import sentry_sdk +# sentry_sdk.init("https://5644cfdc9bd24dfbaadea6bc867a8f5b@sentry.io/1803681") -# Tilt format based on iBeacon format and filter includes Apple iBeacon -# identifier portion (4c000215) as well as Tilt specific uuid preamble -# (a495) +# The Tilt format is based on the iBeacon format, and the filter value includes +# the Apple iBeacon identifier portion (4c000215) as well as the Tilt specific +# uuid preamble (a495). TILT = '4c000215a495' + +# A list of all possible Tilt colors. TILT_COLORS = [ 'Red', 'Green', 'Black', 'Purple', 'Orange', 'Blue', 'Yellow', 'Pink' ] -opts = None class TiltManager: """ Manages the monitoring of all Tilts and storing the read values """ + threads = [] def __init__(self, color=None, averagingPeriod=0, medianWindow=0, dev_id=0): @@ -44,16 +52,20 @@ def __init__(self, color=None, averagingPeriod=0, medianWindow=0, dev_id=0): :param medianWindow: Median filter setting in number of entries :param dev_id: Device ID of the local Bluetooth device to use """ + self.tilt = None self.color = color self.dev_id = dev_id self.averagingPeriod = averagingPeriod self.medianWindow = medianWindow if color == None: + # Set up an array of Tilt objects, one for each color self.tilt = [None] * len(TILT_COLORS) for i in range(len(TILT_COLORS)): - self.tilt[i] = Tilt(TILT_COLORS[i], averagingPeriod, medianWindow) + self.tilt[i] = Tilt( + TILT_COLORS[i], averagingPeriod, medianWindow) else: + # Set up a single Tilt object self.tilt = Tilt(color, averagingPeriod, medianWindow) self.conn = None self.btctrl = None @@ -133,6 +145,7 @@ def storeValue(self, color, temperature, gravity, battery): :param battery: Battery age value to be stored :return: None """ + if isinstance(self.tilt, list): for i in range(len(TILT_COLORS)): if color == TILT_COLORS[i]: @@ -147,17 +160,28 @@ def getValue(self, color): :return: Tilt value """ + + returnValue = None if isinstance(self.tilt, list): + # If there's an array of Tilt objects, loop through till we have a match for i in range(len(TILT_COLORS)): if TILT_COLORS[i] == color: returnValue = self.tilt[i].getValues(color) else: + # If there's a single Tilt object, return it's value returnValue = self.tilt.getValues(color) return returnValue def decode(self, packet): - # Tilt format based on iBeacon format and filter includes Apple iBeacon - # identifier portion (4c000215) as well as Tilt specific uuid preamble (a495) + """ + Format Tilt values. + Tilt format based on iBeacon format and filter includes Apple iBeacon + identifier portion (4c000215) as well as Tilt specific uuid preamble + (a495). + + :param packet: Raw BLEacon packet + :return: Tilt values encoded as JSON + """ global TILT data = {} @@ -183,6 +207,7 @@ def blecallback(self, data): :return: None """ + packet = aiobs.HCI_Event() packet.decode(data) response = self.decode(packet) @@ -316,6 +341,7 @@ def setValues(self, color, temperature, gravity, battery): These values will be calibrated before storing if calibration is enabled """ + self.cleanValues() self.calibrate(color) calTemp = self.tempCal(temperature) @@ -333,6 +359,7 @@ def getValues(self, color): This will be the latest read value unless averaging / median has been enabled """ + colorValues = [] returnValue = None @@ -358,6 +385,7 @@ def averageValues(self, color, values): :return: Averaged values """ + returnValue = None if len(values) > 0: returnValue = TiltValue('', 0, 0, 0) @@ -374,7 +402,7 @@ def averageValues(self, color, values): returnValue.gravity = returnValue.gravity # Make sure battery returns only real values (> 0) - returnValue.battery = getBatteryValue(values, color) + returnValue.battery = self.getBatteryValue(values, color) return returnValue @@ -389,6 +417,7 @@ def medianValues(self, color, values, window=3): averaged :return: Median value """ + # Ensure there are enough values to do a median filter, if not shrink # window temporarily if len(values) < window: @@ -435,6 +464,13 @@ def medianValues(self, color, values, window=3): return returnValue def getBatteryValue(self, values, color): + """ + Return battery age in weeks for a given color + + :param values: An array of Tilt values + :return: Integer of battery age in weeks, or 0 + """ + batteryValues = [] if len(values) > 0: for i in range(len(values)): @@ -450,6 +486,7 @@ def cleanValues(self): :return: None, operates on values in class """ + nowTime = datetime.datetime.now() for value in self.values: @@ -467,6 +504,7 @@ def tiltCal(self, which, color): :param which: Which value (gravity or temperature) is to be processed :return: The calibration function to be called """ + # Default time in seconds to wait before checking config files to see if # calibration data has changed. DATA_REFRESH_WINDOW = 60 @@ -537,6 +575,7 @@ def check_mac(mac): :param val: String representation of a valid mac address, e.g.: e8:ae:6b:42:cc:20 """ + try: if re.match("[0-9a-f]{2}([-:])[0-9a-f]{2}(\\1[0-9a-f]{2}){4}$", mac.lower()): return mac.lower() @@ -546,6 +585,13 @@ def check_mac(mac): def parseArgs(): + """ + Parse any command line arguments + + :param: None + :return: None + """ + parser = argparse.ArgumentParser( description="Track Tilt BLEacon packets") parser.add_argument( @@ -606,6 +652,7 @@ def checkSetcap() -> (bool, str, str): :return str: Base executable :return str: getcap values """ + try: base_executable = subprocess.check_output( ["readlink", "-e", sys.executable]).strip().decode("utf-8") @@ -664,12 +711,12 @@ def main(): :return: None """ + print("\nTilt BLEacon test.") - global opts opts = parseArgs() tiltColorName = None - averaging = 300 - median = 10000 + averaging = 0 + median = 0 device_id = 0 # Check that Python has the correct capabilities set @@ -684,7 +731,7 @@ def main(): tiltColorName = opts.color.title() else: tiltColor = None - tiltColorName = "All" + tiltColorName = "all" if opts.median: median = opts.median @@ -701,7 +748,7 @@ def main(): try: print( - "\nReporting {} Tilt values every 5 seconds. Ctrl-C to stop.".format(tiltColor)) + "\nReporting {} Tilt values every 5 seconds. Ctrl-C to stop.".format(tiltColorName)) while 1: # If we are running Tilt, get current values if tilt: @@ -714,11 +761,11 @@ def main(): temperature = round(tiltValue.temperature, 2) gravity = round(tiltValue.gravity, 3) battery = tiltValue.battery - print("{0}: Temp = {1}°F, Gravity = {2}, Battery = {3} weeks old.".format( + print("{0}:\tTemp: {1}°F, Gravity: {2}, Battery: {3} weeks old.".format( color, temperature, gravity, battery)) else: print( - "Color {0} report: No results returned.".format(color)) + "{0}:\tNo results returned.".format(color)) except KeyboardInterrupt: print('\nKeyboard interrupt.') From ff38b95d3f34021aadee7adc16ea7e81dcf63c56 Mon Sep 17 00:00:00 2001 From: lbussy Date: Fri, 18 Dec 2020 20:06:27 -0600 Subject: [PATCH 12/22] Rewrite, plus Pro --- Tilt.py | 384 +++++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 309 insertions(+), 75 deletions(-) diff --git a/Tilt.py b/Tilt.py index d12ea81..9ae3d2a 100755 --- a/Tilt.py +++ b/Tilt.py @@ -1,10 +1,18 @@ -#!/usr/bin/python3 +#!/usr/bin/env python3 +# Requires the following apt packages: +# libatlas-base-dev (for numpy) # Requires the following pip packages: -# aioblescan, numpy, libatlas-base-dev -# Requires setcap in order to run without root: +# aioblescan, numpy (for calibrations) +# +# Requires setcap and bluetooth group membership in order to run without root: # sudo setcap cap_net_raw+eip $(eval readlink -f `which python3`) +# TODO: +# I still have no idea why this will not work in a venv +# Fix design version (v1, 2, 3) +# Fix battery value based on version and gattool? + import sys from os.path import dirname, abspath, exists, isfile, getmtime from csv import reader @@ -16,7 +24,7 @@ import re import datetime import threading -import aioblescan as aiobs +import aioblescan from struct import unpack import json import numpy @@ -25,15 +33,9 @@ # import sentry_sdk # sentry_sdk.init("https://5644cfdc9bd24dfbaadea6bc867a8f5b@sentry.io/1803681") -# The Tilt format is based on the iBeacon format, and the filter value includes -# the Apple iBeacon identifier portion (4c000215) as well as the Tilt specific -# uuid preamble (a495). -TILT = '4c000215a495' - # A list of all possible Tilt colors. -TILT_COLORS = [ - 'Red', 'Green', 'Black', 'Purple', 'Orange', 'Blue', 'Yellow', 'Pink' -] +TILT_COLORS = ['Red', 'Green', 'Black', 'Purple', 'Orange', 'Blue', 'Yellow', 'Pink'] +TILT_VERSIONS = ['Unknown', 'v1', 'v2', 'v3', 'Pro', 'v2 or 3'] class TiltManager: @@ -73,6 +75,8 @@ def __init__(self, color=None, averagingPeriod=0, medianWindow=0, dev_id=0): self.mysocket = None self.fac = None + self.tiltError = False + def loadSettings(self): """ Load Settings from config file @@ -136,7 +140,7 @@ def tiltName(self, uuid): 'a495bb80c5b14b44b5121370f02d74de': 'Pink' }.get(uuid) - def storeValue(self, color, temperature, gravity, battery): + def storeValue(self, timestamp, mac, hwVersion, fwVersion, color, temperature, gravity, battery): """ Store Tilt values @@ -149,15 +153,15 @@ def storeValue(self, color, temperature, gravity, battery): if isinstance(self.tilt, list): for i in range(len(TILT_COLORS)): if color == TILT_COLORS[i]: - self.tilt[i].setValues( - color, temperature, gravity, battery) + self.tilt[i].setValues(timestamp, mac, hwVersion, fwVersion, color, temperature, gravity, battery) else: - self.tilt.setValues(color, temperature, gravity, battery) + self.tilt.setValues(timestamp, mac, hwVersion, fwVersion, color, temperature, gravity, battery) def getValue(self, color): """ Retrieve Tilt value + :param color: Color of Tilt to be checked :return: Tilt value """ @@ -183,41 +187,169 @@ def decode(self, packet): :return: Tilt values encoded as JSON """ - global TILT + # The Tilt format is based on the iBeacon format, and the filter value includes + # the Apple iBeacon identifier portion (4c000215) as well as the Tilt specific + # uuid preamble (a495). + TILT = '4c000215a495' + + # The first reference I recall seeing on this format is here: + # https://kvurd.com/blog/tilt-hydrometer-ibeacon-data-format/ + # + # Importantly: + # + # Example tilt hydrometer sensor data message from hcidump -R: + # + # > 04 3E 27 02 01 00 00 5A 09 9B 16 A3 04 1B 1A FF 4C 00 02 15 + # A4 95 BB 10 C5 B1 4B 44 B5 12 13 70 F0 2D 74 DE 00 44 03 F8 + # C5 C7 + # + # Explanation (with help from the bluetooth core spec and stackoverflow [4] [5] [6]): + # + # 04: HCI Packet Type HCI Event + # 3E: LE Meta event + # 27: Parameter total length (39 octets) + # 02: LE Advertising report sub-event + # 01: Number of reports (1) + # 00: Event type connectable and scannable undirected advertising + # 00: Public address type + # 5A: address + # 09: address + # 9B: address + # 16: address + # A3: address + # 04: address + # 1B: length of data field (27 octets) + # 1A: length of first advertising data (AD) structure (26) + # FF: type of first AD structure - manufacturer specific data + # 4C: manufacturer ID - Apple iBeacon <- *This is where we start checking for a Tilt + # 00: manufacturer ID - Apple iBeacon + # 02: type (constant, defined by iBeacon spec) + # 15: length (constant, defined by iBeacon spec) + # A4: device UUID + # 95: device UUID < - This is where we stop checking for a Tilt + # BB: device UUID + # 10: device UUID < - Color, 10 - 80 + # C5: device UUID + # B1: device UUID + # 4B: device UUID + # 44: device UUID + # B5: device UUID + # 12: device UUID + # 13: device UUID + # 70: device UUID + # F0: device UUID + # 2D: device UUID + # 74: device UUID + # DE: device UUID + # 00: 'major' field of iBeacon data - temperature (in degrees fahrenheit) + # 44: 'major' field of iBeacon data - temperature (in degrees fahrenheit) + # 03: 'minor' field of iBeacon data - specific gravity (x1000) + # F8: 'minor' field of iBeacon data - specific gravity (x1000) + # C5: The TX power in dBm is a signed 8 bit integer. (-59dBm above or 197 unsigned) + # C7: Received signal strength indication (RSSI) is a signed 8 bit integer (-57dBm above or 199 unsigned) + # + # Temperature is a 16 bit unsigned integer, most significant bits first (big endian). + # + # The specific gravity x 1000 (‘minor’ field of iBeacon data) is a 16 bit unsigned integer, most significant bits first (big endian). Divide by 1000 to get the specific gravity. + # + # The UUID of the Tilt Hydrometer is shared between devices of that colour. The list is as follows [7]: + # + # Red: A495BB10C5B14B44B5121370F02D74DE + # Green: A495BB20C5B14B44B5121370F02D74DE + # Black: A495BB30C5B14B44B5121370F02D74DE + # Purple: A495BB40C5B14B44B5121370F02D74DE + # Orange: A495BB50C5B14B44B5121370F02D74DE + # Blue: A495BB60C5B14B44B5121370F02D74DE + # Yellow: A495BB70C5B14B44B5121370F02D74DE + # Pink: A495BB80C5B14B44B5121370F02D74DE + data = {} raw_data = packet.retrieve('Payload for mfg_specific_data') + ev_type = packet.retrieve('ev type') + msd = packet.retrieve('Manufacturer Specific Data') if raw_data: pckt = raw_data[0].val payload = raw_data[0].val.hex() mfg_id = payload[0:12] rssi = packet.retrieve('rssi') - mac = packet.retrieve("peer") + mac = packet.retrieve('peer') if mfg_id == TILT: + + # packet.show(0) # DEBUG + # "ev type" + # 0:"generic adv" (Tilt v1) + # 3:"no connection adv" (Tilt v2, 3 and Pro) + # 4:"scan rsp" + data['ev_type'] = ev_type[0].val + data['uuid'] = payload[8:40] + # Temperature (in degrees fahrenheit) data['major'] = unpack('>H', pckt[20:22])[0] - data['minor'] = unpack('>H', pckt[22:24])[0] + data['minor'] = unpack('>H', pckt[22:24])[0] # Specific gravity (x1000) + # tx_power will be -59 every 5 seconds in order to allow iOS + # to compute RSSI correctly. Only use 0 or real value. data['tx_power'] = unpack('>b', pckt[24:25])[0] data['rssi'] = rssi[-1].val data['mac'] = mac[-1].val return json.dumps(data).encode('utf-8') + else: + return None def blecallback(self, data): """ Callback method for the Bluetooth process + In turn calls self.decode() and then self.storeValue() + :param data: Data from aioblescan :return: None """ - packet = aiobs.HCI_Event() + fwVersion = 0 + temperature = 68 + gravity = 1 + + packet = aioblescan.HCI_Event() packet.decode(data) + # packet.show(0) # DEBUG response = self.decode(packet) + if response: tiltdata = json.loads(response.decode('utf-8', 'ignore')) - color = self.tiltName(tiltdata['uuid']) - gravity = int(tiltdata['minor']) / 1000 - temperature = int(tiltdata['major']) - battery = int(tiltdata['tx_power']) - self.storeValue(color, temperature, gravity, battery) + + if self.color == None or self.tiltName(tiltdata['uuid']) == self.color: + mac = str(tiltdata['mac']) + color = self.tiltName(tiltdata['uuid']) + + if int(tiltdata['major']) == 999: + # For the latest Tilts, this is now actually a special code indicating that + # the gravity is the version info. + fwVersion = int(tiltdata['minor']) + else: + if int(tiltdata['minor']) >= 5000: + # Is a Tilt Pro + # self.tilt_pro = True + gravity = int(tiltdata['minor']) / 10000 + temperature = int(tiltdata['major']) / 10 + else: + # Is not a Pro model + gravity = int(tiltdata['minor']) / 1000 + temperature = int(tiltdata['major']) + + battery = int(tiltdata['tx_power']) + + # Try to derive if we are v1, 2, or 3 + if int(tiltdata['ev_type']) == 0: # Only Tilt v1 shows as "generic adv" + hwVersion = 1 + elif int(tiltdata['minor']) >= 5000: + hwVersion = 4 + else: # TODO: 5 is "v2 or 3" until we can tell the difference between the two of them + hwVersion = 5 + + rssi = int(tiltdata['rssi']) + + timestamp = datetime.datetime.now() + + self.storeValue(timestamp, mac, hwVersion, fwVersion, color, temperature, gravity, battery) def start(self): """ @@ -228,11 +360,11 @@ def start(self): self.event_loop = asyncio.get_event_loop() # First create and configure a raw socket - self.mysocket = aiobs.create_bt_socket(self.dev_id) + self.mysocket = aioblescan.create_bt_socket(self.dev_id) # Create a connection with the STREAM socket self.fac = self.event_loop._create_connection_transport( - self.mysocket, aiobs.BLEScanRequester, None, None) + self.mysocket, aioblescan.BLEScanRequester, None, None) # Start it self.conn, self.btctrl = self.event_loop.run_until_complete(self.fac) # Attach your processing @@ -253,7 +385,7 @@ def stop(self): :return: None """ self.btctrl.stop_scan_request() - command = aiobs.HCI_Cmd_LE_Advertise(enable=False) + command = aioblescan.HCI_Cmd_LE_Advertise(enable=False) self.btctrl.send_command(command) asyncio.gather(*asyncio.Task.all_tasks()).cancel() @@ -270,21 +402,18 @@ class TiltValue: Holds all category values of an individual Tilt reading """ - color = None - temperature = 0 - gravity = 0 - timestamp = 0 - battery = 0 - - def __init__(self, color, temperature, gravity, battery): + def __init__(self, timestamp = 0, mac = "", hwVersion = 0, fwVersion = 0, color = None, temperature = 0, gravity = 0, battery = 0): + self.timestamp = timestamp + self.mac = mac + self.hwVersion = hwVersion + self.fwVersion = fwVersion self.color = color self.temperature = temperature self.gravity = gravity - self.timestamp = datetime.datetime.now() self.battery = battery def __str__(self): - return "C: " + str(self.color) + "T: " + str(self.temperature) + " G: " + str(self.gravity) + " B: " + str(self.battery) + return "S: " + str(self.timestamp) + " M: " + str(self.mac) + "F: " + str(self.fwVersion) + "C: " + str(self.color) + "T: " + str(self.temperature) + " G: " + str(self.gravity) + " B: " + str(self.battery) class Tilt: @@ -295,7 +424,10 @@ class Tilt: """ values = None - lock = None + lastMAC = '' + lastBatt = 0 + lastHwVersion = 0 + lastFwVersion = 0 averagingPeriod = 0 medianWindow = 0 calibrationDataTime = {} @@ -334,7 +466,7 @@ def calibrate(self, color): if self.gravityFunction is not None: self.gravCal = self.gravityFunction - def setValues(self, color, temperature, gravity, battery): + def setValues(self, timestamp, mac, hwVersion, fwVersion, color, temperature, gravity, battery): """ Set/add the latest temperature & gravity readings to the store. @@ -350,11 +482,12 @@ def setValues(self, color, temperature, gravity, battery): # to compute RSSI correctly. Only use 0 or real value. if battery < 0: battery = 0 - self.values.append(TiltValue(color, calTemp, calGrav, battery)) + + self.values.append(TiltValue(timestamp, mac, hwVersion, fwVersion, color, temperature, gravity, battery)) def getValues(self, color): """ - Returns the temperature, gravity & battery values of a given Tilt + Returns the values for a given Tilt This will be the latest read value unless averaging / median has been enabled @@ -366,43 +499,62 @@ def getValues(self, color): if len(self.values) > 0: for i in range(len(self.values)): if self.values[i].color == color: + + timestamp = self.values[i].timestamp + mac = self.values[i].mac + hwVersion = self.values[i].hwVersion + fwVersion = self.values[i].fwVersion temperature = self.values[i].temperature gravity = self.values[i].gravity battery = self.values[i].battery - colorValues.append( - TiltValue(color, temperature, gravity, battery)) + + colorValues.append(TiltValue(timestamp, mac, hwVersion, fwVersion, color, temperature, gravity, battery)) + if len(colorValues) > 0: if self.medianWindow == 0: returnValue = self.averageValues(color, colorValues) else: returnValue = self.medianValues(color, colorValues) + self.cleanValues() + return returnValue def averageValues(self, color, values): """ Average all the stored values in the Tilt class (except battery) - :return: Averaged values + :param color: Color of Tilt to check + :param values: Array containing values to check + :return: Averaged values """ returnValue = None + if len(values) > 0: - returnValue = TiltValue('', 0, 0, 0) + returnValue = TiltValue() for value in values: returnValue.temperature += value.temperature returnValue.gravity += value.gravity - # Average values + # Get last timestamp from values (or 0) + returnValue.timestamp = self.getTimestamp(color) + + # Get MAC out of array of values + returnValue.mac = self.getMAC(color) + + # Get Hardware version out of array of values + returnValue.hwVersion = self.getHwVersion(color) + + # Get Firmware version out of array of values + returnValue.fwVersion = self.getFWVersion(color) + + # Average temp and gravity values returnValue.temperature /= len(values) returnValue.gravity /= len(values) - # Round values - returnValue.temperature = returnValue.temperature - returnValue.gravity = returnValue.gravity - - # Make sure battery returns only real values (> 0) - returnValue.battery = self.getBatteryValue(values, color) + # Make sure battery returns max of current values (or 0) + returnValue.battery = self.getBatteryValue(color) return returnValue @@ -410,6 +562,8 @@ def medianValues(self, color, values, window=3): """ Use a median method across the stored values to reduce noise + :param color: Color of Tilt to be checked + :param values: Array containing values to check :param window: Smoothing window to apply across the data. If the window is less than the dataset size, the window will be moved across the dataset taking a median @@ -423,7 +577,7 @@ def medianValues(self, color, values, window=3): if len(values) < window: window = len(values) - returnValue = TiltValue('', 0, 0, 0) + returnValue = TiltValue() # sidebars = (window - 1) / 2 medianValueCount = 0 @@ -450,20 +604,43 @@ def medianValues(self, color, values, window=3): # Increase count medianValueCount += 1 + # Get last timestamp from values (or 0) + returnValue.timestamp = self.getTimestamp(color) + + # Get MAC out of array of values + returnValue.mac = self.getMAC(color) + + # Get Hardware version out of array of values + returnValue.hwVersion = self.getHwVersion(color) + + # Get Firmware version out of array of values + returnValue.fwVersion = self.getFWVersion(color) + # Average values returnValue.temperature /= medianValueCount returnValue.gravity /= medianValueCount - # Round values - returnValue.temperature = returnValue.temperature - returnValue.gravity = returnValue.gravity - - # Now just get the max of battery to filter out 0's - returnValue.battery = self.getBatteryValue(values, color) + # Make sure battery returns max of current values (or 0) + returnValue.battery = self.getBatteryValue(color) return returnValue - def getBatteryValue(self, values, color): + def getTimestamp(self, color): + """ + Return timestamp of last report for a given color + + :param values: Array of values to be checked + :return: Timestamp of last report, or 0 + """ + + timestamps = [] + for i in range(len(self.values)): + value = self.values[i] + timestamps.append(value.timestamp) + + return max(timestamps) + + def getBatteryValue(self, color): """ Return battery age in weeks for a given color @@ -472,13 +649,68 @@ def getBatteryValue(self, values, color): """ batteryValues = [] - if len(values) > 0: - for i in range(len(values)): - if values[i].color == color: - batteryValues.append(values[i].battery) - return max(batteryValues) - else: - return 0 + for i in range(len(self.values)): + value = self.values[i] + batteryValues.append(value.battery) + + # Since tx_power will be -59 every 5 seconds in order to allow iOS + # to compute RSSI correctly, we cache the last good value and only + # use the max of all on-hand values as the battery age to prevent + # zeroes. A zero value should only come from V1 (and maybe v2) Tilts. + batteryValue = max(batteryValues) + self.lastBatt = max(batteryValue, self.lastBatt) + return self.lastBatt + + def getHwVersion(self, color): + """ + Return HArdware Version for a given color + + :param values: An array of Tilt values + :return: Int of TILT_VERSIONS or empty string + """ + + hwVersions = [] + for i in range(len(self.values)): + value = self.values[i] + hwVersions.append(value.hwVersion) + + hwVersion = max(hwVersions) + self.lastHwVersion = max(hwVersion, self.lastHwVersion) + return self.lastHwVersion + + def getFWVersion(self, color): + """ + Return firmware version for a given color + + :param values: An array of Tilt values + :return: Integer of version or 0 + """ + + fwVersions = [] + for i in range(len(self.values)): + value = self.values[i] + fwVersions.append(value.fwVersion) + + fwVersion = max(fwVersions) + self.lastFwVersion = max(fwVersion, self.lastFwVersion) + return self.lastFwVersion + + def getMAC(self, color): + """ + Return MAC for a given color + + :param values: An array of Tilt values + :return: String of version or empty string + """ + + macs = [] + for i in range(len(self.values)): + value = self.values[i] + macs.append(value.mac) + + mac = max(macs) + self.lastMAC = max(mac, self.lastMAC) + return self.lastMAC def cleanValues(self): """ @@ -722,8 +954,8 @@ def main(): # Check that Python has the correct capabilities set hasCaps, pythonPath, getCapValues = checkSetcap() if not hasCaps: - print("\nERROR: Missing cap flags on python executable.\nExecutable:\t{}\nCap Values:\t{}\n".format( - pythonPath, getCapValues)) + commandLine = "sudo setcap cap_net_raw+eip $(eval readlink -f `which python3`)" + print("\nERROR: Missing cap flags on python executable.\nExecutable:\t{}\nCap Values:\t{}\nSuggested command:\t{}".format(pythonPath, getCapValues, commandLine)) return if opts.color: @@ -747,25 +979,27 @@ def main(): tilt.start() try: - print( - "\nReporting {} Tilt values every 5 seconds. Ctrl-C to stop.".format(tiltColorName)) + print("\nReporting {} Tilt values every 5 seconds. Ctrl-C to stop.".format(tiltColorName)) while 1: # If we are running Tilt, get current values if tilt: sleep(5) + # Check each of the Tilt colors for color in TILT_COLORS: if color == tiltColor or tiltColor == None: tiltValue = tilt.getValue(color) if tiltValue is not None: + timestamp = tiltValue.timestamp + hwVersion = tiltValue.hwVersion + fwVersion = tiltValue.fwVersion temperature = round(tiltValue.temperature, 2) gravity = round(tiltValue.gravity, 3) battery = tiltValue.battery - print("{0}:\tTemp: {1}°F, Gravity: {2}, Battery: {3} weeks old.".format( - color, temperature, gravity, battery)) + mac = tiltValue.mac + print("{}:\tLast Report: {}\n\tMAC: {}, Version: {}, Firmware: {}\n\tTemp: {}°F, Gravity: {}, Battery: {} weeks old".format(color, timestamp, mac.upper(), TILT_VERSIONS[hwVersion], fwVersion, temperature, gravity, battery)) else: - print( - "{0}:\tNo results returned.".format(color)) + print("{}:\tNo results returned.".format(color)) except KeyboardInterrupt: print('\nKeyboard interrupt.') From 3fa72d974c5be5b4d9086e39059b2723c7c8b1cc Mon Sep 17 00:00:00 2001 From: lbussy Date: Sat, 19 Dec 2020 11:35:14 -0600 Subject: [PATCH 13/22] Fix startup sequence --- brewpi.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/brewpi.py b/brewpi.py index c6c6197..da6fa1e 100755 --- a/brewpi.py +++ b/brewpi.py @@ -299,16 +299,16 @@ def checkDoNotRun(): # Check do not run file util.addSlash(config['wwwPath'])) # Check dont run file when it exists and exit it it does - if checkDontRunFile: + if os.path.exists(dontRunFilePath): + + # Do not print anything or it will flood the logs + sys.exit(1) + else: + # This is here to exit with the semaphore anyway, but print notice + # This should only be hit when running interactively. if os.path.exists(dontRunFilePath): - # Do not print anything or it will flood the logs + print("Semaphore exists, exiting.") sys.exit(1) - else: - # This is here to exit with the semaphore anyway, but print notice - # This should only be hit when running interactively. - if os.path.exists(dontRunFilePath): - print("Semaphore exists, exiting.") - sys.exit(1) def checkOthers(): # Check for other running brewpi From 85e66c456e5c569d80d9087c4df95397634d1264 Mon Sep 17 00:00:00 2001 From: lbussy Date: Sat, 19 Dec 2020 12:28:13 -0600 Subject: [PATCH 14/22] Add Pro, rewrite major areas --- Tilt.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Tilt.py b/Tilt.py index 9ae3d2a..d81baf3 100755 --- a/Tilt.py +++ b/Tilt.py @@ -12,6 +12,7 @@ # I still have no idea why this will not work in a venv # Fix design version (v1, 2, 3) # Fix battery value based on version and gattool? +# Change tilt manager object to an array even if one color import sys from os.path import dirname, abspath, exists, isfile, getmtime From f6d784adef1fd4b808f4b3cf9aadf4d1e9730661 Mon Sep 17 00:00:00 2001 From: lbussy Date: Sat, 19 Dec 2020 16:49:29 -0600 Subject: [PATCH 15/22] Fix exceptions --- Tilt.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) mode change 100755 => 100644 Tilt.py diff --git a/Tilt.py b/Tilt.py old mode 100755 new mode 100644 From 050465d93e327416c8df3d4c9222ad03da56de72 Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 21 Dec 2020 02:29:17 -0600 Subject: [PATCH 16/22] Fix logging --- BrewPiUtil.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/BrewPiUtil.py b/BrewPiUtil.py index 6a945c3..5b77882 100755 --- a/BrewPiUtil.py +++ b/BrewPiUtil.py @@ -278,9 +278,9 @@ def logError(*objs): Prints a timestamped message to stderr """ if 'USE_TIMESTAMP_LOG' in os.environ: - printStdOut(strftime("%Y-%m-%d %H:%M:%S [E]"), *objs) + printStdErr(strftime("%Y-%m-%d %H:%M:%S [E]"), *objs) else: - printStdOut(*objs) + printStdErr(*objs) def removeDontRunFile(path = None): @@ -536,12 +536,15 @@ def asciiToUnicode(s): class Unbuffered(object): def __init__(self, stream): self.stream = stream + def write(self, data): self.stream.write(data) self.stream.flush() + def writelines(self, datas): self.stream.writelines(datas) self.stream.flush() + def __getattr__(self, attr): return getattr(self.stream, attr) From c85b8ea7103b978987e3d7cb5f05c9b3a0c93c86 Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 21 Dec 2020 02:30:12 -0600 Subject: [PATCH 17/22] Check color on arg --- Tilt.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) mode change 100644 => 100755 Tilt.py diff --git a/Tilt.py b/Tilt.py old mode 100644 new mode 100755 index d81baf3..d312274 --- a/Tilt.py +++ b/Tilt.py @@ -61,7 +61,7 @@ def __init__(self, color=None, averagingPeriod=0, medianWindow=0, dev_id=0): self.dev_id = dev_id self.averagingPeriod = averagingPeriod self.medianWindow = medianWindow - if color == None: + if color is None: # Set up an array of Tilt objects, one for each color self.tilt = [None] * len(TILT_COLORS) for i in range(len(TILT_COLORS)): @@ -394,6 +394,7 @@ def stop(self): self.event_loop.call_soon_threadsafe(self.event_loop.stop) thread.join() + self.conn.close() self.event_loop.close() return @@ -871,6 +872,10 @@ def parseArgs(): help="number of entries in median window") try: opts = parser.parse_args() + opts.color = opts.color.title() if opts.color else None + if opts.color and opts.color not in TILT_COLORS: + parser.error("Invalid color choice.") + opts.hci = opts.hci if opts.hci else 0 return opts except Exception as e: parser.error("Error: " + str(e)) From 414f40dbb7a73df0f74099e33b5467df09f0c7cd Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 21 Dec 2020 02:31:07 -0600 Subject: [PATCH 18/22] Clamp Tilt values --- brewpi.py | 66 +++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 52 insertions(+), 14 deletions(-) diff --git a/brewpi.py b/brewpi.py index da6fa1e..66b142f 100755 --- a/brewpi.py +++ b/brewpi.py @@ -143,6 +143,12 @@ lastTiltbridge = 0 timeoutTiltbridge = 300 +# Clamp values for Tilt +clampSGUpper = 1.150 +clampSGLower = 0.980 +clampTempHigh = 99.0 +clampTempLow = 30.0 + # Keep track of time between new data requests prevDataTime = 0 prevTimeOut = 0 @@ -588,7 +594,10 @@ def initTilt(): # Set up Tilt if not checkBluetooth(): logError("Configured for Tilt but no Bluetooth radio available.") else: - tilt = Tilt.TiltManager(config['tiltColor'], 300, 10000, 0) + if tilt: + tilt.stop() + tilt = None + tilt = Tilt.TiltManager(config['tiltColor'], 60, 10, 0) tilt.loadSettings() tilt.start() # Create prevTempJson for Tilt @@ -703,6 +712,11 @@ def startLogs(): # Log startup messages urllib.parse.unquote(config['beerName']) + ".'") +def clamp(raw, minn, maxn): + # Clamps value (raw) between minn and maxn + return max(min(maxn, raw), minn) + + def startSerial(): # Start controller global config global serialConn @@ -729,11 +743,9 @@ def startSerial(): # Start controller logMessage("very old version of BrewPi. Please upload a new version") logMessage("of BrewPi to your controller.") # Script will continue so you can at least program the controller - lcdText = ['Could not receive', 'ver from controller', - 'Please (re)program', 'your controller.'] + lcdText = ['Could not receive', 'ver from controller', 'Please (re)program', 'your controller.'] else: - logMessage("Found " + hwVersion.toExtendedString() + - " on port " + serialConn.name + ".") + logMessage("Found " + hwVersion.toExtendedString() + " on port " + serialConn.name + ".") if LooseVersion(hwVersion.toString()) < LooseVersion(compatibleHwVersion): logMessage("Warning: Minimum BrewPi version compatible with this") logMessage("script is {0} but version number received is".format( @@ -769,10 +781,22 @@ def startSerial(): # Start controller except KeyboardInterrupt: print() # Simply a visual hack if we are running via command line logMessage("Detected keyboard interrupt, exiting.") - shutdown() - sys.exit(0) + + except RuntimeError: + logError(e) + type, value, traceback = sys.exc_info() + fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] + logError("Caught a Runtime Error.") + logError("Error info:") + logError("\tError: ({0}): '{1}'".format( + getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) + logError("\tType: {0}".format(type)) + logError("\tFilename: {0}".format(fname)) + logError("\tLineNo: {0}".format(traceback.tb_lineno)) + logMessage("Caught a Runtime Error.") except Exception as e: + logError(e) type, value, traceback = sys.exc_info() fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] logError("Caught an unexpected exception.") @@ -782,9 +806,7 @@ def startSerial(): # Start controller logError("\tType: {0}".format(type)) logError("\tFilename: {0}".format(fname)) logError("\tLineNo: {0}".format(traceback.tb_lineno)) - logMessage("Caught an unexpected exception, exiting.") - shutdown() - sys.exit(1) + logMessage("Caught an unexpected exception.") def loop(): # Main program loop @@ -818,6 +840,11 @@ def loop(): # Main program loop global tilt global tiltbridge global ispindel + # Clamp values for Tilt + global clampSGUpper + global clampSGLower + global clampTempHigh + global clampTempLow bc = BrewConvert.BrewConvert() run = True # Allow script loop to run @@ -1480,17 +1507,29 @@ def loop(): # Main program loop tiltValue = tilt.getValue(color) if tiltValue is not None: _temp = tiltValue.temperature + + # Clamp temp values + _temp = clamp(_temp, clampTempLow, clampTempHigh) + + # Convert to C if cc['tempFormat'] == 'C': - _temp = bc.convert( - _temp, 'F', 'C') + _temp = bc.convert(_temp, 'F', 'C') + + # Clamp SG Values + _grav = clamp( + tiltValue.gravity, + clampSGLower, clampSGUpper) prevTempJson[color + 'Temp'] = round(_temp, 2) prevTempJson[color + - 'SG'] = round(tiltValue.gravity, 3) + 'SG'] = round(_grav, 3) prevTempJson[color + 'Batt'] = round(tiltValue.battery, 3) else: + logError("Failed to retrieve {} Tilt value, restarting Tilt.".format(color)) + initTilt() + prevTempJson[color + 'Temp'] = None prevTempJson[color + @@ -1742,7 +1781,6 @@ def shutdown(): # Process a graceful shutdown global tilt global thread global serialConn - global conn try: bgSerialConn # If we are running background serial, stop it From a58812f7f70296bf7d8edac905af4ca2c712318c Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 21 Dec 2020 06:21:15 -0600 Subject: [PATCH 19/22] Allow higher res for Pro --- Tilt.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/Tilt.py b/Tilt.py index d312274..551271b 100755 --- a/Tilt.py +++ b/Tilt.py @@ -329,12 +329,12 @@ def blecallback(self, data): if int(tiltdata['minor']) >= 5000: # Is a Tilt Pro # self.tilt_pro = True - gravity = int(tiltdata['minor']) / 10000 - temperature = int(tiltdata['major']) / 10 + gravity = float(tiltdata['minor']) / 10000 + temperature = float(tiltdata['major']) / 10 else: # Is not a Pro model - gravity = int(tiltdata['minor']) / 1000 - temperature = int(tiltdata['major']) + gravity = float(tiltdata['minor']) / 1000 + temperature = float(tiltdata['major']) battery = int(tiltdata['tx_power']) @@ -999,8 +999,12 @@ def main(): timestamp = tiltValue.timestamp hwVersion = tiltValue.hwVersion fwVersion = tiltValue.fwVersion - temperature = round(tiltValue.temperature, 2) - gravity = round(tiltValue.gravity, 3) + if (hwVersion == 4): # If we are using a Pro, take advantage of it + temperature = round(tiltValue.temperature, 2) + gravity = round(tiltValue.gravity, 4) + else: + temperature = round(tiltValue.temperature, 2) + gravity = round(tiltValue.gravity, 3) battery = tiltValue.battery mac = tiltValue.mac print("{}:\tLast Report: {}\n\tMAC: {}, Version: {}, Firmware: {}\n\tTemp: {}°F, Gravity: {}, Battery: {} weeks old".format(color, timestamp, mac.upper(), TILT_VERSIONS[hwVersion], fwVersion, temperature, gravity, battery)) From 1de294d6d31447912d22b430999d65d4bb9cf6dc Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 21 Dec 2020 06:22:18 -0600 Subject: [PATCH 20/22] Add Tilt HW/SW version --- brewpi.py | 132 +++++++++++++++++++++--------------------------------- 1 file changed, 52 insertions(+), 80 deletions(-) diff --git a/brewpi.py b/brewpi.py index 66b142f..cdd203d 100755 --- a/brewpi.py +++ b/brewpi.py @@ -177,6 +177,8 @@ statusType = ['N/A', 'N/A', 'N/A', 'N/A'] statusValue = ['N/A', 'N/A', 'N/A', 'N/A'] +TILT_VERSIONS = ['Unknown', 'v1', 'v2', 'v3', 'Pro', 'v2 or 3'] + def getGit(): # Get the current script version @@ -602,8 +604,10 @@ def initTilt(): # Set up Tilt tilt.start() # Create prevTempJson for Tilt prevTempJson.update({ - config['tiltColor'] + 'Temp': 0, + config['tiltColor'] + 'HWVer': 0, + config['tiltColor'] + 'SWVer': 0, config['tiltColor'] + 'SG': 0, + config['tiltColor'] + 'Temp': 0, config['tiltColor'] + 'Batt': 0 }) @@ -1165,11 +1169,9 @@ def loop(): # Main program loop if apiKey == "Brew Bubbles": # Received JSON from Brew Bubbles # Log received line if true, false is short message, none = mute if outputJson == True: - logMessage( - "API BB JSON Recvd: " + json.dumps(api)) + logMessage("API BB JSON Recvd: " + json.dumps(api)) elif outputJson == False: - logMessage( - "API Brew Bubbles JSON received.") + logMessage("API Brew Bubbles JSON received.") else: pass # Don't log JSON messages @@ -1211,12 +1213,10 @@ def loop(): # Main program loop # END: Process a Brew Bubbles API POST else: - logMessage( - "WARNING: Unknown API key received in JSON:") + logMessage("WARNING: Unknown API key received in JSON:") logMessage(value) # Begin: iSpindel Processing - # iSpindel elif checkKey(api, 'name') and checkKey(api, 'ID') and checkKey(api, 'gravity'): if ispindel is not None and config['iSpindel'] == api['name']: @@ -1303,28 +1303,20 @@ def loop(): # Main program loop lastTiltbridge = timestamp = time.time() _temp = api['tilts'][config['tiltColor']]['temp'] if cc['tempFormat'] == 'C': - _temp = round( - bc.convert(_temp, 'F', 'C'), 1) - prevTempJson[config["tiltColor"] + - 'Temp'] = round(_temp, 1) - prevTempJson[config["tiltColor"] + 'SG'] = float( - api['tilts'][config['tiltColor']]['gravity']) - # TODO: prevTempJson[config["tiltColor"] + 'Batt'] = api['tilts'][config['tiltColor']]['batt'] - prevTempJson[config["tiltColor"] + - 'Batt'] = None + _temp = round(bc.convert(_temp, 'F', 'C'), 1) + prevTempJson[config["tiltColor"] + 'Temp'] = _temp, 1 + prevTempJson[config["tiltColor"] + 'SG'] = float(api['tilts'][config['tiltColor']]['gravity']) + prevTempJson[config["tiltColor"] + 'Batt'] = None # END: Tiltbridge Processing else: - logError( - "Received API message, however no matching configuration exists.") + logError("Received API message, however no matching configuration exists.") except json.JSONDecodeError: - logError( - "Invalid JSON received from API. String received:") + logError("Invalid JSON received from API. String received:") logError(value) except Exception as e: - logError( - "Unknown error processing API. String received:") + logError("Unknown error processing API. String received:") logError(value) elif messageType == "statusText": # Status contents requested status = {} @@ -1342,97 +1334,78 @@ def loop(): # Main program loop if checkKey(prevTempJson, 'bbbpm'): status[statusIndex] = {} statusType = "Airlock: " - statusValue = str( - round(prevTempJson['bbbpm'], 1)) + " bpm" + statusValue = str(round(prevTempJson['bbbpm'], 1)) + " bpm" status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'bbamb'): - # filter out disconnected sensors - if not int(prevTempJson['bbamb']) == -100: + if int(prevTempJson['bbamb']) > -127: status[statusIndex] = {} statusType = "Ambient Temp: " - statusValue = str( - round(prevTempJson['bbamb'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['bbamb'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'bbves'): - # filter out disconnected sensors - if not int(prevTempJson['bbves']) == -100: + if int(prevTempJson['bbves']) > -127: status[statusIndex] = {} statusType = "Vessel Temp: " - statusValue = str( - round(prevTempJson['bbves'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['bbves'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: Brew Bubbles Items # Begin: Tilt Items if tilt or tiltbridge: - # if not config['dataLogging'] == 'active': # Only display SG in status when not logging data - # Use as a check to see if it's online if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: if checkKey(prevTempJson, config['tiltColor'] + 'SG'): if prevTempJson[config['tiltColor'] + 'SG'] is not None: status[statusIndex] = {} statusType = "Tilt SG: " - statusValue = str( - prevTempJson[config['tiltColor'] + 'SG']) - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(prevTempJson[config['tiltColor'] + 'SG']) + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, config['tiltColor'] + 'Batt'): if prevTempJson[config['tiltColor'] + 'Batt'] is not None: if not prevTempJson[config['tiltColor'] + 'Batt'] == 0: status[statusIndex] = {} statusType = "Tilt Batt Age: " - statusValue = str( - round(prevTempJson[config['tiltColor'] + 'Batt'], 1)) + " wks" - status[statusIndex].update( - {statusType: statusValue}) + if round(prevTempJson[config['tiltColor'] + 'Batt']) == 1: + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Batt'])) + " wk" + else: + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Batt'])) + " wks" + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 - # and (statusIndex <= 3): if checkKey(prevTempJson, config['tiltColor'] + 'Temp'): if prevTempJson[config['tiltColor'] + 'Temp'] is not None: if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: status[statusIndex] = {} statusType = "Tilt Temp: " - statusValue = str( - round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: Tilt Items # Begin: iSpindel Items if ispindel is not None: - # if config['dataLogging'] == 'active': # Only display SG in status when not logging data if checkKey(prevTempJson, 'spinSG'): if prevTempJson['spinSG'] is not None: status[statusIndex] = {} statusType = "iSpindel SG: " - statusValue = str(prevTempJson['spinSG']) - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinSG'], 3)) + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'spinBatt'): if prevTempJson['spinBatt'] is not None: status[statusIndex] = {} statusType = "iSpindel Batt: " - statusValue = str( - round(prevTempJson['spinBatt'], 1)) + "VDC" - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinBatt'], 1)) + "VDC" + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'spinTemp'): if prevTempJson['spinTemp'] is not None: status[statusIndex] = {} statusType = "iSpindel Temp: " - statusValue = str( - round(prevTempJson['spinTemp'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinTemp'], 2)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: iSpindel Items @@ -1507,6 +1480,8 @@ def loop(): # Main program loop tiltValue = tilt.getValue(color) if tiltValue is not None: _temp = tiltValue.temperature + prevTempJson[color + 'HWVer'] = TILT_VERSIONS[tiltValue.hwVersion] + prevTempJson[color + 'SWVer'] = tiltValue.fwVersion # Clamp temp values _temp = clamp(_temp, clampTempLow, clampTempHigh) @@ -1516,26 +1491,23 @@ def loop(): # Main program loop _temp = bc.convert(_temp, 'F', 'C') # Clamp SG Values - _grav = clamp( - tiltValue.gravity, - clampSGLower, clampSGUpper) - - prevTempJson[color + - 'Temp'] = round(_temp, 2) - prevTempJson[color + - 'SG'] = round(_grav, 3) - prevTempJson[color + - 'Batt'] = round(tiltValue.battery, 3) + _grav = clamp(tiltValue.gravity, clampSGLower, clampSGUpper) + + if prevTempJson[color + 'HWVer'] == 4: + prevTempJson[color + 'SG'] = round(_grav, 4) + prevTempJson[color + 'Temp'] = round(_temp, 2) + else: + prevTempJson[color + 'SG'] = round(_grav, 3) + prevTempJson[color + 'Temp'] = round(_temp, 1) + + prevTempJson[color + 'Batt'] = round(tiltValue.battery, 2) else: logError("Failed to retrieve {} Tilt value, restarting Tilt.".format(color)) initTilt() - prevTempJson[color + - 'Temp'] = None - prevTempJson[color + - 'SG'] = None - prevTempJson[color + - 'Batt'] = None + prevTempJson[color + 'Temp'] = None + prevTempJson[color + 'SG'] = None + prevTempJson[color + 'Batt'] = None # Expire old BB keypairs if (time.time() - lastBbApi) > timeoutBB: From 499efafcd714fc9ea46b95f1ee3257883e53e52e Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 21 Dec 2020 15:34:14 -0600 Subject: [PATCH 21/22] Add clamping, update Tiltbridge --- brewpi.py | 223 ++++++++++++++++++++++++++++++------------------------ 1 file changed, 126 insertions(+), 97 deletions(-) diff --git a/brewpi.py b/brewpi.py index da6fa1e..0447dea 100755 --- a/brewpi.py +++ b/brewpi.py @@ -143,6 +143,12 @@ lastTiltbridge = 0 timeoutTiltbridge = 300 +# Clamp values for Tilt +clampSGUpper = 1.175 +clampSGLower = 0.970 +clampTempHigh = 110.0 +clampTempLow = 25.0 + # Keep track of time between new data requests prevDataTime = 0 prevTimeOut = 0 @@ -171,6 +177,8 @@ statusType = ['N/A', 'N/A', 'N/A', 'N/A'] statusValue = ['N/A', 'N/A', 'N/A', 'N/A'] +TILT_VERSIONS = ['Unknown', 'v1', 'v2', 'v3', 'Pro', 'v2 or 3'] + def getGit(): # Get the current script version @@ -335,8 +343,8 @@ def setUpLog(): # Set up log files sys.stderr = Unbuffered(open(logPath + 'stderr.txt', 'a+')) # Overwrite stdout, unbuffered sys.stdout = Unbuffered(open(logPath + 'stdout.txt', 'w+')) - # Start the logs - logError('Starting BrewPi.') # Timestamp stderr + # Start the logs + logError('Starting BrewPi.') # Timestamp stderr if logToFiles: # Make sure we send a message to daemon print('Starting BrewPi.', file=sys.__stdout__) @@ -588,13 +596,18 @@ def initTilt(): # Set up Tilt if not checkBluetooth(): logError("Configured for Tilt but no Bluetooth radio available.") else: - tilt = Tilt.TiltManager(config['tiltColor'], 300, 10000, 0) + if tilt: + tilt.stop() + tilt = None + tilt = Tilt.TiltManager(config['tiltColor'], 60, 10, 0) tilt.loadSettings() tilt.start() # Create prevTempJson for Tilt prevTempJson.update({ - config['tiltColor'] + 'Temp': 0, + config['tiltColor'] + 'HWVer': 0, + config['tiltColor'] + 'SWVer': 0, config['tiltColor'] + 'SG': 0, + config['tiltColor'] + 'Temp': 0, config['tiltColor'] + 'Batt': 0 }) @@ -703,6 +716,11 @@ def startLogs(): # Log startup messages urllib.parse.unquote(config['beerName']) + ".'") +def clamp(raw, minn, maxn): + # Clamps value (raw) between minn and maxn + return max(min(maxn, raw), minn) + + def startSerial(): # Start controller global config global serialConn @@ -729,11 +747,9 @@ def startSerial(): # Start controller logMessage("very old version of BrewPi. Please upload a new version") logMessage("of BrewPi to your controller.") # Script will continue so you can at least program the controller - lcdText = ['Could not receive', 'ver from controller', - 'Please (re)program', 'your controller.'] + lcdText = ['Could not receive', 'ver from controller', 'Please (re)program', 'your controller.'] else: - logMessage("Found " + hwVersion.toExtendedString() + - " on port " + serialConn.name + ".") + logMessage("Found " + hwVersion.toExtendedString() + " on port " + serialConn.name + ".") if LooseVersion(hwVersion.toString()) < LooseVersion(compatibleHwVersion): logMessage("Warning: Minimum BrewPi version compatible with this") logMessage("script is {0} but version number received is".format( @@ -769,10 +785,22 @@ def startSerial(): # Start controller except KeyboardInterrupt: print() # Simply a visual hack if we are running via command line logMessage("Detected keyboard interrupt, exiting.") - shutdown() - sys.exit(0) + + except RuntimeError: + logError(e) + type, value, traceback = sys.exc_info() + fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] + logError("Caught a Runtime Error.") + logError("Error info:") + logError("\tError: ({0}): '{1}'".format( + getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) + logError("\tType: {0}".format(type)) + logError("\tFilename: {0}".format(fname)) + logError("\tLineNo: {0}".format(traceback.tb_lineno)) + logMessage("Caught a Runtime Error.") except Exception as e: + logError(e) type, value, traceback = sys.exc_info() fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] logError("Caught an unexpected exception.") @@ -782,9 +810,7 @@ def startSerial(): # Start controller logError("\tType: {0}".format(type)) logError("\tFilename: {0}".format(fname)) logError("\tLineNo: {0}".format(traceback.tb_lineno)) - logMessage("Caught an unexpected exception, exiting.") - shutdown() - sys.exit(1) + logMessage("Caught an unexpected exception.") def loop(): # Main program loop @@ -818,6 +844,11 @@ def loop(): # Main program loop global tilt global tiltbridge global ispindel + # Clamp values for Tilt + global clampSGUpper + global clampSGLower + global clampTempHigh + global clampTempLow bc = BrewConvert.BrewConvert() run = True # Allow script loop to run @@ -1138,11 +1169,9 @@ def loop(): # Main program loop if apiKey == "Brew Bubbles": # Received JSON from Brew Bubbles # Log received line if true, false is short message, none = mute if outputJson == True: - logMessage( - "API BB JSON Recvd: " + json.dumps(api)) + logMessage("API BB JSON Recvd: " + json.dumps(api)) elif outputJson == False: - logMessage( - "API Brew Bubbles JSON received.") + logMessage("API Brew Bubbles JSON received.") else: pass # Don't log JSON messages @@ -1184,12 +1213,10 @@ def loop(): # Main program loop # END: Process a Brew Bubbles API POST else: - logMessage( - "WARNING: Unknown API key received in JSON:") + logMessage("WARNING: Unknown API key received in JSON:") logMessage(value) # Begin: iSpindel Processing - # iSpindel elif checkKey(api, 'name') and checkKey(api, 'ID') and checkKey(api, 'gravity'): if ispindel is not None and config['iSpindel'] == api['name']: @@ -1274,31 +1301,43 @@ def loop(): # Main program loop # Set time of last update lastTiltbridge = timestamp = time.time() - _temp = api['tilts'][config['tiltColor']]['temp'] - if cc['tempFormat'] == 'C': - _temp = round( - bc.convert(_temp, 'F', 'C'), 1) - prevTempJson[config["tiltColor"] + - 'Temp'] = round(_temp, 1) - prevTempJson[config["tiltColor"] + 'SG'] = float( - api['tilts'][config['tiltColor']]['gravity']) - # TODO: prevTempJson[config["tiltColor"] + 'Batt'] = api['tilts'][config['tiltColor']]['batt'] - prevTempJson[config["tiltColor"] + - 'Batt'] = None + + # Convert to proper temp unit + _temp = 0 + if cc['tempFormat'] == api['tilts'][config['tiltColor']]['tempUnit']: + _temp = float(api['tilts'][config['tiltColor']]['temp']) + elif cc['tempFormat'] == 'F': + _temp = bc.convert(float(api['tilts'][config['tiltColor']]['temp']), 'C', 'F') + else: + _temp = bc.convert(float(api['tilts'][config['tiltColor']]['temp']), 'F', 'C') + + prevTempJson[config["tiltColor"] + 'Temp'] = _temp + prevTempJson[config["tiltColor"] + 'SG'] = float(api['tilts'][config['tiltColor']]['gravity']) + + # high_resolution: true + # sends_battery: true + # fwVersion: 0 + + # if (checkKey(api['tilts'][config['tiltColor']], 'HWVer')): + # prevTempJson[config["tiltColor"] + 'HWVer'] = int(api['tilts'][config['tiltColor']]['hwVersion']) + # if (checkKey(api['tilts'][config['tiltColor']], 'SWVer')): + # prevTempJson[config["tiltColor"] + 'SWVer'] = int(api['tilts'][config['tiltColor']]['fwVersion']) + + if (checkKey(api['tilts'][config['tiltColor']], 'weeks_on_battery')): + prevTempJson[config["tiltColor"] + 'Batt'] = int(api['tilts'][config['tiltColor']]['weeks_on_battery']) # END: Tiltbridge Processing else: - logError( - "Received API message, however no matching configuration exists.") + logError("Received API message, however no matching configuration exists.") except json.JSONDecodeError: - logError( - "Invalid JSON received from API. String received:") + logError("Invalid JSON received from API. String received:") logError(value) + except Exception as e: - logError( - "Unknown error processing API. String received:") + logError("Unknown error processing API. String received:") logError(value) + elif messageType == "statusText": # Status contents requested status = {} statusIndex = 0 @@ -1315,97 +1354,78 @@ def loop(): # Main program loop if checkKey(prevTempJson, 'bbbpm'): status[statusIndex] = {} statusType = "Airlock: " - statusValue = str( - round(prevTempJson['bbbpm'], 1)) + " bpm" + statusValue = str(round(prevTempJson['bbbpm'], 1)) + " bpm" status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'bbamb'): - # filter out disconnected sensors - if not int(prevTempJson['bbamb']) == -100: + if int(prevTempJson['bbamb']) > -127: status[statusIndex] = {} statusType = "Ambient Temp: " - statusValue = str( - round(prevTempJson['bbamb'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['bbamb'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'bbves'): - # filter out disconnected sensors - if not int(prevTempJson['bbves']) == -100: + if int(prevTempJson['bbves']) > -127: status[statusIndex] = {} statusType = "Vessel Temp: " - statusValue = str( - round(prevTempJson['bbves'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['bbves'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: Brew Bubbles Items # Begin: Tilt Items if tilt or tiltbridge: - # if not config['dataLogging'] == 'active': # Only display SG in status when not logging data - # Use as a check to see if it's online if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: if checkKey(prevTempJson, config['tiltColor'] + 'SG'): if prevTempJson[config['tiltColor'] + 'SG'] is not None: status[statusIndex] = {} statusType = "Tilt SG: " - statusValue = str( - prevTempJson[config['tiltColor'] + 'SG']) - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(prevTempJson[config['tiltColor'] + 'SG']) + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, config['tiltColor'] + 'Batt'): if prevTempJson[config['tiltColor'] + 'Batt'] is not None: if not prevTempJson[config['tiltColor'] + 'Batt'] == 0: status[statusIndex] = {} statusType = "Tilt Batt Age: " - statusValue = str( - round(prevTempJson[config['tiltColor'] + 'Batt'], 1)) + " wks" - status[statusIndex].update( - {statusType: statusValue}) + if round(prevTempJson[config['tiltColor'] + 'Batt']) == 1: + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Batt'])) + " wk" + else: + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Batt'])) + " wks" + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 - # and (statusIndex <= 3): if checkKey(prevTempJson, config['tiltColor'] + 'Temp'): if prevTempJson[config['tiltColor'] + 'Temp'] is not None: if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: status[statusIndex] = {} statusType = "Tilt Temp: " - statusValue = str( - round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: Tilt Items # Begin: iSpindel Items if ispindel is not None: - # if config['dataLogging'] == 'active': # Only display SG in status when not logging data if checkKey(prevTempJson, 'spinSG'): if prevTempJson['spinSG'] is not None: status[statusIndex] = {} statusType = "iSpindel SG: " - statusValue = str(prevTempJson['spinSG']) - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinSG'], 3)) + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'spinBatt'): if prevTempJson['spinBatt'] is not None: status[statusIndex] = {} statusType = "iSpindel Batt: " - statusValue = str( - round(prevTempJson['spinBatt'], 1)) + "VDC" - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinBatt'], 1)) + "VDC" + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'spinTemp'): if prevTempJson['spinTemp'] is not None: status[statusIndex] = {} statusType = "iSpindel Temp: " - statusValue = str( - round(prevTempJson['spinTemp'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinTemp'], 2)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: iSpindel Items @@ -1468,8 +1488,7 @@ def loop(): # Main program loop newData = json.loads(line[2:]) # Copy/rename keys for key in newData: - prevTempJson[renameTempKey( - key)] = newData[key] + prevTempJson[renameTempKey(key)] = newData[key] # If we are running Tilt, get current values if (tilt is not None) and (tiltbridge is not None): @@ -1480,23 +1499,35 @@ def loop(): # Main program loop tiltValue = tilt.getValue(color) if tiltValue is not None: _temp = tiltValue.temperature + prevTempJson[color + 'HWVer'] = TILT_VERSIONS[tiltValue.hwVersion] + prevTempJson[color + 'SWVer'] = tiltValue.fwVersion + + # Clamp temp values + _temp = clamp(_temp, clampTempLow, clampTempHigh) + + # Convert to C if cc['tempFormat'] == 'C': - _temp = bc.convert( - _temp, 'F', 'C') - - prevTempJson[color + - 'Temp'] = round(_temp, 2) - prevTempJson[color + - 'SG'] = round(tiltValue.gravity, 3) - prevTempJson[color + - 'Batt'] = round(tiltValue.battery, 3) + _temp = bc.convert(_temp, 'F', 'C') + + # Clamp SG Values + _grav = clamp(tiltValue.gravity, clampSGLower, clampSGUpper) + + if prevTempJson[color + 'HWVer'] == 4: + prevTempJson[color + 'SG'] = round(_grav, 4) + prevTempJson[color + 'Temp'] = round(_temp, 2) + else: + prevTempJson[color + 'SG'] = round(_grav, 3) + prevTempJson[color + 'Temp'] = round(_temp, 1) + + prevTempJson[color + 'Batt'] = round(tiltValue.battery, 2) else: - prevTempJson[color + - 'Temp'] = None - prevTempJson[color + - 'SG'] = None - prevTempJson[color + - 'Batt'] = None + logError("Failed to retrieve {} Tilt value, restarting Tilt.".format(color)) + initTilt() + prevTempJson[color + 'HWVer'] = None + prevTempJson[color + 'SWVer'] = None + prevTempJson[color + 'Temp'] = None + prevTempJson[color + 'SG'] = None + prevTempJson[color + 'Batt'] = None # Expire old BB keypairs if (time.time() - lastBbApi) > timeoutBB: @@ -1742,7 +1773,6 @@ def shutdown(): # Process a graceful shutdown global tilt global thread global serialConn - global conn try: bgSerialConn # If we are running background serial, stop it @@ -1815,4 +1845,3 @@ def main(): # execute only if run as a script main() sys.exit(0) # Exit script - From 88d14d560989919598c371c6aee7f0d21d07a412 Mon Sep 17 00:00:00 2001 From: lbussy Date: Mon, 21 Dec 2020 15:34:14 -0600 Subject: [PATCH 22/22] Add clamping, update Tiltbridge --- Tilt.py | 0 brewpi.py | 223 ++++++++++++++++++++++++++++++------------------------ 2 files changed, 126 insertions(+), 97 deletions(-) mode change 100644 => 100755 Tilt.py diff --git a/Tilt.py b/Tilt.py old mode 100644 new mode 100755 diff --git a/brewpi.py b/brewpi.py index da6fa1e..0447dea 100755 --- a/brewpi.py +++ b/brewpi.py @@ -143,6 +143,12 @@ lastTiltbridge = 0 timeoutTiltbridge = 300 +# Clamp values for Tilt +clampSGUpper = 1.175 +clampSGLower = 0.970 +clampTempHigh = 110.0 +clampTempLow = 25.0 + # Keep track of time between new data requests prevDataTime = 0 prevTimeOut = 0 @@ -171,6 +177,8 @@ statusType = ['N/A', 'N/A', 'N/A', 'N/A'] statusValue = ['N/A', 'N/A', 'N/A', 'N/A'] +TILT_VERSIONS = ['Unknown', 'v1', 'v2', 'v3', 'Pro', 'v2 or 3'] + def getGit(): # Get the current script version @@ -335,8 +343,8 @@ def setUpLog(): # Set up log files sys.stderr = Unbuffered(open(logPath + 'stderr.txt', 'a+')) # Overwrite stdout, unbuffered sys.stdout = Unbuffered(open(logPath + 'stdout.txt', 'w+')) - # Start the logs - logError('Starting BrewPi.') # Timestamp stderr + # Start the logs + logError('Starting BrewPi.') # Timestamp stderr if logToFiles: # Make sure we send a message to daemon print('Starting BrewPi.', file=sys.__stdout__) @@ -588,13 +596,18 @@ def initTilt(): # Set up Tilt if not checkBluetooth(): logError("Configured for Tilt but no Bluetooth radio available.") else: - tilt = Tilt.TiltManager(config['tiltColor'], 300, 10000, 0) + if tilt: + tilt.stop() + tilt = None + tilt = Tilt.TiltManager(config['tiltColor'], 60, 10, 0) tilt.loadSettings() tilt.start() # Create prevTempJson for Tilt prevTempJson.update({ - config['tiltColor'] + 'Temp': 0, + config['tiltColor'] + 'HWVer': 0, + config['tiltColor'] + 'SWVer': 0, config['tiltColor'] + 'SG': 0, + config['tiltColor'] + 'Temp': 0, config['tiltColor'] + 'Batt': 0 }) @@ -703,6 +716,11 @@ def startLogs(): # Log startup messages urllib.parse.unquote(config['beerName']) + ".'") +def clamp(raw, minn, maxn): + # Clamps value (raw) between minn and maxn + return max(min(maxn, raw), minn) + + def startSerial(): # Start controller global config global serialConn @@ -729,11 +747,9 @@ def startSerial(): # Start controller logMessage("very old version of BrewPi. Please upload a new version") logMessage("of BrewPi to your controller.") # Script will continue so you can at least program the controller - lcdText = ['Could not receive', 'ver from controller', - 'Please (re)program', 'your controller.'] + lcdText = ['Could not receive', 'ver from controller', 'Please (re)program', 'your controller.'] else: - logMessage("Found " + hwVersion.toExtendedString() + - " on port " + serialConn.name + ".") + logMessage("Found " + hwVersion.toExtendedString() + " on port " + serialConn.name + ".") if LooseVersion(hwVersion.toString()) < LooseVersion(compatibleHwVersion): logMessage("Warning: Minimum BrewPi version compatible with this") logMessage("script is {0} but version number received is".format( @@ -769,10 +785,22 @@ def startSerial(): # Start controller except KeyboardInterrupt: print() # Simply a visual hack if we are running via command line logMessage("Detected keyboard interrupt, exiting.") - shutdown() - sys.exit(0) + + except RuntimeError: + logError(e) + type, value, traceback = sys.exc_info() + fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] + logError("Caught a Runtime Error.") + logError("Error info:") + logError("\tError: ({0}): '{1}'".format( + getattr(e, 'errno', ''), getattr(e, 'strerror', ''))) + logError("\tType: {0}".format(type)) + logError("\tFilename: {0}".format(fname)) + logError("\tLineNo: {0}".format(traceback.tb_lineno)) + logMessage("Caught a Runtime Error.") except Exception as e: + logError(e) type, value, traceback = sys.exc_info() fname = os.path.split(traceback.tb_frame.f_code.co_filename)[1] logError("Caught an unexpected exception.") @@ -782,9 +810,7 @@ def startSerial(): # Start controller logError("\tType: {0}".format(type)) logError("\tFilename: {0}".format(fname)) logError("\tLineNo: {0}".format(traceback.tb_lineno)) - logMessage("Caught an unexpected exception, exiting.") - shutdown() - sys.exit(1) + logMessage("Caught an unexpected exception.") def loop(): # Main program loop @@ -818,6 +844,11 @@ def loop(): # Main program loop global tilt global tiltbridge global ispindel + # Clamp values for Tilt + global clampSGUpper + global clampSGLower + global clampTempHigh + global clampTempLow bc = BrewConvert.BrewConvert() run = True # Allow script loop to run @@ -1138,11 +1169,9 @@ def loop(): # Main program loop if apiKey == "Brew Bubbles": # Received JSON from Brew Bubbles # Log received line if true, false is short message, none = mute if outputJson == True: - logMessage( - "API BB JSON Recvd: " + json.dumps(api)) + logMessage("API BB JSON Recvd: " + json.dumps(api)) elif outputJson == False: - logMessage( - "API Brew Bubbles JSON received.") + logMessage("API Brew Bubbles JSON received.") else: pass # Don't log JSON messages @@ -1184,12 +1213,10 @@ def loop(): # Main program loop # END: Process a Brew Bubbles API POST else: - logMessage( - "WARNING: Unknown API key received in JSON:") + logMessage("WARNING: Unknown API key received in JSON:") logMessage(value) # Begin: iSpindel Processing - # iSpindel elif checkKey(api, 'name') and checkKey(api, 'ID') and checkKey(api, 'gravity'): if ispindel is not None and config['iSpindel'] == api['name']: @@ -1274,31 +1301,43 @@ def loop(): # Main program loop # Set time of last update lastTiltbridge = timestamp = time.time() - _temp = api['tilts'][config['tiltColor']]['temp'] - if cc['tempFormat'] == 'C': - _temp = round( - bc.convert(_temp, 'F', 'C'), 1) - prevTempJson[config["tiltColor"] + - 'Temp'] = round(_temp, 1) - prevTempJson[config["tiltColor"] + 'SG'] = float( - api['tilts'][config['tiltColor']]['gravity']) - # TODO: prevTempJson[config["tiltColor"] + 'Batt'] = api['tilts'][config['tiltColor']]['batt'] - prevTempJson[config["tiltColor"] + - 'Batt'] = None + + # Convert to proper temp unit + _temp = 0 + if cc['tempFormat'] == api['tilts'][config['tiltColor']]['tempUnit']: + _temp = float(api['tilts'][config['tiltColor']]['temp']) + elif cc['tempFormat'] == 'F': + _temp = bc.convert(float(api['tilts'][config['tiltColor']]['temp']), 'C', 'F') + else: + _temp = bc.convert(float(api['tilts'][config['tiltColor']]['temp']), 'F', 'C') + + prevTempJson[config["tiltColor"] + 'Temp'] = _temp + prevTempJson[config["tiltColor"] + 'SG'] = float(api['tilts'][config['tiltColor']]['gravity']) + + # high_resolution: true + # sends_battery: true + # fwVersion: 0 + + # if (checkKey(api['tilts'][config['tiltColor']], 'HWVer')): + # prevTempJson[config["tiltColor"] + 'HWVer'] = int(api['tilts'][config['tiltColor']]['hwVersion']) + # if (checkKey(api['tilts'][config['tiltColor']], 'SWVer')): + # prevTempJson[config["tiltColor"] + 'SWVer'] = int(api['tilts'][config['tiltColor']]['fwVersion']) + + if (checkKey(api['tilts'][config['tiltColor']], 'weeks_on_battery')): + prevTempJson[config["tiltColor"] + 'Batt'] = int(api['tilts'][config['tiltColor']]['weeks_on_battery']) # END: Tiltbridge Processing else: - logError( - "Received API message, however no matching configuration exists.") + logError("Received API message, however no matching configuration exists.") except json.JSONDecodeError: - logError( - "Invalid JSON received from API. String received:") + logError("Invalid JSON received from API. String received:") logError(value) + except Exception as e: - logError( - "Unknown error processing API. String received:") + logError("Unknown error processing API. String received:") logError(value) + elif messageType == "statusText": # Status contents requested status = {} statusIndex = 0 @@ -1315,97 +1354,78 @@ def loop(): # Main program loop if checkKey(prevTempJson, 'bbbpm'): status[statusIndex] = {} statusType = "Airlock: " - statusValue = str( - round(prevTempJson['bbbpm'], 1)) + " bpm" + statusValue = str(round(prevTempJson['bbbpm'], 1)) + " bpm" status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'bbamb'): - # filter out disconnected sensors - if not int(prevTempJson['bbamb']) == -100: + if int(prevTempJson['bbamb']) > -127: status[statusIndex] = {} statusType = "Ambient Temp: " - statusValue = str( - round(prevTempJson['bbamb'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['bbamb'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'bbves'): - # filter out disconnected sensors - if not int(prevTempJson['bbves']) == -100: + if int(prevTempJson['bbves']) > -127: status[statusIndex] = {} statusType = "Vessel Temp: " - statusValue = str( - round(prevTempJson['bbves'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['bbves'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: Brew Bubbles Items # Begin: Tilt Items if tilt or tiltbridge: - # if not config['dataLogging'] == 'active': # Only display SG in status when not logging data - # Use as a check to see if it's online if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: if checkKey(prevTempJson, config['tiltColor'] + 'SG'): if prevTempJson[config['tiltColor'] + 'SG'] is not None: status[statusIndex] = {} statusType = "Tilt SG: " - statusValue = str( - prevTempJson[config['tiltColor'] + 'SG']) - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(prevTempJson[config['tiltColor'] + 'SG']) + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, config['tiltColor'] + 'Batt'): if prevTempJson[config['tiltColor'] + 'Batt'] is not None: if not prevTempJson[config['tiltColor'] + 'Batt'] == 0: status[statusIndex] = {} statusType = "Tilt Batt Age: " - statusValue = str( - round(prevTempJson[config['tiltColor'] + 'Batt'], 1)) + " wks" - status[statusIndex].update( - {statusType: statusValue}) + if round(prevTempJson[config['tiltColor'] + 'Batt']) == 1: + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Batt'])) + " wk" + else: + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Batt'])) + " wks" + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 - # and (statusIndex <= 3): if checkKey(prevTempJson, config['tiltColor'] + 'Temp'): if prevTempJson[config['tiltColor'] + 'Temp'] is not None: if not prevTempJson[config['tiltColor'] + 'Temp'] == 0: status[statusIndex] = {} statusType = "Tilt Temp: " - statusValue = str( - round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson[config['tiltColor'] + 'Temp'], 1)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: Tilt Items # Begin: iSpindel Items if ispindel is not None: - # if config['dataLogging'] == 'active': # Only display SG in status when not logging data if checkKey(prevTempJson, 'spinSG'): if prevTempJson['spinSG'] is not None: status[statusIndex] = {} statusType = "iSpindel SG: " - statusValue = str(prevTempJson['spinSG']) - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinSG'], 3)) + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'spinBatt'): if prevTempJson['spinBatt'] is not None: status[statusIndex] = {} statusType = "iSpindel Batt: " - statusValue = str( - round(prevTempJson['spinBatt'], 1)) + "VDC" - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinBatt'], 1)) + "VDC" + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 if checkKey(prevTempJson, 'spinTemp'): if prevTempJson['spinTemp'] is not None: status[statusIndex] = {} statusType = "iSpindel Temp: " - statusValue = str( - round(prevTempJson['spinTemp'], 1)) + tempSuffix - status[statusIndex].update( - {statusType: statusValue}) + statusValue = str(round(prevTempJson['spinTemp'], 2)) + tempSuffix + status[statusIndex].update({statusType: statusValue}) statusIndex = statusIndex + 1 # End: iSpindel Items @@ -1468,8 +1488,7 @@ def loop(): # Main program loop newData = json.loads(line[2:]) # Copy/rename keys for key in newData: - prevTempJson[renameTempKey( - key)] = newData[key] + prevTempJson[renameTempKey(key)] = newData[key] # If we are running Tilt, get current values if (tilt is not None) and (tiltbridge is not None): @@ -1480,23 +1499,35 @@ def loop(): # Main program loop tiltValue = tilt.getValue(color) if tiltValue is not None: _temp = tiltValue.temperature + prevTempJson[color + 'HWVer'] = TILT_VERSIONS[tiltValue.hwVersion] + prevTempJson[color + 'SWVer'] = tiltValue.fwVersion + + # Clamp temp values + _temp = clamp(_temp, clampTempLow, clampTempHigh) + + # Convert to C if cc['tempFormat'] == 'C': - _temp = bc.convert( - _temp, 'F', 'C') - - prevTempJson[color + - 'Temp'] = round(_temp, 2) - prevTempJson[color + - 'SG'] = round(tiltValue.gravity, 3) - prevTempJson[color + - 'Batt'] = round(tiltValue.battery, 3) + _temp = bc.convert(_temp, 'F', 'C') + + # Clamp SG Values + _grav = clamp(tiltValue.gravity, clampSGLower, clampSGUpper) + + if prevTempJson[color + 'HWVer'] == 4: + prevTempJson[color + 'SG'] = round(_grav, 4) + prevTempJson[color + 'Temp'] = round(_temp, 2) + else: + prevTempJson[color + 'SG'] = round(_grav, 3) + prevTempJson[color + 'Temp'] = round(_temp, 1) + + prevTempJson[color + 'Batt'] = round(tiltValue.battery, 2) else: - prevTempJson[color + - 'Temp'] = None - prevTempJson[color + - 'SG'] = None - prevTempJson[color + - 'Batt'] = None + logError("Failed to retrieve {} Tilt value, restarting Tilt.".format(color)) + initTilt() + prevTempJson[color + 'HWVer'] = None + prevTempJson[color + 'SWVer'] = None + prevTempJson[color + 'Temp'] = None + prevTempJson[color + 'SG'] = None + prevTempJson[color + 'Batt'] = None # Expire old BB keypairs if (time.time() - lastBbApi) > timeoutBB: @@ -1742,7 +1773,6 @@ def shutdown(): # Process a graceful shutdown global tilt global thread global serialConn - global conn try: bgSerialConn # If we are running background serial, stop it @@ -1815,4 +1845,3 @@ def main(): # execute only if run as a script main() sys.exit(0) # Exit script -