For my project, I chose to install Android Things OS on my Raspberry Pi instead of Raspbian. This post describes the structure of my Android app that runs on it. I recommend reading my post on Android Things if you’re curious about the differences between the two operating systems and why I chose Android.
Role of the Sensor Hub
The job of the Sensor Hub is to collect temperature data and to send it to the cloud whenever I'm cooking something. I use the mobile app to start a cooking session in the Firebase Realtime Database, and the Sensor Hub responds by taking data from the sensors according to that configuration. It then sends data events to the database.
In order for the Sensor Hub app to be able to respond quickly, it needs to constantly listen for database changes. Since my Raspberry Pi is running Android, I can use the Firebase SDK for Android just like I do in the mobile app. Whereas the mobile app listens for database updates only while it's in the foreground, my Sensor Hub needs to listen for updates all the time. In order to ensure the Sensor Hub app is always running, I needed to create what is known as a Foreground Service in Android, which is guaranteed not to be killed by the operating system.
The first thing the Sensor Hub service does when it starts up for the first time is register itself in the database. It generates a random ID for itself and persists this in SharedPreferences, which is the standard Android local key/value store. It then creates a device registration object and writes this to the database. If the device already has an ID in SharedPreferences when the service is started, it knows that it has already registered and doesn't need to do so again. The device registration object includes the ID, a name, and the capabilities of the device, such as how many input channels it has and what type of data it can collect. As soon as a Sensor Hub is registered, the mobile app can read the configuration and create a session using its sensors. This is how any Sensor Hub can advertise itself as a potential data source. Since it talks directly to only the database, it can be anywhere in the world on any network, and I could include its data in my cooking session.
"-KzvLiaqYMCHTQ0nXR4L": {
"capabilities": {
"channelCount": 4,
"conversionFunctions": {
"TEMPERATURE_PROBE_THERMOWORKS_5600_OHM_F": {
"description": "Converts the output of a Thermoworks Pro Series probe to Fahrenheit. Assumes voltage is across a 5.6KΩ resistor in a voltage divider with the thermistor.",
"name": "ThermoWorks Pro Series Thermistor (F)"
}
}
},
"description": "Sensor Hub that lives on the back porch",
"name": "Back Porch"
}
Figure 1. An example device registration object that specifies the number of channels and conversion functions it supports, and some other identifying information.
Data Streams
Part of the mobile app’s job, when creating a new session, is creating data stream configurations. A data stream configuration is a directive to a specific Sensor Hub to capture data from a specific channel, and it specifies how often to read the sensor, how to convert the raw ADC value into a meaningful (usually Fahrenheit temperature) value, and where in the database to write this data. It also has a field that allows the data stream to be paused or stopped.
{
"channel": 1,
"measurementIntervalMs": 15000,
"conversionFunction": "TEMPERATURE_PROBE_THERMOWORKS_5600_OHM_F",
"sessionId": "-L7rtqOQYbiUtcqoTs-_",
"dataStreamId": "-L7rtqOVFHWkUW7HZ2Jg",
"paused": true
}
Figure 2. An example of a data stream configuration object that would be written to the "activeDataStreams" list in the database, nested under a Sensor Hub.
Each channel that is defined by the session gets its own data stream configuration, and these configurations are nested under the particular Sensor Hub configuration that hosts the corresponding sensor. This allows a single session to receive any data from any number of Sensor Hubs, and multiple sessions can be receiving data from different sets of channels concurrently.
Consider this example situation. I could be smoking some pork in the back yard in my smoker, and my wife could be grilling some steak on the front porch at the same time. The smoker can have an air temperature probe and a meat-penetrating probe, and both probes would be connected to a Sensor Hub in the back yard. The steak could have a meat-penetrating probe, and another air temperature probe could be measuring the ambient outdoor temperature. Both of these sensors would be connected to another Sensor Hub on the front porch. If each of us had our own cooking session configured on our own phones, it would be possible for me to combine temperature data from both of the sensors in the smoker with the ambient temperature from the front porch into a single chart. And, my wife’s cooking session could include both the steak temperature and the outdoor temperature on her chart.
Figure 3. A possible data stream configuration where multiple Sensor Hubs are streaming data to multiple sessions.
Data Stream Generators
Under the hood, the Sensor Hub service is essentially managing a group of what I call data stream generators. A data stream generator is responsible for generating the single stream of data that flows from one sensor to one session. A generator reads from one channel of the ADC (see my post on this) periodically, converts the ADC value to a temperature value (see my post on this), and writes it to a location in the database. The channel, interval, and database location are specified by a data stream configuration. The Sensor Hub service listens for updates to its list of data stream configurations in the database, and whenever a new configuration is added, the service starts a generator with this configuration, and the data starts flowing. Whenever a data stream configuration is deleted or goes into the paused or stopped state, the corresponding generator is stopped.
A data stream generator essentially runs in a loop. To obtain one data point, it initiates several read operations in a row on the ADC and averages the values together. Once the generator has this averaged value, it converts it to a temperature value using the function I derived from the characteristics of my thermistor. Finally, the generator hands the data point back to the service and schedules itself to run again in 15 seconds. The service then writes the data point to the database and evaluates alarm conditions.
Figure 4. Sensor Hub data flow architecture.
Alarms
The alarm definitions for a specific data stream are created by the mobile app and stored in the database (see my previous post about this process). When a generator produces a value, the service checks this value against the conditions of all alarms attached to the data stream. It then writes the alarm’s “active” state in the database, and other system components, such as the Cloud Function, can observe this value. For example, if I want to be notified when my brisket has finished smoking, I would set an alarm on the Meat Temperature data stream that will become active when the temperature exceeds 192°F. When the data stream generator produces a value below 192°F, the alarm conditions will not be met, and the service will write “false” to the “active” state of the alarm. It will do this every time a value below 192 is produced. When the temperature rises to 193°F, the service will receive this data point and again check it against the alarm conditions, and this time it will write “true” to the “active” property. It’s possible that a temperature could oscillate just above and below an alarm threshold, so I may add some logic in later that follows some rule like “set the value to true if the temperature exceeds the threshold for X minutes.”
Alarm Activation
An important decision I made was where the code should run that checks and activates alarms. Since I'm using a Cloud Function to observe and push out alarm events, at first I thought it would make sense to do it there. If this were the case, then the Cloud Function would have to be called once for every new data point. This is a lot of Function calls, as a long cooking session will generate thousands of data points. Firebase charges money for every Cloud Function call you make using their platform, so this option potentially could be monetarily expensive. It’s not actually going to break the bank: Firebase has a free tier, so you aren't charged for the first 125,000 invocations per month, and after that it's only $0.40 per 1 million invocations. I’m not going to reach anything near this number of calls, but I can still avoid this potential cost altogether by designing my system differently.
Besides the cost, evaluating alarm conditions in the cloud could be problematic from a user experience standpoint as well. If a Sensor Hub goes offline for a while due to a network outage, it will continue to collect data points and store them locally until the network is restored. When it comes back online, the Firebase SDK will sync the new data points to the cloud all at once. If the Cloud Function then evaluated the alarms for each of these data points, it's possible that an alarm would be activated for an event that occurred far in the past, and it may rapidly toggle between active and inactive as the events are being processed. Additionally, Firebase may decide to run multiple instances of the Function in parallel in order to handle the influx of of data, which would cause further confusion and possibly duplicated alarms.
Instead of processing the alarms in the cloud, I decided that the Sensor Hub should do it instead, since it can evaluate an alarm condition immediately when the data is generated. It then only needs to keep the most recent state of the alarm up to date. If it goes offline for a while and then comes back, the Cloud Function won't trigger outdated or duplicate alarms because it’s not replaying the series of temperature events. Instead, it needs to process only the most recent alarm state. This means the Cloud Function only gets triggered when the alarm state actually changes, instead of once per data point, saving thousands of invocations and false alarms.
Vent Control
In addition to hosting temperature sensors, the Sensor Hub also hosts a servo, which can be told to move to a certain position. The Sensor Hub service listens to a control value in the database. Whenever this value changes, the Sensor Hub moves the servo accordingly. The value can be set by any device that can write to the database, such as the mobile app. For my smoker, I’m using the servo to control the air intake vents anywhere between 0% open to 100% open. I don’t need to control each of the three vents independently, so all of the vents will move at the same time to the same position. If I receive an alarm telling me that the air temperature is too low, I can open the app and tell the servo to open the air vents without having to walk out to the back yard and do it manually! In the future, I may add a feedback loop into the Sensor Hub service so that it can automatically open or close the air vents to maintain a consistent temperature with no human involvement at all. Measuring the dynamics of the cooker and tuning a PID controller is another project in itself, I think.
Controlling a Servo with a Raspberry Pi
To control a standard servo, you need to send it a digital signal, which is compatible with a Pulse-Width-Modulation (PWM) signal. The Raspberry Pi 3 has two pins that are capable of PWM, so this is pretty straightforward, especially when you use the Android Things peripheral API. A standard servo expects to receive one pulse every 20ms, and it expects the width of the pulse to range between 1ms and 2ms. If the pulse lasts 1ms, the servo will move all the way to one edge of its range, and if the pulse lasts 2ms, it will move all the way to the other edge. If the pulse is exactly 1.5ms wide, then it will move to the center of its range, and so on.
The Android Things PWM API takes parameters in the form of frequency and duty cycle, so this means that I need to use a constant frequency of 50Hz to achieve 20ms pulse intervals, and I need to manipulate the duty cycle to control the width of the pulses. 1ms is exactly 5% of 20ms, and 2ms is a 10%, so I will be setting the duty cycle of the PWM to values between 5% and 10% to control my servo. For example, if I want the vents to be open 50%, then I need the pulse to be 1.5ms, which corresponds to a duty cycle of 1.5ms / 20ms = 7.5%. If I want the vents to be open 30%, then I need the pulse to be 30% of the way between 1ms and 2ms, which is 1.3ms, so the duty cycle would be 1.3ms / 20ms = 6.5%.
public void setFractionOpen(double fractionOpen) {
final double PWM_PERIOD_MS = 20.0;
final double PULSE_WIDTH_RANGE_MS = 1.0; // 2ms - 1ms = 1ms
double pulseWidthMs = 1.0 + fractionOpen * PULSE_WIDTH_RANGE_MS;
double dutyCycleFraction = pulseWidthMs / PWM_PERIOD_MS;
pwmOutput.setPwmFrequencyHz(1000 / PWM_PERIOD_MS);
pwmOutput.setPwmDutyCycle(dutyCycleFraction * 100); // convert fraction to percent
}
Figure 5. Code snippet showing the duty cycle calculation for PWM control of my servo. The real code has some error handling and other logic for reversing the direction.
PCB Update
My PCBs have arrived from OSH Park, and they look great! I really like the purple color, and the little Pi Chef logo looks neat on the top surface.
Figure 6. Photo of manufactured PCB next to the paper printout I used to test-fit my components.
I soldered all of the components on and mounted it on the Raspberry Pi. It fit perfectly. I held my breath and plugged it in for the first time... No sparks, no smoke, no smells... It works!
Figure 7. Photo of my Sensor Hub PCB mounted and measuring temperatures. I populated only two channels because I have only two probes. If I get more, I can easily add the other channels.
My Sensor Hub is nearly complete! The final step will be to see if I can add an NFC board to it to ease the WiFi setup process.