Project tutorial
Smart Dehydrator Cooking Appliance - Alexa Ready

Smart Dehydrator Cooking Appliance - Alexa Ready © GPL3+

A smart dehydrator that uses Home Skill API and low cost arduino to produce better dehydrated foods with less effort.

  • 714 views
  • 0 comments
  • 3 respects

Components and supplies

Necessary tools and machines

09507 01
Soldering iron (generic)

Apps and online services

About this project

About the project.

About two years ago I purchase a home dehydrator, I was always willing to make my own dehydrated fruits, herbs and why not jerky too. After using it for a while I perceived that the device was very dumb, it took more effort than I was willing to do on a regular basics to dehydrate food, specially the thing cannot be leave working for long time without human intervention as t easily over dry or even burn the food. Sometimes I found that high content water food such as bananas or pine apple dry best with a high temperature for a couple of hours, then temperature should be settle lower for a long period of time. Many people on the internet have found better to dehydrate overnight, which makes the above situation not possible. Anyway last but not least because I am sort of nerd about taking things apart, I was anxious to modify my dehydrator to improve the whole process.

Arduino fits here very well.

I start working with a little arduino module, the Mini, it happen that there is an official arduino Mini 05 which is ideally the one I should be using but the little thing is somehow difficult to find so I did my tests using Arduino Pro which is pin compatible module.

This little arduino is well suited for the project since it goes well in price compared to my 70$ dehydrator, a more capable arduino can speed up things but it was not going to be the right choice, basically because I was also hoping to make this solution available for public. I end up taking the difficult road, solving some problems the best way in favor of budget but with consideration to make the solution attractive to hobbyists as well as engineers out there.

About the solution.

Apart from using arduino, the project has several parts that were addressed one after the other, from basci to more complicated and layer by layer.

The arduino Mini is a little device which is very powerful but it has some limitations, specially in terms of connectivity. The first problem I face was about setting up a development environment, remember this is a dehydrator and the way to test it is by putting it to work (with or without food but with trays and everything in place). Here is a picture of the Turbo Dehydrator made by Ronco.

The idea of normal arduino development flow, that is, write some sketch, load onto arduino using serial port, see results and repeat as necessary and the idea that I need the arduino circuitry inside above thing was generating a lot of noise in my head even before I start working seriously on the project.

But not only that, at that time I was also thinking on how I suppose to make the whole thing smarter. One thing was clear to me, I want everything on the cloud, been that my local home network or the internet. How I was going to connect my little arduino to the local network. I came up with the idea to use the ESP8266 for that. The main reason is the price and community support for it.

But wait a second dude, did you said arduino Mini with ESP8266? If you have work with this arduino module, you will fast realize it has only one hardware serial port, so at the end I cannot put "the circuit" inside the dehydrator and forget about it since I need to connect the arduino IDE using serial port to program arduino as well as esp8266. This is exactly one thing I solve for this project, to phrase it.

"I can put both things inside the dehydrator, close it and develop arduino code as well as esp8266 code in a Over the Air fashion mode, even with dehydrator sitting on my kitchen"

I separate the next two problems in different stages, the control involves playing literally with fire, I really don't like too much mains levels so I decide to leave it to the end when I got a robust setup. I decide to work on the acquisition first, which is not only acquire sensor signal but also present them to user, that way I can make my code robust before attempting to play with fire kid.

The original dehydrator sensor/control solution is merely a mechanical thermostat attached to a knob as seen in previous picture. Designers leave an small aperture from heat exhaust to direct some of that hot air goes directly to the thermostat, which is actually cleaver and perhaps I will go ahead now and let you know that temperature differences without food are minimal when measuring at the thermostat location and inside the trays area.

Notice the black cable on above picture? The picture is actually from my final setup (notice thermostat is disconnected), the cable is the temperature probe, I am using DS18B20 sensor, the one that comes with a wire already assembly.

Apart from temperature inside the unit, which I put there as first attempt to reassemble original product, I was willing to measure also humidity which is a good indicator of food water content. Here I haven't come to what a "final solution" sensor should use, but for my test I pick up one I have at hand and actually very good but also pricey (bad for a product). The SHT15 sensor from sparkfun. One thing is for sure is that I need a digital sensor since I need to wire up the thing from the inside to the tray area. Below is a picture of how it was done.

Originally dehydrator has a plate and plastic cover that screw there using two screws, so I manually fabricate another plastic cover with a cylinder to have the SHT15 sensor sort of encapsulated. Below are some pictures that clarify it better.

SHT Assy.

I was afraid the heat can melt the wires, because those wires I got form my "electronic garbage". Actually the plastic enclosure and trays of dehydrated start to shown some damage for usage, specially after I did some jerky with temperature set at 70 Celsius for 5 hour :(

In order to protect them I took some cooper tape I have at hand to help dissipate heat as shown below.

After I solve all mechanical and interface things, and as project evolve, eventually I end up developing the heater control. For that I used a TRIAC that arduino used to phase control the current, the circuit uses isolation devices to meet UL safety standard of a typical cooking appliance. In order to control the fan I used a small relay. This allow me to turn fan on even without heater on, and actually turn the whole thing OFF which was an annoying lacking feature of this unit, even if the know said OFF to the most left position, there is no switch that turn thing off.

Some of the circuit I wired using a breadboard (arduino + PS + ESP-01). The AC power control board was soldered on a universal PCB board for this first prototype stage. The circuit takes power from the AC mains using a laminated transformer with rectifier bridge. Here is a picture of the complete setup.

Notice the usage of tie wraps to make the things fixed and safe! Actually I have use the unit as shown from them on until one day I finish the second prototype PCB board.

A closer look will reveal details of the wiring, of course those will be addressed in next sections.

Hardware and Firmware details.

This section will cover the hardware as well as firmware of the Arduino.

Sensors.

Temperature is acquired using DS18B20 and SHT15 sensor. Both sensors are read continuously, depending on the sensor there are some constrains in how fast the sensor can be read, or better say how long it will take to have a sample ready for arduino, DS18B20 is around 750ms and STH15 is faster been worse case a 14 bit accuracy sample around 320ms. The code can implement different times by adjusting a define in code but we keep both around 1 second.

Both sensors has libraries available that we use on this project. At the time of this writing I have implemented a simple control using temperature from ds18b20 as the input, data from sht15 have serve only for visualization but eventually humidity will help to control the unit too.

Here is the snippet code that is called in arduino loop.

bool handle_dallas(int index){
 if(millis() - lastTempUpdate  > TEMP_READ_DELAY){
   temperature = temperatureSensors.getTempCByIndex(index);
   temperatureF = temperature * 1.8 + 32.0;
   root[ds_json_tag] = double_with_n_digits(temperature, 1);
   lastTempUpdate = millis();
   temperatureSensors.setWaitForConversion(false);
   temperatureSensors.requestTemperatures();
   return true;
 }
 return false;
}

temperature variable is of type float. For SHT15 we are saving the value directly in a json variable, the json facilitate the different values transmission to upper layers as will be show next. Here is the snippet for SHT15.

void handle_sht15(void){
 if(millis() - lastRHUpdate  > RH_READ_DELAY){
   // now get the sht15 sensor data
   readSensor();
   lastRHUpdate = millis();
   if(tempC < -40.0){
     sht15_json["error"] = "comms";
   }else{
     sht15_json["error"] = "noerr";
     sht15_json[sht15_temp_tag] = double_with_n_digits(tempC, 1);
     sht15_json[sht15_rh_tag] = double_with_n_digits(humidity, 1);
   }
   sht15_array.set(0, sht15_json);
 }
}

The code uses the following libraries (show as includes )

#include <DallasTemperature.h>
#include <OneWire.h>
#include <SHT1X.h> 
#include <ArduinoJson.h>

sht15_json is declared as

JsonObject& sht15_json = jsonBuffer.createObject();

Actuators.

The two actuators are the Relay and the Triac. The relay is really simple and it only needs an Output pin to control. The breadboard version nonetheless uses an Arduino Pro Mini, specifically the 3.3V version, which has some problem to interface the standard 5V relay board, for that reason I decide to include a buffer follower P/N SN74LVC1G125DBVR. This can be avoid in a second prototype that we can specify the relay to use.

The AC phase control is implemented in arduino software, it need an input from zero crossing AC mains, which is acquire by means of an AC input Optocoupler PN H11AA1.

The triac circuitry is implemented using an opto triac PN MOC3011 to control a larger Triac PN 2N6073BG, the schematic show the details, a better explanation about the circuit can be found here.

AC phase control was somehow difficult to get it work reliable since the conduction angle range should be established, as noted on AN1003 application note from littlefuse, the conduction angle range should be 30° to 150°. Here is a reference table.

Instead of trying to calculate an exact conduction angle of 30° for example, I start from the known fact that at 60 Hz AC mains frequency, a conduction angle of 180° or π is something like 8.333 ms after the zero crossing detection. Then 30° would be π/6 or around 1.38 ms, 150° would be 5π/6 or 6.94 ms. With those values in mind you are ready to follow me in the Arduino code.

The zero crossing signal from H11AA1 device is feed to an interrupt pin of arduino, then we can start a timer to measure above times and send the trigger signal to opto triac. The code uses timer1 which is a 16 bit timer, here is a good explanation of how to configure it. It's important to note that the code is for 3.3V part which is running at 8 MHz. Basically you do this

  • Configure Timer prescaler to 256. At 8 MHz that would give 8000000 / 256 = 31250
  • Divide 31250 through desired frequency, in this case we can image our frequency can be given by a period equal to above times of t30 and t150.
  • For 30° that would be 31250 * 0.00138 = 43
  • For 150° that would be 31250 * 0.00694 = 216

The Timer is configured during each zero crossing ISR and the reason of this is that we want to also produce a short trigger pulse, which means we need to set the output low some time after the trigger pulse, so the triac will self turn off in the next crossing detection, and the process will repeat.

Here is the code at Zero crossing ISR

void zeroCrossingInterrupt(){  // zero cross detect
 cli();
 if(heater_en){
   OCR1A = tc_value;
   // turn on CTC mode:
   TCCR1B |= (1 << WGM12);
   // Set CS10 for 256 prescaler
   TCCR1B |= (1 << CS12);
   TIMSK1 = 0x03;    //enable comparator A and overflow interrupts
   TCNT1 = 0;   //reset timer - count from zero
 }
 sei();
 toggle = toggle ^ 1;
 digitalWrite(11, toggle);
}

Notice the OCR1A value is a variable since we want to adjust it dynamically as necessary. The limits are defined in code like this

#define TC_OUT_MIN  40        // Minimum Value "97% AC Phase" Control.
#define TC_OUT_MAX  220       // Maximum Value "3% AC Phase" Control.

This way we can avoid going out of this limits. The ISR of Timer1 would be

ISR(TIMER1_COMPA_vect){     //comparator match
 digitalWrite(GATE,HIGH);  //set TRIAC gate to high
 TCNT1 = 65536-PULSE;      //trigger pulse width
}
ISR(TIMER1_OVF_vect){     //timer1 overflow
 digitalWrite(GATE,LOW); //turn off TRIAC gate
 TCCR1B = 0x00;          //disable timer stopd unintended triggers
}

In the case where the count match our previously programmed value TIMER1_COMPA_vect ISR will fire, we set the gate output pin high and then we set some predefined value to Timer count, which will cause the Timer to fire TIMER1_OVF_vect ISR when counter overflow, and there we low the pulse. The predefined value was something I really setup during tests but is something around 0.4 ms.

Did you notice the use of D11 outout in zero crossing ISR, it's for debugging an square wave and see the gate pulse timing, you can see a couple of pictures below (I was lazy to use the USB snapshot feature of my scope...)

Not bad for a little arduino. The firmware works well but I have experience some issues when going from maximum AC phase (150°) to a lower value, the problem is a lost of AC phase control, I have handle this on the controller part of the code explained later.

Arduino - ESP-01 interface.

The hardest part of the arduino firmware was the AC phase control, but the interface with ESP-01 don't lag behind and actually without it this project could easily have become a nightmare. The reason for this is that I have handle to program the arduino using ESP-01, that is, I can upload the .hex file remotely using the WiFi connection of ESP-01.

Apart from the contribution of this to the arduino community, the approach have several advantages to this project, among them:

  • Arduino devices with only one serial port such as Mini 05 or Pro Mini can benefit from this, the serial port is used for programming and also for data transmission i.e. with a remote Host, in this case to transmit data values and configure device.
  • I can develop the dehydrator unit assembled without messy wires, and because of this when I reach some stable production code I can flash the arduino over the air, making this convenient even for a product release, in which you might offer upgrades, which is extremely important for Alexa activated products because as you might have experienced the API might change by i.e. a new skill capability addition, so you need the hardware adjust accordingly.
  • As a consequence pf last point, I can develop faster, since apart from avoiding to reconnect arduino to IDE to flash a firmware, I avoid reconnecting the serial interface between arduino and esp-01 so I can go and do a test faster.

Technical details of arduino - esp01 interface.

I was looking for this feature since I started the project, then I found this very nice project which was really helpful. Nonetheless I wasn't happy with part of the author implementation because of the following: ESP-01 is running micropython.

I was not willing to change micropython since that speed up things a lot here. So I decide to use part of the original work and port the rest to my esp-01 running micropython, and this was kind of painful since I got stuck in a bug while implemeting the STK500 protocol. This story end in a happy end so this is how it works.

  • Hex file server.

The original work idea of having a server of arduino hex files is preserved, the code has some little modifications, basically in the step that request the file transfer, which was somehow obscure to me and depends on some serial data that throw out at boot time the arduino.

  • micropython esp8266 client.

The client was implemented on micropython, hardwired values are used for IP and Port TCP server to connect with. You need to load this python script to running esp-01, then execute it by passing the name of the hex file to flash on arduino. The .hex file should be on same server working directory for this to work.

For example, the command to Flash firmware hex file "AC_Phase.ino.hex" in a client once you are in a REPL shell is:

arduino_programmer.run('AC_Phase.ino.hex')

Here you can find the git code for server and client.

Arduino temperature PID Control.

The control loop was implemented in arduino. It turns out that micropython on esp8266 works well but I have found it has some problems with reliability, I have seen it to reboot sporadically and unexpectedly when running for long period of time. I decide to use micropython as a bridge for arduino, it can parse back and forth data from an upper layer software such Alexa, it can speed up development things since it has good support and advance networking capabilities.

Arduino proves to be better stable on this project, and is basically dedicated to control the temperature of dehydrator while keeping in sync with esp-01 for handling any configuration command such a change in set point for example.

A PID controller is implemented with the help of an existing library for arduino, the PID was tuned by trial and error, if you want to know more about it the author gives you some tutorial on using the library. I actually review other existing PID libraries and pick this one since it has the ability to change the PID tuning parameters dynamically, which I am using since when error value is high I want the PID to be more aggressive, and when output is close to setpoint I want the PID to change the output in a smooth manner. The code for that is shown below.

 if(unit_on_off){
   double gap = abs(Setpoint-temperature);
   if(gap <= 2.0){
     dehydratorPID.SetTunings(consKp, consKi, consKd);
   }else{
     dehydratorPID.SetTunings(aggKp, aggKi, aggKd);
   }
   dehydratorPID.Compute();
   // ensure the Output of PID library is on allowed range
   if((Output >= TC_OUT_MIN) && (Output <= TC_OUT_MAX)){
     root["output"] = double_with_n_digits(Output, 1);
     root["setpoint"] = double_with_n_digits(Setpoint, 1);
     // Turn On heater when in AC phase control range
     heater_en = true;
     tc_value = Output;
     one_time = true;
   }else if(Output > TC_OUT_MAX){
       heater_en = false;
       if(one_time){
         cli();
         TCCR1A = 0;    //timer control registers set for
         TCCR1B = 0;    //normal operation, timer disabled
         digitalWrite(GATE,LOW);      // set TRIAC gate to low
         one_time = false;
         digitalWrite(RELAY,HIGH);     // set RELAY to OFF state
         delay(200);
         digitalWrite(RELAY,LOW);     // set RELAY to OFF state
         sei();
       }
       root["output"] = double_with_n_digits(Output, 1);
       root["setpoint"] = double_with_n_digits(Setpoint, 1);
   }else{
     root["output"] = "error";
   }
 }

I am keeping a variable that indicates if the unit is ON or OFF first. Then the gap is computed to find which tuning to use. After that Compute() is called which calculate the Output. Notice we ensure Output is in the correct range, and the reason for doing this manually is because we are specifying the output limit range a little bigger using SetOutputLimits as this

// +something we will shutdown heater but leave air fan on
dehydratorPID.SetOutputLimits(TC_OUT_MIN, TC_OUT_MAX+1);

So why if there is a function that handle limits do I code my limits out of range? Well because I found that the lower limit range of triac firing actually don't turn off the unit completely and some current still heat the element, I have try several things to fix this such as removing the gate trigger completely which in theory should turn off the thing but anyway triac sometimes are black magic, in this case it seems trying to lower gate pulse only make triac to loose control, so if you know how this can be improved in hardware just let me know please.

Returning back to our limits fix, The PID will try to set the output to lowest TC_OUT_MAX+1 value, specially when you change the setpoint and error gap is high like when going from 60° C to 40° C. When this is the case, I just turn the unit off and leave fan working which actually cool the thing faster. Remember I comment the lost of control of triac? Well I have to turn unit off and on quickly once in order to "reset" the triac and then from that moment don't apply again the gate pulse until PID output value is on the range values again. Since temperature setpoint will not change frequently this is really the best solution I found and actually work quit well.

To initialize PID in void setup just do

 dehydratorPID.SetSampleTime(PID_SAMPLE_TIME);
 // +something we will shutdown heater but leave air fan on
 dehydratorPID.SetOutputLimits(TC_OUT_MIN, TC_OUT_MAX+1);
 dehydratorPID.SetMode(AUTOMATIC);

Arduino Serial Interface Protocol.

The arduino serial port is configured to receive in a loop using the SerialEvent function and a json parser is implemented. The string should end in a new line character for arduino to process it as a new json command.

Once a string is complete, the arduino will parse it and look for several possible "keys" which are

  • setpoint : The desired temperature.
  • state: On or Off state the unit should be in.
  • notify: whether or not should notify periodically to the upper layer(esp8266) the unit operation values such temperatury, humidity, output, etc.

The protocol is very simple and can easily supportmore commands, here is how it looks.

 // process command from esp8266 WiFi Bridge
 if (stringComplete) {
   StaticJsonBuffer<300> jsonBuffer;
   JsonObject& received = jsonBuffer.parseObject(inputString);
   boolean saveCfg = false;
   // Setpoint (Temperature in Integer value)
   int sp = received["setpoint"];
   if((sp >= TEMP_MIN) && (sp <= TEMP_MAX)){
     Setpoint = sp*1.0;
     cnfData.setpoint = (int)Setpoint;
     saveCfg = true;
     valid = true;
   }else if(sp == 0){
     // do nothing workaround
   }else{
     // turn off dehydrator if other value
     digitalWrite(RELAY,HIGH);     // set RELAY to OFF state
     heater_en = false;
     valid = false;
   }
   // ON/OFF State
   const char* state = received["state"];
   if(!strcmp(state, "on")){
     send_ack = true;
     if(valid){
       unit_on_off = true;
       digitalWrite(RELAY,LOW);      // set RELAY to ON state
       heater_en = true;
       // remember we are ON state
       cnfData.state = true;         // next reboot will be ON
       saveCfg = true;
       root["reported"] = "on";
     }else{
       root["reported"] = "off";
     }
   }else if(!strcmp(state, "off")){
     send_ack = true;
     unit_on_off = false;
     digitalWrite(RELAY,HIGH);     // set RELAY to OFF state
     heater_en = false;
     cnfData.state = false;        // next reboot will be OFF at start
     saveCfg = true;
     root["reported"] = "off";
   }else{
     // do nothing
   }
   if(saveCfg == true){
     eeAddress = EEADDR; 
     EEPROM.put(eeAddress, cnfData);
   }
   // Notifications
   const char* notify_cfg = received["notify"];
   if(!strcmp(notify_cfg, "on")){
     notify = true;
   }else if(!strcmp(notify_cfg, "off")){
     notify = false;
   }else{
     // do nothing
   }
   // clear the string:
   inputString = "";
   stringComplete = false;
 }

Non volatile memory.

The arduino on Mini boards has an ATmega328P which has an Eeprom memory of 1024 bytes, this comes handy to this project. The reason to use an Eeprom arose at the time I was doing tests and notice that for any reason the unit can be disconnected momentarily (such a thing can happen in the kitchen more often than what you think). In those cases the esp8266 was slow at bringing to life since it has to connect to a network, even worse if for some reason the network is unavailable. To avoid shutting down and waiting for commands if already programmed, the unit just resume the last setting stored in eeprom.

The code the save configuration values to eeprom is contained in the last section of serial reception, this little snippet

if(saveCfg == true){
  eeAddress = EEADDR; 
  EEPROM.put(eeAddress, cnfData);
}

where cnfData is a variable of type configObj, an struct defined as

struct configObj{
 int ver;
 int setpoint;
 boolean state;
};

The version integer element ver allow for resetting the parameters to default values on a firmware upgrade by doing this on setup function

 EEPROM.get( eeAddress, cnfData );
 if(cnfData.ver != VER){
   cnfData.ver = VER;
   cnfData.state = false;
   cnfData.setpoint = 0;
 }

This way we can change VER in code for every new version of the firmware.

Micropython Firmware - ESP-01 (ESP8266).

This section discuss details about micropython firmware.

Getting starting.

The ESP-01 is somehow limited for micropython development due to memory available, so you need at least the version with 1MB version which is probably the one with a black soldermask PCB. Other esp8266 modules can be used but this one is very cheap (around 4$) and works well once you set it up correctly. It's 0.1" header is also convenient for prototyping.

Another problem that need to be solve to make the ESP-01 work with micropython is that there is only one serial port which is by default used by the REPL prompt used to interact with micropython engine. So you need to disable it, the problem is that you need to patch original source code to open the serial port in a python script unavailable for the master version as far as I know. Don't worry I have attached the binary file already patched and ready to flash in your ESP-01, in order to flash it just make sure you connect a serial port (3.3V levels) to your ESP-01 and install esptool to your PC. Then execute the following commands.

esptool.py --port /dev/ttyUSB2 erase_flash
esptool.py --port /dev/ttyUSB2  write_flash -fm dio --flash_size 8m 0 ./build/firmware-combined.bin

After that you should be able to open a serial terminal emulator which allow you to access the micropython REPL and configure network as explained here.

The trick is to enable the webREPL as explained here or here. Then disable the serial REPL is done by replacing the boot.py file with this content.

# This file is executed on every boot (including wake-boot from deepsleep)
import gc
import webrepl
import esp
import time
esp.uart_nostdio(1)
webrepl.start()

The actual instruction which disable the serial REPL is esp.uart_nostdio(1) , which is the function that adds the aforementioned patch.

Development under micropython.

The best way I found to interact with micropython webREPL is using mpfshell utility, it's convenient for developing stage since it allow to upload files and also interact with REPL.

Firmware.

The micropython firmware do a couple of things. It runs a web socket server listening for commands from a host. Parse commands and send them to arduino in json format using the serial port.

I could have implemented an MQTT broker directly here but I really think to extend this project further, for example with the addition of a home controller with more processing power.

The complete code (including the arduino code) can be found here. Just want to showcase here how it works, first make sure you have all the sources are in place, then

the function that should be called once you import the code under REPL is called run(), using mpfshell invoke repl and proceed as follows

Then you can use any websocket tool to test. I am using web socket chrome extension. Here is a video that shows how it works.

Dehydrator receive new set point and debug info available using web sockets.

The first part shows the command, after that it take some minutes to reach setpoint and if you not get bored or asleep first you can see how it keeps temperature within +-2 C of setpoint value, the notify command prints also the actual output of the PID controller, so you can verify it's working as expected.

Alexa Smart Home dehydrator.

There is a nice quote from Mr. Einstein I would like to bring it here.

Everything should be made as simple as possible, but not simpler.

Which explains the reason I pick up Alexa Smart Home skill to control my dehydrator, since I do not want to mess up to redefine things that Amazon guys have cook for us, they work on this everyday to make Alexa smarter so why not to use it, at the end of the day they would like to see new usages (even if it need a new feature) to improve the platform I guess.

About the connection.

The ESP-01 micropython firmware certainly can connect and interact directly with Amazon Things Shadow, but I took a different approach since it's an opportunity to test new things, learn new stuff and explore another perspective.

I have some experience using this open source tool called freeboard, which I really like because I feel it can be tweaked to fit your needs, it's based on javascript and I have discover it can be run easily using a node.js server, actually there is a project that can be setup quickly called freeboard-websockets-express, it has the advantage to offer websockets integration, which is something I am up to for this project.

Here is the workflow for the dehydrator project.

Node.js running freeboard.

The implementation has the potential to grow and become a Smart Gateway capable of controlling several devices in the home, for this project it just control the dehydrator.

There is a nice project for the freeboard and AWS integration called freeboard-aws-iot-ws-mqtt, it provides the basic for communication with AWS IoT, I have done some small changes to integrate with my dehydrator widgets.

The datasource configuration of the freeboard-aws-iot-ws-mqtt is similar to the example here, you have to provide IoT Endpoint, Access and Secret Keys. It differ in the Topic To Subscribe, for this project we are using the delta (for more here) topic, which provides the difference between the "desired" and "reported" states to our freeboard widget.

Freeboard widgets.

The aws datasource once configured execute the mqtt operations to subscribe to Topics, in this case we are using delta

$aws/things/dehydrator/shadow/update/delta

this allow widgets to get the information of interest in a call back function we will see in a moment. I have programmed a couple of custom widgets for dehydrator, first we need an On/Off widget, second you guess a widget to visualize temperature.

The On/Off widget.

I will present you the widget configuration screen first.

The widgets are reusable so they have a type you pick up to open this configuration screen. All the parameters shown are created in the widget javascript file, there is an instruction loadWidgetPlugin which create this form for you.

   freeboard.loadWidgetPlugin({
       type_name: "bool_rw_property",
       display_name: "Boolean Property Control",
       description: "Boolean property which can send a value as well as receive",
       settings: [
           {
               name: "title",
               display_name: "Title",
               type: "text"
           },
           {
               name: "config_data",
               display_name: "Configuration Data",
               type: "calculated"
           },
           {
               name: "property",
               display_name: "Property Name",
               type: "text"
           },
           {
               name: "on_value",
               display_name: "On Value",
               type: "text"
           },
           {
               name: "off_value",
               display_name: "Off Value",
               type: "text"
           },            
           {
               name: "ds_name",
               display_name: "Datasource Name",
               type: "text"
           },            
           {
               name: "topicName",
               display_name: "Topic name",
               type: "text"
           },
           {
               name: "on_text",
               display_name: "On Text",
               type: "calculated"
           },
           {
               name: "off_text",
               display_name: "Off Text",
               type: "calculated"
           }
       ],
       newInstance: function (settings, newInstanceCallback) {
           newInstanceCallback(new bool_rw_property(settings));
       }
   });

The order is the same as presented. The easy parameters to use are the ones without the datasource or js editor at the right, those are basically text. The calculated types allow you to change them by means of some javascript function i.e. if opening the editor (not used here), but also allow you to reference data from the datasource, which we use here.

The datasource get mqtt topics from it's subscription, the data is json, for our delta topic an example of received data would be

Since we are only subscribed to this topic and we are interested in in the "state" content, we specify the datasource as

datasources["Dehydrator"].state

The On/Offf widget file is called bool_rw_property_aws.js, freeboard load or widgets using the configuration seen above invoking it as

newInstance: function (settings, newInstanceCallback) {
    newInstanceCallback(new bool_rw_property(settings));
}

where bool_rw_property is a function that holds our code. Inside this function there is a section that defines the html widget , the variables and some code that always executes upon entrance. The there are some functions used internally and other functions declared using this.function_name, those are called by freeboard as needed, for example upon a datasource update the following function executes

this.onCalculatedValueChanged = function (settingName, newValue) {
   if (settingName == "config_data") {
       jsonobj = newValue;
       if (jsonobj.hasOwnProperty(currentSettings.property)) {
           var property = jsonobj[currentSettings.property];
           setOnProperty(property);
       }
   }
   if (settingName == "on_text") {
       onText = newValue;
   }
   if (settingName == "off_text") {
       offText = newValue;
   }
}

The parameter settingName holds the value of the given parameter, i.e. config_data defined in settings above. If you have a widget defined and you open the configuration tab and change the ON TEXT or OFF TEXT field, freeboard will call this function, also notice how you can handle it in code, in this case changing a variable (not really used here.)

The parameter newValue in our delta case holds the json state, which already has stripped the "state", so we can go directly and verify that the On/Off parameter, a per configuration called "unitpower" is contained. If so, it means the widget has some data to act upon. Otherwise nothing is done.

The variable property has the new value, the function that handle the action is

function setOnProperty(property){
   var json_obj = new Object();
   var set_property = json_obj["direct-cmd"] = {};
   set_property.state = property;
   set_property.notify = 'on';        // property;
   console.log(json_obj);
   myWorker.port.postMessage(json_obj);
}

Remember that the connection with the micropython firmware is using web sockets, here we post a message to a worker that handle a single web socket connection. The way micropython and arduino code interacts only allow a single client to be connected. As you will notice if you dive deeper using the freeboard is that you normally create minimalist widgets as you can combine multiple widgets later on in the html web page using a Pane.

This widget is handling the "unitpower" property of our dehydrator Thing, but as you will see in a moment we will have another widget handling the temperature and both have to communicate with dehydrator using a single websocket.

The single websocket problem and solution.

A shared HTML5 javascript worker was the best solution I can come up to solve this problem. The shared web socket will open a unique web socket connection to micropython and then forward traffic from/to the connection to any worker.

if (!!window.SharedWorker) {
   console.log('Create New Shared Worker - bool property');
   myWorker = new SharedWorker("lib/js/thirdparty/worker.js");
   myWorker.port.onmessage = function(e) {
       var cmd = e.data.split(":");
       switch (cmd[0]){
           case "debug":
               console.log(cmd[1]);
           break;
           case "onconnect":
               // do nothing
           break;
           case "data":
               var data = String(e.data);
               var response = data.substring('data:'.length);
               var json_obj = JSON.parse(response);
               var reported = json_obj["reported"];
               isOn = reported["state"];
               updateState();
               if(updateShadow){
                   updateThingShadow();
                   updateShadow = false
               }
           break;
       }
   }
}

The code checks whether or not shared workers are supported, then create a new one and attach to MessagePort handler onmessage to receive messages. We implement a simple message parsing that consist of a message header, that is

  • debug: Messages from worker, used for debug since console.log don't work inside the worker.
  • onconnect: Not used but might have some usage.
  • data: The actual data from the arduino.

The updateState function just update the indicator light and set the variable appropriately. This was important since I have learn a little bit more about AWS workflow. In the following code notice a variable called updateShadow, this is only set true in case the value has change meaning is time to send a "reported" update to our Thing.

function updateState() {
   if (isOn === "on") {
       if(setValue === false){
           setValue = true;
           updateShadow = true;
       }
       stateElement.text((_.isUndefined(onText) ? (_.isUndefined(currentSett                 ings.on_text) ? "" : currentSettings.on_text) : onText));
       indicatorElement.toggleClass("on", true);
   }
   else if (isOn === "off") {
       if(setValue === true){
           setValue = false;
           updateShadow = true;
       }
       stateElement.text((_.isUndefined(offText) ? (_.isUndefined(currentSet               tings.off_text) ? "" : currentSettings.off_text) : offText));
       indicatorElement.toggleClass("on", false);
   }
}

To send the reported value I have dived into the code and also do little hack to freeboard dashboard engine, with the actual library on git there is no way to access the MQTT instance for sending a publish message. If my memory don't fail (I am attaching the code in any case so you can compare) I have included a publish function and a connection state function to know if datasource is already connected.

this.isConnected = function isConnected(){
   return connected;
}
this.publish = function publish(topic, message){
   var message = new Paho.MQTT.Message(message);
   message.destinationName = topic;
   message.qos = 0;
   self.mqtt.send(message);
   console.info("Publishing to MQTT topic %s", topic);
}

The publish is used in the updateThingShadow function

function updateThingShadow() {
   var json_obj = new Object();
   json_obj.state = {};
   var json_reported = json_obj.state.reported = {};
   if(setValue === true){
       json_reported[currentSettings.property] = currentSettings.on_value;
   }else if(setValue === false){
       json_reported[currentSettings.property] = currentSettings.off_value;
   }
   var jsonString= JSON.stringify(json_obj);
   var datasource = freeboard.getDatasourceInstance(currentSettings.ds_nam     e);
  datasource.datasourceInstance.publish(currentSettings.topicName, jsonStrin   g);
}

The non documented function getDatasourceInstance is used to assign the instance of the AWS datasource by using it's name currentSettings.ds_name, then the function publish can be invoked with the json string, i.e. in human readable way

Notice the currentSettings.topicName which was defined as

$aws/things/dehydrator/shadow/update

With all this functions working some final considerations were taken.

The first time you open the dashboard the widget connect with dehydrator Thing and get current state and it follows an update to the Thing Shadow, I found that the MQTT connection was taking longer than the websocket so my code was breaking, I fix it by including the isConnected() in a self calling function that stop once it's connected.

let delay = 500;
let timerId = setTimeout(function request() {
   var datasource = freeboard.getDatasourceInstance(currentSettings.ds_name);
   if(datasource.datasourceInstance.isConnected() === false){
       delay = 500;
       timerId = setTimeout(request, delay);
   }else{
       clearTimeout(timerId);
       updateThingShadow();
   }
}, delay);

This is how it looks the widgets running with my dehydrator long time OFF.

Here is a short video that shows the On/Off widget working, when button is pressed it update the Thing Shadow with "desired" state as can be seen in the AWS IoT Test window, then Thing Shadow send delta update, which is received and acted by freeboard widget.

Smart dehydrator and AWS interaction.

Temperature widget.

Freeboard don't come with a huge library of widgets to fill everybody needs, in our case we want some cool visualization of temperature, and so I decide to integrate this really cool Gauge on my html widget seen in the last video. The integration require unique id for the html element, very important when you want more than one Gauge. Basically you define a unique html element gauge like this

var gaugeElement = $('<div class="gauge-widget" id="' + thisGaugeID + '"></div>');

where thisGaugeID is a variable that initialize to 0 when freeboard loads.

For sure you will understand the configuration here which is similar as the On/Off widget, that is

This widget has a property name "setpoint" and a variable name "temperature". We are getting feedback of their value from arduino that we are using to update the Thing Shadow. The setpoint we are updating only once, either at start up or whenever a new setpoint command is sent to arduino, this way we can be sure the data is set correctly.

The temperature is updated at intervals dictated by the rate of received data from arduino, this is for now a hardwired value in arduino code of around 10 seconds, the value can be made a configurable parameter from freeboard or even AWS IoT easily which is left as an exercise to the reader.

The code that handle incoming data from arduino and send the Thing Shadow updates is

if (!!window.SharedWorker) {
 myWorker = new SharedWorker("lib/js/thirdparty/worker.js");
 myWorker.port.onmessage = function(e) {
 	var cmd = e.data.split(":");
	switch (cmd[0]){
	  case "debug":
	    console.log(cmd[1]);
	  break;
	  case "onconnect":
		  //do nothing here
	  break;
	  case "data":
		  var data = String(e.data);
		  var response = data.substring('data:'.length);
		  var json_obj = JSON.parse(response);
		  var reported = json_obj["reported"];
		  var temperature = reported["temperature"];
		  temperature = parseFloat(temperature).toFixed(1);
		  if (!_.isUndefined(gaugeObject)) {
			  gaugeObject.refresh(temperature);
		  }
		  if(update_property === true){
		    if(json_obj.hasOwnProperty(currentSettings.property)){
		      var prop_val = json_obj[currentSettings.property];
		      reportThingShadow(currentSettings.property, prop_val);
		      update_property = false;
		    }
		  }
		  reportThingShadow(currentSettings.var_name, temperature);
	  break;	
	}
   }
}

After the experience with On/Off, we have done a single function called reportThingShadow that takkes as input the name and value to update in "reported".

Here is a look of the very few updates when dashboard html is opened.

The last one repeats every sample received.

Alexa Smart Home Integration.

This section covers details of the integration of our previous work of AWS IoT and our Smart Dehydrator with Alexa using the Smart Home Skill.

For the sanity of everyone I will not print here every screen you need to fill up in AWS in order to make the Smart Home Skill work, will stop at steps I consider important to bring information regarding the project and not general configuration.

Lambda.

I have configured Dehydrator Lambda to use Alexa Smart Home, AWS IoT and Cloud Watch to show some logs as shown below.

You can download the lambda code from the attachments, a few important things to mention.

  • Connecting to AWS .

This is done using boto3 library, three important functions use it, tis functions update and get Thing Shadow values to use in Alexa requests.

def dehydrator_temperature(setpoint):
   # Change topic, qos and payload
   response = client.update_thing_shadow(
       thingName = 'dehydrator', 
       payload = json.dumps({
           'state': {
               'desired': {
                   'setpoint': setpoint
               }
           }
       })
   )
def dehydrator_state(state):
   # Change topic, qos and payload
   response = client.update_thing_shadow(
       thingName = 'dehydrator', 
       payload = json.dumps({
           'state': {
               'desired': {
                   'unitpower': state
               }
           }
       })
   )
def get_dehydrator_temperature():
   response = client.get_thing_shadow(thingName='dehydrator')
   streamingBody = response["payload"]
   jsonState = json.loads(streamingBody.read())
   return jsonState["state"]["reported"]["temperature"]

For the Dehydrator I am using the Alexa.ThermostatController Interface, but for some reason during the first stage of development I started using the Alexa.PowerController Interface to turn On/Off the device. To change this a few things need to be change and there was not enough time to test them. Said that, I have three cases of importance for the requests to handle.

The PowerController Interface which make used of the dehydrator_state function.

   if request_namespace == "Alexa.PowerController":
       logger.info("Handle PowerController")
       if request_name == "TurnOn":
           dehydrator_state("on")
           value = "ON"
       else:
           dehydrator_state("off")
           value = "OFF"
       response = {
           "context": {
               "properties": [
                   {
                       "namespace": "Alexa.PowerController",
                       "name": "powerState",
                       "value": value,
                       "timeOfSample": get_utc_timestamp(),
                       "uncertaintyInMilliseconds": 500
                   }
               ]
           },
           "event": {
               "header": {
                   "namespace": "Alexa",
                   "name": "Response",
                   "payloadVersion": "3",
                   "messageId": get_uuid(),
                   "correlationToken": request["directive"]["header"]["correlationToken"]
               },
               "endpoint": {
                   "scope": {
                       "type": "BearerToken",
                       "token": "access-token-from-Amazon"
                   },
                   "endpointId": request["directive"]["endpoint"]["endpointId"]
               },
               "payload": {}
           }
       }
       return response

The Thermostat Controller Interface uses the dehydrator_temperature to send a new setpoint value.

I also try to gather the temperature data using the Thermostat Controller, but could make that work so I end up using the Report State, which I am not sure is the best solution here. Anyway here is the code

   elif request_namespace == "Alexa":
       logger.info("Handle TemperatureSensor")
       if request_name == "ReportState":
           logger.info("Handle ReportState")
           value = get_dehydrator_temperature()
           response = {
               "context": {
                   "properties": [{
                   "namespace": "Alexa.TemperatureSensor",
                   "name": "temperature",
                   "value": {
                       "value": value,
                       "scale": "CELSIUS"
                   },
                   "timeOfSample": get_utc_timestamp(),
                   "uncertaintyInMilliseconds": 1000
                   } ]
               },
               "event": {
                   "header": {
                   "namespace": "Alexa",
                   "name": "StateReport",
                   "payloadVersion": "3",
                   "messageId": get_uuid(),
                   "correlationToken": request["directive"]["header"]["correlationToken"]
               },
               "endpoint": {
                   "endpointId": request["directive"]["endpoint"]["endpointId"]
               },
               "payload": {}
               }
           }
           return response

I define the Dehydrator under the categories Thermostat and Temperature Sensor.

model_name == "Smart Dehydrator": displayCategories = ["THERMOSTAT", "TEMPERATURE_SENSOR" ]

Once you discover your devices it should appear like this in your Alexa account.

I am using echosim on my android tablet to send voice commands, here is a video without audio that shows how I speak "Alexa, turn on dehydrator" and the device working, the freeboard On/Off light activate and some logs on Cloud Watch, AWS IoT Test and Alexa History is reviewed after each command.

For some reason I need to debug, Alexa is not respoding correctly the voice command for gathering dehydrator temperature, but I can see the Cloud Watch log that actually gather the last value. Finally device is turn off.

Alexa Dehydrator tests.

Here is another video that shows how Alexa turn dehydrator unit On, set thermostat setpoint to 50 Celsius, then after three minutes setpoint is reach and Alexa set it back to 40 Celsius, finally turning off unit.

Playing with setpoint.

Final Thoughts.

I enjoy doing this project with Arduino and Alexa. Arduino has proven to be up to the task for any Smart Home project since this project uses one of it's little beasts that is actually getting hot inside the dehydrator unit, working reliable while controlling the heater using AC phase control and keep a communication channel with a Host CPU in charge of WiFi communications.

Alexa and AWS IoT are really nice for Smart Home Automation, they are friendly to use and very configurable, this project shows a few aspects of it but the possibilities are getting better with new features.

Specially of interest is the new cooking features, which is the next step here, the idea is that specify which food to dehydrate and Alexa take care if it automatically. This can be done using recipes. Even machine learning can be explored since dehydrate process is sort of an art. Several conditions might affect the process including changes in ambient conditions from one day to other day, which are difficult to notice or adjust by hand.

In short arduino and Alexa Rocks!

Code

micropython binary for esp-01MicroPython
Flash on a ESP-01, look for instructions in story.
No preview (download only).
lambda.pyPython
Use in AWS
# -*- coding: utf-8 -*-

# Copyright 2017 Amazon.com, Inc. or its affiliates. All Rights Reserved.
#
# Licensed under the Amazon Software License (the "License"). You may not use this file except in
# compliance with the License. A copy of the License is located at
#
#    http://aws.amazon.com/asl/
#
# or in the "license" file accompanying this file. This file is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, express or implied. See the License for the specific
# language governing permissions and limitations under the License.

"""Alexa Smart Home Lambda Function Sample Code.

This file demonstrates some key concepts when migrating an existing Smart Home skill Lambda to
v3, including recommendations on how to transfer endpoint/appliance objects, how v2 and vNext
handlers can be used together, and how to validate your v3 responses using the new Validation
Schema.

Note that this example does not deal with user authentication, only uses virtual devices, omits
a lot of implementation and error handling to keep the code simple and focused.
"""

import logging
import time
import json
import uuid

import boto3

client = boto3.client('iot-data')

# Imports for v3 validation
from validation import validate_message

# Setup logger
logger = logging.getLogger()
logger.setLevel(logging.INFO)

# To simplify this sample Lambda, we omit validation of access tokens and retrieval of a specific
# user's appliances. Instead, this array includes a variety of virtual appliances in v2 API syntax,
# and will be used to demonstrate transformation between v2 appliances and v3 endpoints.
SAMPLE_APPLIANCES = [
    {
        "applianceId": "smart-ronco-endpoint",
        "manufacturerName": "Hackster Contest MAIA",
        "modelName": "Smart Dehydrator",
        "version": "1",
        "friendlyName": "dehydrator",
        "friendlyDescription": "A modified ronco Dehydrator that automate the process",
        "isReachable": True,
        "actions": [
            "turnOn",
            "turnOff",
            "thermostatMode",
            "setTargetTemperature",
            "incrementTargetTemperature",
            "decrementTargetTemperature",
            "getTargetTemperature",
            "getTemperatureReading"
        ],
        "additionalApplianceDetails": {
            "detail1": "An arduino controlled modified Ronco Dehydrator"
        }
    }
]

def dehydrator_temperature(setpoint):
    # Change topic, qos and payload
    response = client.update_thing_shadow(
        thingName = 'dehydrator', 
        payload = json.dumps({
            'state': {
                'desired': {
                    'setpoint': setpoint
                }
            }
        })
    )

def dehydrator_state(state):
    # Change topic, qos and payload
    response = client.update_thing_shadow(
        thingName = 'dehydrator', 
        payload = json.dumps({
            'state': {
                'desired': {
                    'unitpower': state
                }
            }
        })
    )
    
def get_dehydrator_temperature():
    response = client.get_thing_shadow(thingName='dehydrator')
    streamingBody = response["payload"]
    jsonState = json.loads(streamingBody.read())
    return jsonState["state"]["reported"]["temperature"]

def lambda_handler(request, context):
    """Main Lambda handler.

    Since you can expect both v2 and v3 directives for a period of time during the migration
    and transition of your existing users, this main Lambda handler must be modified to support
    both v2 and v3 requests.
    """

    try:
        logger.info("Directive:")
        logger.info(json.dumps(request, indent=4, sort_keys=True))

        version = get_directive_version(request)

        if version == "3":
            logger.info("Received v3 directive!")
            if request["directive"]["header"]["name"] == "Discover":
                response = handle_discovery_v3(request)
            else:
                response = handle_non_discovery_v3(request)

        else:
            logger.info("Received v2 directive!")
            if request["header"]["namespace"] == "Alexa.ConnectedHome.Discovery":
                response = handle_discovery()
            else:
                response = handle_non_discovery(request)

        logger.info("Response:")
        logger.info(json.dumps(response, indent=4, sort_keys=True))

        if version == "3":
            logger.info("Validate v3 response")
            validate_message(request, response)

        return response
    except ValueError as error:
        logger.error(error)
        raise

# v2 handlers
def handle_discovery():
    header = {
        "namespace": "Alexa.ConnectedHome.Discovery",
        "name": "DiscoverAppliancesResponse",
        "payloadVersion": "2",
        "messageId": get_uuid()
    }
    payload = {
        "discoveredAppliances": SAMPLE_APPLIANCES
    }
    response = {
        "header": header,
        "payload": payload
    }
    return response

def handle_non_discovery(request):
    request_name = request["header"]["name"]

    if request_name == "TurnOnRequest":
        header = {
            "namespace": "Alexa.ConnectedHome.Control",
            "name": "TurnOnConfirmation",
            "payloadVersion": "2",
            "messageId": get_uuid()
        }
        payload = {}
    elif request_name == "TurnOffRequest":
        header = {
            "namespace": "Alexa.ConnectedHome.Control",
            "name": "TurnOffConfirmation",
            "payloadVersion": "2",
            "messageId": get_uuid()
        }
    # other handlers omitted in this example
    payload = {}
    response = {
        "header": header,
        "payload": payload
    }
    return response

# v2 utility functions
def get_appliance_by_appliance_id(appliance_id):
    for appliance in SAMPLE_APPLIANCES:
        if appliance["applianceId"] == appliance_id:
            return appliance
    return None

def get_utc_timestamp(seconds=None):
    return time.strftime("%Y-%m-%dT%H:%M:%S.00Z", time.gmtime(seconds))

def get_uuid():
    return str(uuid.uuid4())

# v3 handlers
def handle_discovery_v3(request):
    endpoints = []
    for appliance in SAMPLE_APPLIANCES:
        endpoints.append(get_endpoint_from_v2_appliance(appliance))

    response = {
        "event": {
            "header": {
                "namespace": "Alexa.Discovery",
                "name": "Discover.Response",
                "payloadVersion": "3",
                "messageId": get_uuid()
            },
            "payload": {
                "endpoints": endpoints
            }
        }
    }
    return response

def handle_non_discovery_v3(request):
    request_namespace = request["directive"]["header"]["namespace"]
    request_name = request["directive"]["header"]["name"]

    logger.info("Requested Namespace: %s" % request_namespace)
    logger.info("Requested Name: %s" % request_name)

    if request_namespace == "Alexa.PowerController":
        logger.info("Handle PowerController")
        if request_name == "TurnOn":
            dehydrator_state("on")
            value = "ON"
        else:
            dehydrator_state("off")
            value = "OFF"

        response = {
            "context": {
                "properties": [
                    {
                        "namespace": "Alexa.PowerController",
                        "name": "powerState",
                        "value": value,
                        "timeOfSample": get_utc_timestamp(),
                        "uncertaintyInMilliseconds": 500
                    }
                ]
            },
            "event": {
                "header": {
                    "namespace": "Alexa",
                    "name": "Response",
                    "payloadVersion": "3",
                    "messageId": get_uuid(),
                    "correlationToken": request["directive"]["header"]["correlationToken"]
                },
                "endpoint": {
                    "scope": {
                        "type": "BearerToken",
                        "token": "access-token-from-Amazon"
                    },
                    "endpointId": request["directive"]["endpoint"]["endpointId"]
                },
                "payload": {}
            }
        }
        return response

    elif request_namespace == "Alexa.Authorization":
        if request_name == "AcceptGrant":
            response = {
                "event": {
                    "header": {
                        "namespace": "Alexa.Authorization",
                        "name": "AcceptGrant.Response",
                        "payloadVersion": "3",
                        "messageId": "5f8a426e-01e4-4cc9-8b79-65f8bd0fd8a4"
                    },
                    "payload": {}
                }
            }
            return response

    elif request_namespace == "Alexa.ThermostatController":
        logger.info("Handle ThermostatController")
        if request_name == "SetTargetTemperature":
            request_value = request["directive"]["payload"]["targetSetpoint"]["value"]
            dehydrator_temperature(request_value)
            response = {
                "context": {
                    "properties": [ {
                        "namespace": "Alexa.ThermostatController",
                        "name": "targetSetpoint",
                        "value": {
                            "value": request_value,
                            "scale": "CELSIUS"
                        },
                        "timeOfSample": get_utc_timestamp(),
                        "uncertaintyInMilliseconds": 500
                    }, {
                      "namespace": "Alexa.ThermostatController",
                      "name": "thermostatMode",
                      "value": "AUTO",
                      "timeOfSample": get_utc_timestamp(),
                      "uncertaintyInMilliseconds": 500
                    } ]
                },
                "event": {
                    "header": {
                        "namespace": "Alexa",
                        "name": "Response",
                        "payloadVersion": "3",
                        "messageId": get_uuid(),
                        "correlationToken": request["directive"]["header"]["correlationToken"]
                    },
                    "endpoint": {
                        "endpointId": request["directive"]["endpoint"]["endpointId"]
                    },
                    "payload": {}
                }
            }
            return response
    elif request_namespace == "Alexa":
        logger.info("Handle TemperatureSensor")
        if request_name == "ReportState":
            logger.info("Handle ReportState")
            value = get_dehydrator_temperature()
            response = {
                "context": {
                    "properties": [{
                    "namespace": "Alexa.TemperatureSensor",
                    "name": "temperature",
                    "value": {
                        "value": value,
                        "scale": "CELSIUS"
                    },
                    "timeOfSample": get_utc_timestamp(),
                    "uncertaintyInMilliseconds": 1000
                    } ]
                },
                "event": {
                    "header": {
                    "namespace": "Alexa",
                    "name": "StateReport",
                    "payloadVersion": "3",
                    "messageId": get_uuid(),
                    "correlationToken": request["directive"]["header"]["correlationToken"]
                },
                "endpoint": {
                    "endpointId": request["directive"]["endpoint"]["endpointId"]
                },
                "payload": {}
                }
            }
            return response

# v3 utility functions
def get_endpoint_from_v2_appliance(appliance):
    endpoint = {
        "endpointId": appliance["applianceId"],
        "manufacturerName": appliance["manufacturerName"],
        "friendlyName": appliance["friendlyName"],
        "description": appliance["friendlyDescription"],
        "displayCategories": [],
        "cookie": appliance["additionalApplianceDetails"],
        "capabilities": []
    }
    endpoint["displayCategories"] = get_display_categories_from_v2_appliance(appliance)
    endpoint["capabilities"] = get_capabilities_from_v2_appliance(appliance)
    return endpoint

def get_directive_version(request):
    try:
        return request["directive"]["header"]["payloadVersion"]
    except:
        try:
            return request["header"]["payloadVersion"]
        except:
            return "-1"

def get_endpoint_by_endpoint_id(endpoint_id):
    appliance = get_appliance_by_appliance_id(endpoint_id)
    if appliance:
        return get_endpoint_from_v2_appliance(appliance)
    return None

def get_display_categories_from_v2_appliance(appliance):
    model_name = appliance["modelName"]
    if model_name == "Smart Switch": displayCategories = ["SWITCH"]
    elif model_name == "Smart Light": displayCategories = ["LIGHT"]
    elif model_name == "Smart White Light": displayCategories = ["LIGHT"]
    elif model_name == "Smart Thermostat": displayCategories = ["THERMOSTAT"]
    elif model_name == "Smart Lock": displayCategories = ["SMARTLOCK"]
    elif model_name == "Smart Scene": displayCategories = ["SCENE_TRIGGER"]
    elif model_name == "Smart Activity": displayCategories = ["ACTIVITY_TRIGGER"]
    elif model_name == "Smart Camera": displayCategories = ["CAMERA"]
    elif model_name == "Smart Dehydrator": displayCategories = ["THERMOSTAT", "TEMPERATURE_SENSOR" ]
    else: displayCategories = ["OTHER"]
    return displayCategories

def get_capabilities_from_v2_appliance(appliance):
    model_name = appliance["modelName"]
    if model_name == "Smart Dehydrator":
        capabilities = [
            {
                "type": "AlexaInterface",
                "interface": "Alexa.PowerController",
                "version": "3",
                "properties": {
                    "supported": [
                        { "name": "powerState" }
                    ],
                    "proactivelyReported": True,
                    "retrievable": True
                }
            },
            {
                "type": "AlexaInterface",
                "interface": "Alexa.ThermostatController",
                "version": "3",
                "properties": {
                    "supported": [
                        { "name": "targetSetpoint" },
                        { "name": "thermostatMode" }
                    ],
                    "proactivelyReported": True,
                    "retrievable": True
                }
            },
            {
                "type": "AlexaInterface",
                "interface": "Alexa.TemperatureSensor",
                "version": "3",
                "properties": {
                    "supported": [
                        { "name": "temperature" }
                    ],
                    "proactivelyReported": True,
                    "retrievable": True
                }
            }
        ]
    else:
        # in this example, just return simple on/off capability
        capabilities = [
            {
                "type": "AlexaInterface",
                "interface": "Alexa.PowerController",
                "version": "3",
                "properties": {
                    "supported": [
                        { "name": "powerState" }
                    ],
                    "proactivelyReported": True,
                    "retrievable": True
                }
            }
        ]

    # additional capabilities that are required for each endpoint
    endpoint_health_capability = {
        "type": "AlexaInterface",
        "interface": "Alexa.EndpointHealth",
        "version": "3",
        "properties": {
            "supported":[
                { "name":"connectivity" }
            ],
            "proactivelyReported": True,
            "retrievable": True
        }
    }
    alexa_interface_capability = {
        "type": "AlexaInterface",
        "interface": "Alexa",
        "version": "3"
    }
    capabilities.append(endpoint_health_capability)
    capabilities.append(alexa_interface_capability)
    return capabilities
bool_rw_property_aws.jsJavaScript
Use it under freeboard node.js
// ┌────────────────────────────────────────────────────────────────────┐ \\
// │ freeboard-bool-property-RW-plugin AWS                              │ \\
// ├────────────────────────────────────────────────────────────────────┤ \\
// │ The Alexa and Arduino Smart Home Challenge                         │ \\
// | (URL here)                                                         | \\
// ├────────────────────────────────────────────────────────────────────┤ \\
// │ Licensed under the MIT license.                                    │ \\
// ├────────────────────────────────────────────────────────────────────┤ \\
// │ Freeboard widget plugin.                                           │ \\
// └────────────────────────────────────────────────────────────────────┘ \\
(function () {
    //
    // DECLARATIONS
    //
    var LOADING_INDICATOR_DELAY = 1000;

    //

    freeboard.loadWidgetPlugin({
        type_name: "bool_rw_property",
        display_name: "Boolean Property Control",
        description: "Boolean property which can send a value as well as receive",
        settings: [
            {
                name: "title",
                display_name: "Title",
                type: "text"
            },
            {
                name: "config_data",
                display_name: "Configuration Data",
                type: "calculated"
            },
            {
                name: "property",
                display_name: "Property Name",
                type: "text"
            },
            {
                name: "on_value",
                display_name: "On Value",
                type: "text"
            },
            {
                name: "off_value",
                display_name: "Off Value",
                type: "text"
            },            
            {
                name: "ds_name",
                display_name: "Datasource Name",
                type: "text"
            },            
            {
                name: "topicName",
                display_name: "Topic name",
                type: "text"
            },
            {
                name: "on_text",
                display_name: "On Text",
                type: "calculated"
            },
            {
                name: "off_text",
                display_name: "Off Text",
                type: "calculated"
            }

        ],
        newInstance: function (settings, newInstanceCallback) {
            newInstanceCallback(new bool_rw_property(settings));
        }
    });

    freeboard.addStyle('.indicator-light.interactive:hover', "box-shadow: 0px 0px 15px #FF9900; cursor: pointer;");
    var bool_rw_property = function (settings) {
        var self = this;
        var titleElement = $('<h2 class="section-title"></h2>');
        var stateElement = $('<div class="indicator-text"></div>');
        var indicatorElement = $('<div class="indicator-light interactive"></div>');
        var currentSettings = settings;
        var isOn;
        var setValue = false;
        var jsonobj;
        var onText;
        var offText;
        var url;
        var myWorker;
        var updateShadow = false;

        let delay = 500;
        let timerId = setTimeout(function request() {
            var datasource = freeboard.getDatasourceInstance(currentSettings.ds_name);
            if(datasource.datasourceInstance.isConnected() === false){
                delay = 500;
                timerId = setTimeout(request, delay);
            }else{
                clearTimeout(timerId);
                updateThingShadow();
            }
        }, delay);

        if (!!window.SharedWorker) {
            console.log('Create New Shared Worker - bool property');
            myWorker = new SharedWorker("lib/js/thirdparty/worker.js");

            myWorker.port.onmessage = function(e) {
                var cmd = e.data.split(":");
                switch (cmd[0]){
                    case "debug":
                        console.log(cmd[1]);
                    break;
                    case "onconnect":
                        // do nothing
                    break;
                    case "data":
                        var data = String(e.data);
                        var response = data.substring('data:'.length);
                        console.log("bool response: "+response);
                        var json_obj = JSON.parse(response);
                        console.log(json_obj);
                        var reported = json_obj["reported"];
                        console.log(reported);
                        isOn = reported["state"];
                        updateState();
                        if(updateShadow){
                            updateThingShadow();
                            updateShadow = false
                        }
                    break;
                }
            }
        }
        
        function setOnProperty(property){
            var json_obj = new Object();
            var set_property = json_obj["direct-cmd"] = {};
            set_property.state = property;
            set_property.notify = 'on';        // property;
            console.log(json_obj);
            myWorker.port.postMessage(json_obj);
        }

        function updateState() {
            if (isOn === "on") {
                if(setValue === false){
                    setValue = true;
                    updateShadow = true;
                }
                stateElement.text((_.isUndefined(onText) ? (_.isUndefined(currentSettings.on_text) ? "" : currentSettings.on_text) : onText));
                indicatorElement.toggleClass("on", true);
            }
            else if (isOn === "off") {
                if(setValue === true){
                    setValue = false;
                    updateShadow = true;
                }
                stateElement.text((_.isUndefined(offText) ? (_.isUndefined(currentSettings.off_text) ? "" : currentSettings.off_text) : offText));
                indicatorElement.toggleClass("on", false);
            }
        }

        function updateThingShadow() {
            var json_obj = new Object();
            json_obj.state = {};
            var json_reported = json_obj.state.reported = {};
            if(setValue === true){
                json_reported[currentSettings.property] = currentSettings.on_value;
            }else if(setValue === false){
                json_reported[currentSettings.property] = currentSettings.off_value;
            }
            var jsonString= JSON.stringify(json_obj);
            console.log("json: " + jsonString);
            var datasource = freeboard.getDatasourceInstance(currentSettings.ds_name);
            datasource.datasourceInstance.publish(currentSettings.topicName, jsonString);
        }

        function updateDesiredThingShadow(){
            var json_obj = new Object();
            json_obj.state = {};
            var json_desired = json_obj.state.desired = {};
            if(setValue === true){
                // if device is On send desired new styate OFF
                console.log("Send desire to switch Unit OFF");
                json_desired[currentSettings.property] = currentSettings.off_value;
            }else if(setValue === false){
                // if device is Off send desired new styate ON
                console.log("Send desire to switch Unit ON");
                json_desired[currentSettings.property] = currentSettings.on_value;
            }
            var jsonString= JSON.stringify(json_obj);
            console.log("json: " + jsonString);
            var datasource = freeboard.getDatasourceInstance(currentSettings.ds_name);
            datasource.datasourceInstance.publish(currentSettings.topicName, jsonString);            
        }

        this.onClick = function(e) {
            updateDesiredThingShadow();
            e.preventDefault();

            // var json_obj = new Object();
            // json_obj.state = {};
            // var json_desired = json_obj.state.desired = {};
            // if(setValue){
            //     json_desired[currentSettings.property] = currentSettings.off_value;
            // }else{
            //     json_desired[currentSettings.property] = currentSettings.on_value;
            // }
            // var jsonString= JSON.stringify(json_obj);
            // console.log("json: " + jsonString);
            // // var datasources = freeboard.getDatasourceSettings("Dehydrator");
            // // var datasource = freeboard.getDatasourceInstance("Dehydrator");
            // var datasource = freeboard.getDatasourceInstance(currentSettings.ds_name);
            // // console.log("datasources settings:");
            // // console.log(datasources);
            // // console.log("datasources object:");
            // // console.log(datasource);
            // // console.log("click button received");
            // datasource.datasourceInstance.publish(currentSettings.topicName, jsonString);


            // var new_val = !isOn;
            // jsonobj[currentSettings.property] = new_val;
            // var new_jsondata = JSON.stringify(jsonobj);
            // this.onCalculatedValueChanged('config_data', jsonobj);
            // url = currentSettings.url;
            // if (_.isUndefined(url))
            //     freeboard.showDialog($("<div align='center'>url undefined</div>"), "Error!", "OK", null, function () {
            //     });
            // else {
            //     this.sendValue(url, new_jsondata);
            // }
        }


        this.render = function (element) {
            $(element).append(titleElement).append(indicatorElement).append(stateElement);
            $(indicatorElement).click(this.onClick.bind(this));
        }

        this.onSettingsChanged = function (newSettings) {
            currentSettings = newSettings;
            titleElement.html((_.isUndefined(newSettings.title) ? "" : newSettings.title));
            updateState();
        }

        this.onCalculatedValueChanged = function (settingName, newValue) {
            console.log(settingName);
            console.log(newValue);
            if (settingName == "config_data") {
                jsonobj = newValue;
                if (jsonobj.hasOwnProperty(currentSettings.property)) {
                    console.log("receive property");
                    var property = jsonobj[currentSettings.property];
                    setOnProperty(property);
                }
            }
            if (settingName == "on_text") {
                onText = newValue;
            }
            if (settingName == "off_text") {
                offText = newValue;
            }
        }

        var request;

        this.sendValue = function (url, json_response) {
            console.log(url, json_response);
            request = new XMLHttpRequest();
            if (!request) {
                console.log('Giving up :( Cannot create an XMLHTTP instance');
                return false;
            }
            request.onreadystatechange = this.alertContents;
            request.open('POST', url, true);
            request.setRequestHeader("Content-type", "application/json");
            freeboard.showLoadingIndicator(true);
            request.send(json_response);
        }

        this.alertContents = function () {
            if (request.readyState === XMLHttpRequest.DONE) {
                if (request.status === 200) {
                    console.log(request.responseText);
                    setTimeout(function () {
                        freeboard.showLoadingIndicator(false);
                        //freeboard.showDialog($("<div align='center'>Request response 200</div>"),"Success!","OK",null,function(){});
                    }, LOADING_INDICATOR_DELAY);
                } else {
                    console.log('There was a problem with the request.');
                    setTimeout(function () {
                        freeboard.showLoadingIndicator(false);
                        freeboard.showDialog($("<div align='center'>There was a problem with the request. Code " + request.status + request.responseText + " </div>"), "Error!", "OK", null, function () {
                        });
                    }, LOADING_INDICATOR_DELAY);
                }

            }

        }

        this.onDispose = function () {
        }

        this.getHeight = function () {
            return 1;
        }

        this.onSettingsChanged(settings);
    };

}());
gauge_rw_property_aws.jsJavaScript
use with freeboard node.js
// ┌────────────────────────────────────────────────────────────────────┐ \\
// │ freeboard-bool-property-RW-plugin AWS                              │ \\
// ├────────────────────────────────────────────────────────────────────┤ \\
// │ The Alexa and Arduino Smart Home Challenge                         │ \\
// | (URL here)                                                         | \\
// ├────────────────────────────────────────────────────────────────────┤ \\
// │ Licensed under the MIT license.                                    │ \\
// ├────────────────────────────────────────────────────────────────────┤ \\
// │ Freeboard widget plugin.                                           │ \\
// └────────────────────────────────────────────────────────────────────┘ \\

(function () {

    freeboard.loadWidgetPlugin({
        type_name: "gaugeWidget",
        display_name: "AWS IoT Gauge",
        "external_scripts" : [
            "plugins/thirdparty/raphael.2.1.0.min.js",
            "plugins/thirdparty/justgage.1.0.1.js"
        ],
        settings: [
            {
                name: "title",
                display_name: "Title",
                type: "text"
            },
            {
                name: "config_data",
                display_name: "Configuration Data",
                type: "calculated"
            },
            {
                name: "property",
                display_name: "Property Name",
                type: "text"
            },
            {
                name: "var_name",
                display_name: "Variable Name",
                type: "text"
            },
            {
                name: "ds_name",
                display_name: "Datasource Name",
                type: "text"
            },            
            {
                name: "topicName",
                display_name: "Topic name",
                type: "text"
            },
            {
                name: "units",
                display_name: "Units",
                type: "text"
            },
            {
                name: "min_value",
                display_name: "Minimum",
                type: "text",
                default_value: 0
            },
            {
                name: "max_value",
                display_name: "Maximum",
                type: "text",
                default_value: 100
            }
        ],
        newInstance: function (settings, newInstanceCallback) {
            newInstanceCallback(new gaugeWidget(settings));
        }
    });


    var gaugeID = 0;
	freeboard.addStyle('.gauge-widget-wrapper', "width: 100%;text-align: center;");
	freeboard.addStyle('.gauge-widget', "width:200px;height:160px;display:inline-block;");

    var gaugeWidget = function (settings) {
        var self = this;

        var thisGaugeID = "gauge-" + gaugeID++;
        var titleElement = $('<h2 class="section-title"></h2>');
        var gaugeElement = $('<div class="gauge-widget" id="' + thisGaugeID + '"></div>');

        var gaugeObject;
        var rendered = false;

        var jsonobj;
        var currentSettings = settings;
        var update_property = true;

		if (!!window.SharedWorker) {
			console.log('Create New Shared Worker - '+thisGaugeID);
			myWorker = new SharedWorker("lib/js/thirdparty/worker.js");

			myWorker.port.onmessage = function(e) {
				var cmd = e.data.split(":");
				switch (cmd[0]){
					case "debug":
						console.log(cmd[1]);
					break;
					case "onconnect":
						//do nothing here
					break;
					case "data":
						var data = String(e.data);
						var response = data.substring('data:'.length);
						console.log("gauge response: "+response);
						var json_obj = JSON.parse(response);
						console.log(json_obj);
						var reported = json_obj["reported"];
						var temperature = reported["temperature"];
						temperature = parseFloat(temperature).toFixed(1);
						if (!_.isUndefined(gaugeObject)) {
							gaugeObject.refresh(temperature);
						}
						if(update_property === true){
							if(json_obj.hasOwnProperty(currentSettings.property)){
								var prop_val = json_obj[currentSettings.property];
								reportThingShadow(currentSettings.property, prop_val);
								update_property = false;
							}
						}
						reportThingShadow(currentSettings.var_name, temperature);
					break;	
				}
			}
		}

        function setGaugeProperty(value){
            var json_obj = new Object();
            var set_property = json_obj["direct-cmd"] = {};	
            set_property[currentSettings.property] = value;
            console.log("setGaugeProperty - send json cmd:");
            console.log(json_obj);
            myWorker.port.postMessage(json_obj);
            // this way we request the report of setpoint once is available
            update_property = true;
        }

        function reportThingShadow(param, value) {
            var json_obj = new Object();
            json_obj.state = {};
            var json_reported = json_obj.state.reported = {};
            json_reported[param] = value;
            var jsonString = JSON.stringify(json_obj);
            console.log("json: " + jsonString);
            var datasource = freeboard.getDatasourceInstance(currentSettings.ds_name);
            datasource.datasourceInstance.publish(currentSettings.topicName, jsonString);
        }

        function createGauge() {
            if (!rendered) {
                return;
            }

            gaugeElement.empty();
            gaugeObject = new JustGage({
                id: thisGaugeID,
                value: (_.isUndefined(currentSettings.min_value) ? 0 : currentSettings.min_value),
                min: (_.isUndefined(currentSettings.min_value) ? 0 : currentSettings.min_value),
                max: (_.isUndefined(currentSettings.max_value) ? 0 : currentSettings.max_value),
                label: currentSettings.units,
                showInnerShadow: false,
                valueFontColor: "#d3d4d4"
            });
        }

        this.render = function (element) {
            rendered = true;
            $(element).append(titleElement).append($('<div class="gauge-widget-wrapper"></div>').append(gaugeElement));
            createGauge();
        }

        this.onSettingsChanged = function (newSettings) {
            if (newSettings.min_value != currentSettings.min_value || newSettings.max_value != currentSettings.max_value || newSettings.units != currentSettings.units) {
                currentSettings = newSettings;
                createGauge();
            }
            else {
                currentSettings = newSettings;
            }

            titleElement.html(newSettings.title);
        }

        this.onCalculatedValueChanged = function (settingName, newValue) {
        	if (settingName == "config_data") {
        		var value;
                jsonobj = newValue;
                if (jsonobj.hasOwnProperty(currentSettings.property)) {
                    value = jsonobj[currentSettings.property];
                    setGaugeProperty(value);
                }   
	        }
        }

        this.onDispose = function () {
        }

        this.getHeight = function () {
            return 3;
        }

        this.onSettingsChanged(settings);
    };

}());
worker.jsJavaScript
This is the javascript shared worker code for freeboard node.js
var ws;
var ports = [];

function start() {
	if (typeof ws === 'undefined') {
		ws = new WebSocket("ws://192.168.1.80:80");
		ws.onopen = function () {
			for(var i=0; i<ports.length; i++){
				ports[i].postMessage("onconnect:onopen websocket");
			}
		};
		ws.onmessage = function (evt) {
			for(var i=0; i<ports.length; i++){
				ports[i].postMessage("data:"+evt.data);
			}
		};
	}
}

onconnect = function (e) {
	start();
	var port = e.ports[0];

	ports.push(port);

	port.onmessage = function (e) {
		port.postMessage("debug:received message");
		json_obj = e.data;
		if(ws.readyState === WebSocket.OPEN){
		    var json_string = JSON.stringify(json_obj);
		    ws.send(json_string+'\n');
		}
		
	};
}
dehydrator sources
Arduino and ESP-01 dehydrator sources
Arduino OTA update sources
Flash Arduino using esp8266 running micropython.

Schematics

simplified schematic
The core with Arduino and ESP
Schetch mkeuyaj0mu

Comments

Similar projects you might like

Alexa Based Smart Home Monitoring

Project tutorial by Adithya TG

  • 19,076 views
  • 19 comments
  • 54 respects

Smart Pool: Alexa Controlled Pool Manager

Project tutorial by Benjamin Winiarski

  • 1,355 views
  • 2 comments
  • 7 respects

Alexa BBQ/Kitchen Thermometer with IoT Arduino and e-Paper

Project tutorial by Roger Theriault

  • 2,475 views
  • 0 comments
  • 9 respects

Rampiot - Cool Smart Lock

Project tutorial by Robinson Mesino

  • 3,900 views
  • 1 comment
  • 24 respects

Alexa as a Smart Switch!

by FelixAdiy

  • 1,296 views
  • 1 comment
  • 4 respects

Alexa Smart Socket

Project tutorial by Emmanuel Edwards

  • 1,292 views
  • 4 comments
  • 9 respects
Add projectSign up / Login