Fermentation management software designed with flexibility in mind. Models of different beer styles allow custom fermentation curves to be applied. Control relays for heating and/or cooling and monitor readings from instrumentation like temperature, specific gravity, or pH. Out-of-the box functionality for reading data from Google Sheets (including Brewometer/Tilt sheets), graphite, and firebase data stores. This software was written in python and is intended to be run from a Raspberry Pi device, but in theory could work on any machine with a Python (3) interpreter.
Run fermenator with its command-line script, as follows::
fermenator run
Installation is possible by cloning this git repository and installing as follows::
git clone prairiedogbeer/fermentator
pip install -e fermenator
Configuration is supported through either a text file or by binding to a configuration datastore, but in either case, a local configuration file is required to bootstrap the basic software and tell it where to look for the remainder of configuration. Remote configuration datastores are used to allow users to centrally administer configuration without having to edit text files and restart software on embedded devices every time they need to change the temperature or name of a beer.
Fermenator looks for bootstrap configuration in the following locations, in order, and the first file found will be used:
Configuration files are always in YaML format, and must always contain a
top-level bootstrap
key that tells the code where to find the remainder
of configuration. There are several types of configuration datastores that can
be specified with the type
key under bootstrap
. Finally, any
datastore-specific configuration must be applied under the config
sub-key.
Finally, the bootstrap configuration must be given a name
key, which is used
in log messages to make it more obvious who is doing what in the code.
The configuration example below represents a scenario where no external
configuration datastore is used, one beer is being monitored, and a
BrewConsoleFirebaseDS datastore is read for all beer-specific instrumentation
data. The DictionaryConfig
config datastore type is used and allows the user
to specify the entire configuration as sub-keys under config
rather than
relying on a separate datastore for configuration::
bootstrap:
name: brewconsoleconfig
type: DictionaryConfig
config:
version: 2017070604
managers:
Dark Strong:
config:
beer: PB0056
active_cooling_relay: CoolingRelay1
active_heating_relay: HeatingRelay1
polling_frequency: 60
active_heating: True
active_cooling: True
beers:
PB0056:
type: LinearBeer
config:
identifier: pfv01
datasource: brewconsole
original_gravity: 27.0
final_gravity: 4.0
start_set_point: 18
end_set_point: 25
tolerance: 0.3
data_age_warning_time: 1800
datasources:
brewconsole:
type: BrewConsoleFirebaseDS
config:
apiKey: <svc-acct-google-api-key>
authDomain: <foo>.firebaseio.com
databaseURL: https://<foo>.firebaseio.com
storageBucket: <foo>.appspot.com
serviceAccount: /path/to/a/service-acct/credentials.json
relays:
CoolingRelay1:
type: Relay
config:
gpio_pin: 4
duty_cycle: 0.5
cycle_time: 600
HeatingRelay1:
type: Relay
config:
gpio_pin: 5
duty_cycle: 0.5
cycle_time: 1800
Based on the example above, you may be able to get a general sense of the overall structure of the software -- bootstrap loads configuration, and configuration loads managers, beers, datasources, and relays. Managers manage beers and relays, beers require datasources. The details of how each of these work and are configured is outlined further below.
The following subsections describe the different types of configuration datastore objects and how to implement them.
FermenatorConfig
This class represents the basic API that all of the configuration classes
further below implement. You can't use FermenatorConfig in a working setup
directly, but you can use any of the following methods with all of the config
subclasses described below, such as DictionaryConfig.
- assemble() - read all the configuration data for relays, datasources, beers,
and managers, and assemble them into interrelated objects
- run() - start all Managers actively polling beers and check for configuration
updates every `polling_frequency` seconds (infinite loop)
- disassemble() - shut off all managed relays and deconstruct objects, freeing
memory
Generally speaking, if you are manually running fermenator from an interpreter
or your own python script, you need to only call `run()`, because it calls
`assemble` and `disassemble` throughout its normal routine, including on
KeyboardInterrupt or destruction.
DictionaryConfig
As mentioned above, the DictionaryConfig datastore type simply allows you to
specify object configuration directly as python dictionary data. When a
DictionaryConfig type is specified under bootstrap configuration, fermenator
assumes that the dictionary configuration that this object requires is found
in the config
bootstrap key, and it is passed directly into the config object
on instantiation. As such, DictionaryConfig objects are a run-time-only config
option, changing the config file after startup does not result in any changes in
runtime, so the entire program must be restarted if you change the config file.
GoogleSheetConfig
Google sheets are supported as simple configuration sources that allow the user
to log into a google spreadsheet remotely and turn up or down the temperature
of their beer, turn off active cooling, etc. Changes to google sheet data are
not atomic, so they are not recommended for production environments where
internally consistent configuration is critical.
The google sheet must have at least the following worksheets:
- Manager
- Beer
- DataSource
- Relay
Each worksheet should have three columns, with the first being `<type>_name`, so
for the DataSource sheet, the first column would be `datasource_name`. The
second column in each sheet should be titled 'key', and the third column should
be titled 'value'. For example, a Manger sheet may look like this:
================== ==================== ====================
manager_name key value
================== ==================== ====================
French Saison beer PB0053
French Saison active_cooling_relay CoolingRelay1
French Saison active_heating_relay HeatingRelay1
French Saison polling_frequency 300
French Saison active_heating TRUE
French Saison active_cooling TRUE
================== ==================== ====================
As you can see, the manager name must be repeated for every line of config
specific to that manager. Keys exactly match those in the dictionary config
example above and the Managers below. Values closely match the dictionary
example, but booleans in google sheets are all-caps.
When specifying a GoogleSheetConfig class, you must provide a config key called
`spreadsheet_id`, which contains the ID number of your google sheet (you can
pull it directly out of the URL, usually just before ``/edit``.)
The GoogleSheet base class used by GoogleSheetConfig requires a Google service
account in order to read the spreadsheet, no anonymous reading is supported at
this time. Creating a service account is out of the scope of this readme, but
you need to obtain a JSON credential file from Google and place it in a path
accessible to fermenator. Fermenator will search for the credentials file at
these locations:
- .credentials.json
- ~/.fermenator/credentials.json
- /etc/fermenator/credentials.json
The service account only requires read access to the sheet, and should be
authorized for the following scopes:
- 'https://www.googleapis.com/auth/spreadsheets.readonly',
- 'https://www.googleapis.com/auth/drive.readonly'
As with any configuration datastore, a `refresh_interval` may be supplied to
specify how often the configuration should be re-checked for updates. With
GoogleSheetConfig, the google drive API is checked for updates to the
spreadsheet. Whenever an update is found, the existing configuration and all
objects (Managers, Beers, etc) will be torn down and reconstructed based on
the latest sheet data.
Warning: GoogleSheetConfig doesn't allow for atomic changes to configuration. It is
possible that you could be half-way through updating configuration when new
objects are constructed, leading to errors in the software. It is
recommended that you update configuration in this order: Relays,
DataSources, Beers, Managers, and set fermenator to run under a manager
or shell script in an infinite loop, in case an exception causes it to
shut down. If you want a more robust remote configuration, try one of the
others below.
FirebaseConfig
This class implements configuration in a simple firebase key-value datastore.
Configuration must be found under a top-level key called config
, with a sub-
key called fermenator
. The next level down contains keys for:
Each of the keys above exactly match the structure found in the beginning of this section.
FirebaseConfig also requires information about how it will access the datastore,
via the following keys in the config
section of bootstrap::
bootstrap:
name: brewconsoleconfig
type: FirebaseConfig
config:
apiKey: <svc-acct-google-api-key>
authDomain: <foo>.firebaseio.com
databaseURL: https://<foo>.firebaseio.com
storageBucket: <foo>.appspot.com
serviceAccount: /path/to/a/service-acct/credentials.json
You may notice that these exactly match the config keys for
BrewConsoleFirebaseDS in the example at the start of this section. You can use
the same Firebase datastore to store configuration and for beer information
(temperature, gravity, pH, etc). If you do so, you can configure the datastore
once at the bootstrap level, then set the config
key to inherit
in later
datastore configuration (which also avoids placing information such as your
apiKey into a cloud-hosted firebase).
Another point to make here is that the service account credentials file must be specified here, rather than being automatically found on the filesystem. This may change in the future but for now that's the way it is.
Managers ask a beer, "do you require heating or cooling?", and the beer responds with a simple "yes" or "no" to each question. One manager manages one and only one beer.
Managers turn on and off relays for heating and cooling based on the answers
the beer gives, which are configured through the active_cooling_relay
and
active_heating_relaykeys. Managers do not need to be configured with both cooling and heating relays, simply omit the configuration key for one (or both) as desired. You can also enable or disable the relays through the boolean keys,
active_heatingand
active_cooling`, which is not very useful with a local
config file, but very useful with a central datastore that can be administered
online/remotely, where a brewmaster may want to shut off cooling entirely for
a while.
Managers run in the background and can be provided with a polling_frequency
,
in seconds, which specifies how often they should interrogate beers about their
need of cooling or heating, and in turn, how often they should turn on and off
relays based on those answers. There is no point setting this polling frequency
at a more frequent interval than the source data is being updated at, but it
shouldn't hurt anything if you do.
Managers always try to shut down any managed relays when they shut down.
Here is an example of a complete manager configuration, which sets the manager
name (Dark Strong), and provides config. The beer
key must match the name of
a Beer object defined elsewhere in the config::
Dark Strong:
config:
beer: PB0056
active_cooling_relay: CoolingRelay1
active_heating_relay: HeatingRelay1
polling_frequency: 60
active_heating: True
active_cooling: True
All the logic about whether or not a particular beer needs to be heated or cooled is contained within the beer, itself, rather than in managers. This enables us to create new models for types of beers that implement fermentation curves, diacetyl rests, etc, and simply apply/configure them to the individual beer being scrutinized. Beers must be provided with a datasource where they can look up their temperature, gravity, etc. The following types of beers are currently implemented:
Each are described in more detail below.
AbstractBeer
All beers descend from AbstractBeer and implement the same API as it defines.
AbstractBeer requires a name, and can be optionally provided with these config
arguments:
- data_age_warning_time: if the data read from the datastore is older than this
(in seconds), issue a warning as a log message [default: 30 mins]
- gravity_unit: Either 'P' for Plato or 'SG' for standard gravity units.
[default: P]
- temperature_unit: Either 'C' for Celcius or 'F' for Fahrenheit [default: C]
All beers implement the following methods:
- requires_heating(): returns True if the beer is too cold
- requires_cooling(): returns True if the beer is too hot
SetPointBeer
This class implements a simple approach to temperature control like what you'd find on an STC-1000. Given a set-point and a tolerance, the class tries to keep the beer around the set-point, turning on heating and cooling as required to keep the temp within the set point. This class has no hysteresis/smarts about overshoot of temperature due to heating and cooling, but can be extended.
Additional configuration arguments required by this class, beyond AbstractBeer:
LinearBeer
Based on a starting and final gravity values, as well as a starting and
an ending temperature, linearly ramp temperature on a slope.
For example, a beer starts at 25 plato and should finish at 5 plato,
for a 20 plato apparent attenuation. The brewmaster wants the beer to start
at 16 celcius and finish out at 20 celcius, for a 4 degree spread. On day 0,
with the beer at 25P, the beer will be held at 16 celcius. When the beer
reaches 20P, 1/4 of planned attenuation, it will be held at 17 celcius.
As the beer hits 15P, half way to attenuation, it will be at 18 celicus.
If the beer starts at a higher gravity than anticipated, the configured lower
starting point temperature will be applied. Same in the reverse direction. Thus,
at the end of fermentation, this class will behave more or less like a
:class:`SetPointBeer`.
Note: Nothing about this class requires that start_set_point is a lower temperature
than end_set_point. If you want to gradually cool a beer during the course of
fermentation, go for it.
This class supports the following config arguments in addition to those required
by AbstractBeer:
- original_gravity: Expected original extract/gravity in Plato or SG (depending
on gravity_unit)
- final_gravity: Expected final gravity in Plato or SG
- start_set_point: The temperature to start the beer at (at OG/OE)
- end_set_point: Temperature the beer should finish at (at FG/AE)
- tolerance: optional, defaults to 0.5 degrees, similar to SetPointBeer
DataSources
-----------
Datasources are just what they sound like, a place where some data is stored.
In fermenator, a datasource can be used to hold configuration, or it can be a
place where some other software writes information about beers such as gravity,
temperature, or pH. At the time of this writing, fermenator does not write to
any datastores, but it was designed with writing in mind. Eventually, datastores
will hold state information about whether or not relays are on or off, if beers
are in an alarm state, etc.
Various DataSource implementations are found in fermenator, and they are
described below.
DataSource
This is the abstract, base class that all datasources descend from. It defines the basic API. The abstract DataSource object doesn't require any config arguments, but it provides the following abstract methods:
FirebaseDataSource
Implementation of a DataSource that enables gets and sets against a Firebase
database. This class takes the same arguments as FirebaseConfig::
apiKey: <svc-acct-google-api-key>
authDomain: <foo>.firebaseio.com
databaseURL: https://<foo>.firebaseio.com
storageBucket: <foo>.appspot.com
serviceAccount: /path/to/a/service-acct/credentials.json
authDomain, databaseURL and storageBucket are all easily gleaned if you look at
your Firebase database web page. apiKey and serviceAccount must match up with a
valid Google service account that has been authorized to access your Firebase
database.
Methods are the same as DataSource, `set()` is not implemented.
BrewConsoleFirebaseDS
This datasource implements the FirebaseDataSource with additional logic that makes this class better for getting beer-specific data.
BrewConsoleFirebaseDS requires all of the config arguments as FirebaseDataSource as well as the following:
This class implements two new/important methods:
Both of these new methods return the data in dictionary form, like this::
{
'timestamp': Datetime(...),
'temperature': 19.6,
}
GraphiteDataSource
This class implements DataSource and facilitates reading data from a graphite
web UI via the json format.
Three additional configuration arguments are supported:
- url: (the base url to graphite)
- user: (optional, password is required if user is used)
- password: (optional)
The set method is not currently implemented, since sets in graphite occur
against a completely different service (carbon), which may exist on a totally
different server. Gets work as follows::
graphite = GraphiteDataSource(url='http://foo.bar.com')
graphite.get((path, to, the, data))
Data is returned in reverse-time-series order as a list of dictionaries, with
keys for `timestamp` (datetime object), and whatever else was requested.
GoogleSheet
A base class designed to allow a user to get data from a google sheets document. This class handles the authentication to the sheets API, but does not directly implement getters and setters for the data. Subclasses should be created for various spreadsheet formats to make getting and setting of data easy and performant based on the type of fetches required.
All gsheet interactions require OAUTH with a client credential file. This code is based on the concepts found here:
https://developers.google.com/sheets/api/quickstart/python
This class requires the spreadsheet_id
config argument, which directly refers
to the id found in the spreadsheet URL.
GoogleSheet implements a few useful methods:
get_sheet_range
but without row headersBrewometerGoogleSheet
This is the class that specifically implements reads from Brewometer/Tilt
Google sheets. As with GoogleSheet, you must provide a `spreadsheet_id`.
This class should implement get_gravity and get_temperature similar to
BrewConsoleFirebaseDS, but it doesn't right now. Don't use this class.
Relays
------
Relays are probably the simplest object to explain. They represent real-life,
actual relays, which have two states -- on or off. Nice and simple. There are
currently two types of relay object that you may be interested in, as follows.
Relay
This is the base class for all relays, and doesn't actually control any hardware,
but it is useful on its own for testing
purposes. It is recommended that you try getting things up and running with
this type of relay specified, initially, then after you observe the code working
and what it would do, switch the relay type to GPIORelay
, below. You can
specify all of the GPIORelay configuration and it won't cause errors applied to
this relay type.
Relay objects have no special configuration arguments, but they can accept any argument you pass to them, they will just be ignored. Relays expose four methods:
GPIORelay
Implement relay as a GPIO Device such as would be connected to a
Raspberry Pi. Adds support for duty cycling the relay rather than keeping
it running continuously in the on phase, which may be useful with hardware
capable of inducing rapid temperature changes in a short period of time
(where the user wants to slow down the temperature change).
These additional parameters are supported:
- gpio_pin: The GPIO pin number where a relay is connected
- duty_cycle: an optional floating point percentage of on time
- cycle_time: the total time for each duty cycle (on and off), optional
- active_high: whether sending a 1 to the gpio port should turn on the relay,
or not (defaults to True)
MCP23017Relay
Implements a :class:Relay
connected to a GPIO expansion IC, the
MC23017Y. The MC23017 sits on the I2C bus and implements a simple GPIO-like
interface.
Supports the following configuration arguments: