Skip to content

Commit

Permalink
Release Tilt Monitor changes
Browse files Browse the repository at this point in the history
  • Loading branch information
thorrak authored Oct 25, 2018
2 parents 4924c65 + cb332f8 commit 841969f
Show file tree
Hide file tree
Showing 10 changed files with 273 additions and 23 deletions.
9 changes: 4 additions & 5 deletions app/device_forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
import pytz
import random

class DeviceForm(forms.Form):

class DeviceForm(forms.Form):
device_name = forms.CharField(max_length=48, help_text="Unique name for this device",
widget=forms.TextInput(attrs={'class': 'form-control', 'placeholder': 'Device Name'}))

Expand All @@ -27,7 +27,6 @@ class DeviceForm(forms.Form):
help_text="Type of connection between the Raspberry Pi and the hardware",
widget=forms.Select(attrs={'class': 'form-control'}))


useInetSocket = forms.BooleanField(required=False, initial=True,
help_text="Whether or not to use an internet socket (rather than local)")

Expand Down Expand Up @@ -194,9 +193,9 @@ class SensorFormRevised(forms.Form):
# Not sure if I want to change 'invert' to be a switch or a dropdown
# invert = forms.BooleanField(required=False, widget=forms.CheckboxInput(attrs={'data-toggle': 'switch'}))

calibration = forms.DecimalField(label="Temp Calibration Offset", required=False, initial=0.0,
help_text="The temperature calibration to be added to each reading (in case "
"your temperature sensors misread temps)")
calibration = forms.FloatField(label="Temp Calibration Offset", required=False, initial=0.0,
help_text="The temperature calibration to be added to each reading (in case "
"your temperature sensors misread temps)")

address = forms.CharField(widget=forms.HiddenInput, required=False)
pin = forms.CharField(widget=forms.HiddenInput)
Expand Down
16 changes: 16 additions & 0 deletions docs/source/develop/changelog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,22 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/) because it was the first relatively standard format to pop up when I googled "changelog formats".



[2018-10-24] - Tilt Monitor Refactoring
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Changed
---------------------

- The Tilt Hydrometer monitor now uses aioblescan instead of beacontools for better reliability
- Added support for smaller screen sizes

Fixed
---------------------

- Tilt Hydrometers will now properly record temperatures measured in Celsius


[2018-08-05] - Gravity Refactoring
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
4 changes: 2 additions & 2 deletions docs/source/develop/components.rst
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ In addition to Django, this app utilizes a number of Python packages. These pack
- `Apache Public License v2 <http://www.apache.org/licenses/LICENSE-2.0>`__
* - `pid <https://pypi.python.org/pypi/pid/2.1.1>`__
- `Apache Public License v2 <https://github.com/trbs/pid/blob/master/LICENSE>`__
* - `beacontools <https://github.com/citruz/beacontools>`__
- `MIT (Expat) <https://github.com/citruz/beacontools/blob/master/LICENSE.txt>`__
* - `aioblescan <https://github.com/frawau/aioblescan>`__
- `MIT (Expat) <https://github.com/frawau/aioblescan/blob/master/LICENSE.txt>`__


JavaScript Packages
Expand Down
55 changes: 47 additions & 8 deletions gravity/tilt/TiltHydrometer.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import datetime
from typing import List, Dict, TYPE_CHECKING
from beacontools import IBeaconAdvertisement
# from beacontools import IBeaconAdvertisement
from collections import deque

from gravity.models import TiltConfiguration, GravityLogPoint
from gravity.models import TiltConfiguration, GravityLogPoint, GravitySensor


class TiltHydrometer(object):
Expand All @@ -22,6 +22,7 @@ class TiltHydrometer(object):

# color_lookup is created at first use in color_lookup
color_lookup_table = {} # type: Dict[str, str]
color_lookup_table_no_dash = {} # type: Dict[str, str]

def __init__(self, color: str):
self.color = color # type: str
Expand All @@ -34,9 +35,9 @@ def __init__(self, color: str):
self.last_value_received = datetime.datetime.now() - self._cache_expiry_seconds() # type: datetime.datetime
self.last_saved_value = datetime.datetime.now() # type: datetime.datetime


self.gravity = 0.0 # type: float
self.raw_gravity = 0.0 # type: float
# Note - temp is always in fahrenheit
self.temp = 0 # type: int
self.raw_temp = 0 # type: int
self.rssi = 0 # type: int
Expand All @@ -46,6 +47,11 @@ def __init__(self, color: str):
# Let's load the object from Fermentrack as part of the initialization
self.load_obj_from_fermentrack()

if self.obj is not None:
self.temp_format = self.obj.sensor.temp_format
else:
self.temp_format = GravitySensor.TEMP_FAHRENHEIT # Defaulting to Fahrenheit as that's what the Tilt sends

def __str__(self):
return self.color

Expand All @@ -54,6 +60,14 @@ def _cache_expiry_seconds(self) -> datetime.timedelta:
return datetime.timedelta(seconds=(self.smoothing_window * 1.2 * 4))

def _cache_expired(self) -> bool:
if self.obj is not None:
# The other condition we want to explicitly clear the cache is if the temp format has changed between what
# was loaded from the sensor object & what we previously had cached when the object was loaded
if self.temp_format != self.obj.sensor.temp_format:
# Clear the cached temp/gravity values &
self.temp_format = self.obj.sensor.temp_format # Cache the new temp format
return True

return self.last_value_received <= datetime.datetime.now() - self._cache_expiry_seconds()

def _add_to_list(self, gravity, temp):
Expand All @@ -74,20 +88,41 @@ def should_save(self) -> bool:

return self.last_saved_value <= datetime.datetime.now() - datetime.timedelta(seconds=(self.obj.polling_frequency))

def process_ibeacon_info(self, ibeacon_info: IBeaconAdvertisement, rssi):
self.raw_gravity = ibeacon_info.minor / 1000
# def process_ibeacon_info(self, ibeacon_info: IBeaconAdvertisement, rssi):
# self.raw_gravity = ibeacon_info.minor / 1000
# if self.obj is None:
# # If there is no TiltConfiguration object set, just use the raw gravity the Tilt provided
# self.gravity = self.raw_gravity
# else:
# # Otherwise, apply the calibration
# self.gravity = self.obj.apply_gravity_calibration(self.raw_gravity)
#
# # Temps are always provided in degrees fahrenheit - Convert to Celsius if required
# # Note - convert_temp_to_sensor returns as a tuple (with units) - we only want the degrees not the units
# self.raw_temp, _ = self.obj.sensor.convert_temp_to_sensor_format(ibeacon_info.major,
# GravitySensor.TEMP_FAHRENHEIT)
# self.temp = self.raw_temp
# self.rssi = rssi
# self._add_to_list(self.gravity, self.temp)

def process_decoded_values(self, sensor_gravity: int, sensor_temp: int, rssi):
self.raw_gravity = sensor_gravity / 1000
if self.obj is None:
# If there is no TiltConfiguration object set, just use the raw gravity the Tilt provided
self.gravity = self.raw_gravity
else:
# Otherwise, apply the calibration
self.gravity = self.obj.apply_gravity_calibration(self.raw_gravity)

self.raw_temp = ibeacon_info.major
# Temps are always provided in degrees fahrenheit - Convert to Celsius if required
# Note - convert_temp_to_sensor returns as a tuple (with units) - we only want the degrees not the units
self.raw_temp, _ = self.obj.sensor.convert_temp_to_sensor_format(sensor_temp,
GravitySensor.TEMP_FAHRENHEIT)
self.temp = self.raw_temp
self.rssi = rssi
self._add_to_list(self.gravity, self.temp)


def smoothed_gravity(self):
# Return the average gravity in gravity_list
if len(self.gravity_list) <= 0:
Expand All @@ -110,18 +145,22 @@ def smoothed_temp(self):

@classmethod
def color_lookup(cls, color):
if len(cls.color_lookup_table) >= 0:
if len(cls.color_lookup_table) <= 0:
cls.color_lookup_table = {cls.tilt_colors[x]: x for x in cls.tilt_colors}
if len(cls.color_lookup_table_no_dash) <= 0:
cls.color_lookup_table_no_dash = {cls.tilt_colors[x].replace("-",""): x for x in cls.tilt_colors}

if color in cls.color_lookup_table:
return cls.color_lookup_table[color]
elif color in cls.color_lookup_table_no_dash:
return cls.color_lookup_table_no_dash[color]
else:
return None

def print_data(self):
print("{} Tilt: {} ({}) / {} F".format(self.color, self.smoothed_gravity(), self.gravity, self.temp))

def load_obj_from_fermentrack(self, obj:TiltConfiguration = None):
def load_obj_from_fermentrack(self, obj: TiltConfiguration = None):
if obj is None:
# If we weren't handed the object itself, try to load it
try:
Expand Down
192 changes: 192 additions & 0 deletions gravity/tilt/tilt_monitor_aio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
#!/usr/bin/python

import os, sys
import time, datetime, getopt, pid
from typing import List, Dict
import asyncio
# import argparse, re
import aioblescan as aiobs


# In order to be able to use the Django ORM, we have to load everything else Django-related. Lets do that now.
BASE_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "fermentrack_django.settings") # This is so Django knows where to find stuff.
sys.path.append(BASE_DIR)
os.chdir(BASE_DIR) # This is so my local_settings.py gets loaded.
from django.core.wsgi import get_wsgi_application
application = get_wsgi_application()


from gravity.tilt.TiltHydrometer import TiltHydrometer
import gravity.models

# import django.core.exceptions



# Script Defaults
verbose = False # Should the script print out what it's doing to the console
pidFileDir = "/tmp" # Where the pidfile should be written out to
mydev = 0 # Default to /dev/hci0


def print_to_stderr(*objs):
print("", *objs, file=sys.stderr)


try:
opts, args = getopt.getopt(sys.argv[1:], "h:vp:d:l", ['help', 'verbose', 'pidfiledir=', 'device=', 'list'])
except getopt.GetoptError:
print_to_stderr("Unknown parameter. Available options: --help, --verbose, " +
"--pidfiledir <directory> --device <device number>", "--list")
sys.exit()


for o, a in opts:
# print help message for command line options
if o in ('-h', '--help'):
print_to_stderr("\r\n Available command line options: ")
print_to_stderr("--help: Print this help message")
print_to_stderr("--verbose: Echo readings to the console")
print_to_stderr("--pidfiledir <directory path>: Directory to store the pidfile in")
print_to_stderr("--device <device number>: The number of the bluetooth device (the X in /dev/hciX)")
print_to_stderr("--list: List Tilt colors that have been set up in Fermentrack")
exit()

# Echo additional information to the console
if o in ('-v', '--verbose'):
verbose = True

# Specify where the pidfile is saved out to
if o in ('-p', '--pidfiledir'):
if not os.path.exists(a):
sys.exit('ERROR: pidfiledir "%s" does not exist' % a)
pidFileDir = a

# Allow the user to specify an alternative bluetooth device
if o in ('-d', '--device'):
if not os.path.exists("/dev/hci{}".format(a)):
sys.exit('ERROR: Device /dev/hci{} does not exist!'.format(a))
mydev = a

# List out the colors currently configured in Fermentrack
if o in ('-l', '--list'):
try:
dbDevices = gravity.models.TiltConfiguration.objects.all()
print("=============== Tilt Hydrometers in Database ===============")
if len(dbDevices) == 0:
print("No configured devices found.")
else:
x = 0
print("Configured Colors:")
for d in dbDevices:
x += 1
print(" %d: %s" % (x, d.color))
print("============================================================")
exit()
except (Exception) as e:
sys.exit(e)


# check for other running instances of the Tilt monitor script that will cause conflicts with this instance
pidFile = pid.PidFile(piddir=pidFileDir, pidname="tilt_monitor_aio")
try:
pidFile.create()
except pid.PidFileAlreadyLockedError:
print_to_stderr("Another instance of the monitor script is running. Exiting.")
exit(0)


#### The main loop

# Create a list of TiltHydrometer objects for us to use
tilts = {x: TiltHydrometer(x) for x in TiltHydrometer.tilt_colors} # type: Dict[str, TiltHydrometer]
# Create a list of UUIDs to match against & make it easy to lookup color
uuids = [TiltHydrometer.tilt_colors[x].replace("-","") for x in TiltHydrometer.tilt_colors] # type: List[str]
# Create the default
reload_objects_at = datetime.datetime.now() + datetime.timedelta(seconds=15)


def processBLEBeacon(data):
# While I'm not a fan of globals, not sure how else we can store state here easily
global opts
global reload_objects_at
global tilts
global uuids

ev = aiobs.HCI_Event()
xx = ev.decode(data)

# To make things easier, let's convert the byte string to a hex string first
raw_data_hex = ev.raw_data.hex()

if len(raw_data_hex) < 80: # Very quick filter to determine if this is a valid Tilt device
return False
if "1370f02d74de" not in raw_data_hex: # Another very quick filter (honestly, might not be faster than just looking at uuid below)
return False

# For testing/viewing raw announcements, uncomment the following
# print("Raw data (hex) {}: {}".format(len(raw_data_hex), raw_data_hex))
# ev.show(0)

try:
# Let's use some of the functions of aioblesscan to tease out the mfg_specific_data payload
payload = ev.retrieve("Payload for mfg_specific_data")[0].val.hex()

# ...and then dissect said payload into a UUID, temp, gravity, and rssi (which isn't actually rssi)
uuid = payload[8:40]
temp = int.from_bytes(bytes.fromhex(payload[40:44]), byteorder='big')
gravity = int.from_bytes(bytes.fromhex(payload[44:48]), byteorder='big')
# tx_pwr = int.from_bytes(bytes.fromhex(payload[48:49]), byteorder='big')
# rssi = int.from_bytes(bytes.fromhex(payload[49:50]), byteorder='big')
rssi = 0 # TODO - Fix this
except:
return

if verbose:
print("Tilt Payload (hex): {}".format(payload))

color = TiltHydrometer.color_lookup(uuid) # Map the uuid back to our TiltHydrometer object
tilts[color].process_decoded_values(gravity, temp, rssi) # Process the data sent from the Tilt

# The Fermentrack specific stuff:
reload = False
if datetime.datetime.now() > reload_objects_at:
# Doing this so that as time passes while we're polling objects, we still end up reloading everything
reload = True
reload_objects_at = datetime.datetime.now() + datetime.timedelta(seconds=15)

for this_tilt in tilts:
if tilts[this_tilt].should_save():
tilts[this_tilt].save_value_to_fermentrack(verbose=verbose)

if reload: # Users editing/changing objects in Fermentrack doesn't signal this process so reload on a timer
tilts[this_tilt].load_obj_from_fermentrack()


event_loop = asyncio.get_event_loop()

# First create and configure a raw socket
mysocket = aiobs.create_bt_socket(mydev)

# create a connection with the raw socket (Uses _create_connection_transport instead of create_connection as this now
# requires a STREAM socket) - previously was fac=event_loop.create_connection(aiobs.BLEScanRequester,sock=mysocket)
fac = event_loop._create_connection_transport(mysocket, aiobs.BLEScanRequester, None, None)
conn, btctrl = event_loop.run_until_complete(fac) # Start the bluetooth control loop
btctrl.process=processBLEBeacon # Attach the handler to the bluetooth control loop

# Begin probing
btctrl.send_scan_request()
try:
event_loop.run_forever()
except KeyboardInterrupt:
if verbose:
print('Keyboard interrupt')
finally:
if verbose:
print('Closing event loop')
btctrl.stop_scan_request()
command = aiobs.HCI_Cmd_LE_Advertise(enable=False)
btctrl.send_command(command)
conn.close()
event_loop.close()
File renamed without changes.
2 changes: 1 addition & 1 deletion gravity/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
try:
# Bluetooth support isn't always available as it requires additional work to install. Going to carve this out to
# pop up an error message.
import beacontools
import aioblescan
bluetooth_loaded = True
except ImportError:
bluetooth_loaded = False
Expand Down
Loading

0 comments on commit 841969f

Please sign in to comment.