If you ever wanted something other than plain old landscaping lights that are only one color, then this project might interest you.
This project grew out of me wanting to experiment with Google Cloud's IoT core while starting to create/build my own smart home system for my house. The first part of this project is setting up and account with Google Cloud and enabling App Engine, IoT Core and Datastore. I am not going to go into the details of doing that but you can find the documentation here:
- App Engine: https://cloud.google.com/appengine/
- IoT Core: https://cloud.google.com/iot-core/
- Datastore: https://cloud.google.com/datastore/
You should be well within the free tier with this project, so there should be no cost to you for using Google Cloud.
Now let's get into some code.
BackendFirst we will start with the backend (App Engine) that you will be using to get Json Web Tokens (JWT) for the Arduino device to connect to IoT Core (more on this later).
In Android Studio Create a new Google Cloud module.
In build.gradle
for your App Engine project, add these libraries.
Objectify - Creates objects out of Cloud Datastore items:
implementation 'com.googlecode.objectify:objectify:5.1.21'
Cloud IoT:
implementation 'com.google.apis:google-api-services-cloudiot:v1-rev23-1.23.0'
Joda Time - Library:
implementation group: 'joda-time', name: 'joda-time', version: '2.9.2'
Json Web Token - used for creating a JWT to send to the Arduino device:
implementation 'io.jsonwebtoken:jjwt:0.7.0'
Setup Objectify
Follow this link for how to setup objectify on App Engine: https://github.com/objectify/objectify/wiki/Setup
Device Registration Endpoint
Create a Device POJO Entity class.
This class will be used to hold device information, such as the Firebase Cloud Messaging (FCM) instance ID used to send push messages to your Android Things device.
@Entity
public class Device {
@Id
private Long id;
@Ignore
private boolean wasUpdated = true;
@Index
private String key;
@Index
private Date updateDate;
@Index
private String deviceName;
@Index
private String ipAddress;
@Index
private String buildId;
public Long getId(){
return id;
}
public String getKey(){
return key;
}
public Date getUpdateDate(){
return updateDate;
}
public void setKey(String key){
this.key = key;
}
public void setUpdateDate(Date date){
this.updateDate = date;
}
public void setId(long id){
this.id = id;
}
public void setDeviceName(String name){
this.deviceName = name;
}
public String getDeviceName(){
return deviceName;
}
public void setIpAddress(String ipAddress){
this.ipAddress = ipAddress;
}
public String getIpAddress(){
return ipAddress;
}
public void setBuildId(String buildId){
this.buildId = buildId;
}
public String getBuildId(){
return buildId;
}
}
Create a registration endpoint that your Android Things device will call to store your FCM instance ID.
@Api(
name = "registration",
version = "v1",
namespace = @ApiNamespace(
ownerDomain = "your-app-domain",
ownerName = "your-app-name",
packagePath = ""
)
)
public class RegistrationEndpoint {
private static final Logger log = Logger.getLogger(Device.class.getName());
@ApiMethod(name = "register",
httpMethod = ApiMethod.HttpMethod.POST,
path = "register")
public Device register(@Named("instanceId") String instanceId,@Named("device")String device,@Named("ipAddress")String ipAddress,@Named("buildId")String buildId){
Device id = findRecord(device);
if(id == null){
id = new Device();
id.setDeviceName(device);
log.info("Push id not found");
}else{
log.info("Push id found, updating");
}
id.setBuildId(buildId);
id.setKey(instanceId);
id.setIpAddress(ipAddress);
id.setUpdateDate(new Date());
ofy().save().entity(id).now();
return id;
}
private Device findRecord(String key){
return ofy().load().type(Device.class).filter("deviceName", key).first().now();
}
}
This POST call takes in the instance ID, device name, IP address and build ID. First it checks to see if the device exists, and if not, it creates a new device, saves it and then returns the device.
FCM Push Notification Helper
This class is a helper class to send Push Notifications to devices.
public class FCMHelper {
private static final Logger log = Logger.getLogger(FCMHelper.class.getName());
private static final String API_KEY = System.getProperty("fcm.api.key");
public FCMHelper(){
}
public void sendDeviceNotification(List<Device> devices,JSONObject data) throws NotFoundException {
List<String> ids = new ArrayList<String>(devices.size());
for(int i=0; i<devices.size(); i++){
ids.add(devices.get(i).getKey());
}
StringBuilder sb = new StringBuilder();
JSONObject json = new JSONObject();
json.put("registration_ids",new JSONArray(ids));
json.put("data",data);
HttpURLConnection conn = null;
try{
URL url = new URL("https://fcm.googleapis.com/fcm/send");
conn = (HttpURLConnection) url.openConnection();
conn.setDoOutput(true);
conn.setRequestMethod("POST");
conn.setRequestProperty("Content-Type","application/json");
conn.setRequestProperty("Authorization","key="+API_KEY);
conn.connect();
log.info("Url connected");
OutputStreamWriter writer = new OutputStreamWriter(conn.getOutputStream());
writer.write(json.toString());
writer.close();
log.info("Getting response code");
int HttpResult = conn.getResponseCode();
if (HttpResult == HttpURLConnection.HTTP_OK) {
BufferedReader br = new BufferedReader(new InputStreamReader(conn.getInputStream(), "utf-8"));
String line = null;
while ((line = br.readLine()) != null) {
sb.append(line);
}
br.close();
JSONObject result = new JSONObject(sb.toString());
handleResponse(result,devices);
}else{
log.warning("Push message failed: "+conn.getResponseMessage());
}
}catch (Exception e){
log.severe("Could not send push message, error: "+e.getMessage());
}finally{
if(conn != null){
conn.disconnect();
}
}
}
private void handleResponse(JSONObject response,List<Device> devices){
JSONArray results = response.getJSONArray("results");
for(int i=0; i<results.length(); i++){
JSONObject obj = results.getJSONObject(i);
if(obj.has("registration_id")){
String regId = obj.get("registration_id").toString();
Device device = devices.get(i);
device.setKey(regId);
ofy().save().entity(device).now();
}
if(obj.has("error") && obj.get("error").toString().length() > 0){
log.warning("Push error: "+obj.get("error").toString());
}
}
}
}
sendDeviceNotification
loops through a list of given devices getting their instance IDs that we got from Firebase and stored in Cloud Datastore and creates a data push message from the JSONObject passed in. It also checks the response from Firebase to update the instance IDs of any devices if needed.
Create a new Firebase application https://firebase.google.com/
Under Settings/Cloud Messaging copy the Server Key.
In your appengine-web.xml
copy the key.
<system-properties>
<property name="java.util.logging.config.file" value="WEB-INF/logging.properties" />
<property name="fcm.api.key" value="server-key"/>
</system-properties>
Device Status Endpoint
@Api(
name = "deviceStatus",
version = "v1",
namespace = @ApiNamespace(
ownerDomain = "your.app.domain",
ownerName = "your.app.name",
packagePath = ""
)
)
public class DeviceStatusEndpoint {
@ApiMethod(name = "updateDeviceConfig",
httpMethod = ApiMethod.HttpMethod.POST,
path = "updateDeviceConfig")
public void updateDeviceConfig(@Named("deviceId")String deviceId,@Named("registry")String registry,
DeviceConfiguration data)throws GeneralSecurityException, IOException, NotFoundException {
GoogleCredential credential = GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all());
JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential);
final CloudIot service = new CloudIot.Builder(GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init)
.setApplicationName("your-app-name").build();
ModifyCloudToDeviceConfigRequest req = new ModifyCloudToDeviceConfigRequest();
req.setVersionToUpdate(0L);
final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s",
"your-app-name", "us-central1", registry, deviceId);
Base64.Encoder encoder = Base64.getEncoder();
String encPayload = encoder.encodeToString(data.getData().getBytes("UTF-8"));
req.setBinaryData(encPayload);
DeviceConfig config = service.projects().locations().registries().devices()
.modifyCloudToDeviceConfig(devicePath, req).execute();
List<Device> devices = ofy().load().type(Device.class).filter("deviceName", "hub").list();
JSONObject json = new JSONObject();
json.put("device",deviceId);
FCMHelper fcm = new FCMHelper();
fcm.sendDeviceNotification(devices,json);
}
@ApiMethod(name = "getDeviceState",
httpMethod = ApiMethod.HttpMethod.GET,
path = "getDeviceState")
public CollectionResponse<DeviceConfig> getDeviceStates(@Named("deviceId")String deviceId, @Named("registry")String registry)
throws GeneralSecurityException, IOException {
GoogleCredential credential = GoogleCredential.getApplicationDefault().createScoped(CloudIotScopes.all());
JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
HttpRequestInitializer init = new RetryHttpInitializerWrapper(credential);
final CloudIot service = new CloudIot.Builder(
GoogleNetHttpTransport.newTrustedTransport(),jsonFactory, init)
.setApplicationName("your-project-name").build();
final String devicePath = String.format("projects/%s/locations/%s/registries/%s/devices/%s",
"your-project-name", "your-device-location", registry, deviceId);
ListDeviceConfigVersionsResponse resp = service.projects().locations().registries().devices().configVersions().list(devicePath).execute();
return CollectionResponse.<DeviceConfig>builder().setItems(resp.getDeviceConfigs()).build();
}
}
There are 2 calls in this endpoint, the first is updateDeviceConfig
which is what Android Things uses to turn on/off the lights, you can read more here about it. The second is getDeviceState
which for a given device ID and registry in IoT Core returns a list of the past few device configuration changes. This is used when Android Things boots back up so that it has the most recent information. You can read more about getting a device state here.
Create a Simple POJO IOTAuth that will hold the JWT that gets sent back.
IoT Auth Endpoint
public class IOTAuth {
private String mToken = "";
public String getToken(){
return mToken;
}
public void setToken(String token){
mToken = token;
}
}
Create a new endpoint class.
@Api(
name = "token",
version = "v1",
namespace = @ApiNamespace(
ownerDomain = "your.domain",
ownerName = "your.owner.name",
packagePath = ""
)
)
public class IOTAuthEndpoint {
@ApiMethod(name = "jwtGenerate",
httpMethod = ApiMethod.HttpMethod.GET,
path = "getJwt")
public IOTAuth getJwt() throws Exception{
DateTime now = new DateTime();
JwtBuilder jwtBuilder = Jwts.builder()
.setIssuedAt(now.toDate())
.setExpiration(now.plusDays(1).toDate())
.setAudience("your-cloud-project-name");
InputStream privateKey = Thread.currentThread().getContextClassLoader()
.getResourceAsStream("rsa_private_pkcs8");
byte[] keyBytes = ByteStreams.toByteArray(privateKey);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes);
KeyFactory kf = KeyFactory.getInstance("RSA");
String key = jwtBuilder.signWith(SignatureAlgorithm.RS256, kf.generatePrivate(spec)).compact();
IOTAuth auth = new IOTAuth();
auth.setToken(key);
return auth;
}
}
This is a HTTP GET call that will return a JWT that is valid for 24 hours. You need to create a public/private RS256 keys to authenticate a device with Cloud IoT Core. You can read how to create keys with openssl here: https://cloud.google.com/iot/docs/how-tos/credentials/keys
Once you created the keys, take your rsa_private_pkcs8 key that was generated and copy it into your App Engine project in Android Studio under your resources folder. In your appengine-web.xml
file you need to add this to be able to reference your key.
<resource-files>
<include path="/**" />
</resource-files>
Before we can publish the App Engine project we have to declare the endpoints in your web.xml
. Under the <servlet>
you add in the endpoint like this:
<servlet>
<servlet-name>EndpointsServlet</servlet-name>
<servlet-class>com.google.api.server.spi.EndpointsServlet</servlet-class>
<init-param>
<param-name>services</param-name>
<param-value>
your.class.path.RegistrationEndpoint,
your.class.path.DeviceStatusEndpoint,
your.class.path.IOTAuthEndpoint
</param-value>
</init-param>
</servlet>
You can view how to publish you backend here: https://cloud.google.com/appengine/docs/standard/java/tools/gradle
Once you publish your backend, you should be able to call it via a normal http get call with a URL like this: https://your-app-project-name/_ah/api/token/v1/getJwt
You can use Postman to verify that the call works.
Android ThingsYou can look through here how to get started with Android Things and how to flash your Raspberry Pi: https://developer.android.com/things/console/
Once you have your Pi flashed with Android Things go back in android studio and create a new Android Things module: https://developer.android.com/things/training/first-device/create-studio-project
What the Raspberry Pi serves in this project is a hub for communication to the ESP32 device. Basically the Pi gets the most recent configuration for the ESP32 and stores that information also setting alarms for when the lights need to turn on and off.
To connect your things project to your App Engine project add these items to your things build.gradle
:
apply plugin: 'com.google.cloud.tools.endpoints-framework-client'
buildscript {
repositories {
jcenter()
}
dependencies {
classpath 'com.google.cloud.tools:endpoints-framework-gradle-plugin:1.0.2'
}
}
dependencies{
implementation ('com.google.api-client:google-api-client-android:1.23.0'){
exclude module: 'httpclient'
}
endpointsServer project(path: ':backend', configuration: 'endpoints')
}
Now when you build your project, you will have access to all the endpoints because the classes will be generated for you.
MainActivity
class MainActivity: Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val i = Intent(this@MainActivity,BackgroundService::class.java)
i.action = BackgroundService.Constants.PUSH_ID
startService(i)
val intent = Intent(this@MainActivity,BackgroundService::class.java)
intent.action = BackgroundService.Constants.GET_CURRENT_DEVICE_CONFIG
intent.putExtra("device","garden_lights")
startService(intent)
val alarms = (application as App).mDatabase!!.alarmDao().getAllAlarms()
for (alarm: AlarmsEntity in alarms){
val device = (application as App).mDatabase!!.deviceDao().getDeviceFromID(alarm.deviceId)
setupNewAlarm(alarm.triggerHourOfDay,alarm.triggerMinuteOfDay,device.name,alarm.action,alarm.interval,alarm.state)
}
}
private fun setupNewAlarm(hourOfDay:Int, minute:Int, deviceName:String,action:String,interval:Long,state:String){
val calendar = Calendar.getInstance()
calendar.timeZone = TimeZone.getTimeZone("America/New_York")
calendar.set(Calendar.HOUR_OF_DAY, hourOfDay)
calendar.set(Calendar.MINUTE, minute)
val alarmManager : AlarmManager = systemService()
val i = Intent(this@MainActivity,AlarmBroadcastReceiver::class.java)
i.action = action
i.putExtra("device",deviceName)
i.putExtra("state",state)
val pendingIntent = PendingIntent.getBroadcast(this@MainActivity, 0, i, PendingIntent.FLAG_UPDATE_CURRENT)
alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, calendar.timeInMillis,interval, pendingIntent)
}
}
The activity is fairly simple, we start an intent to a service that registers the FCM instance ID with App Engine and then we check the database for any alarms that we currently have and sets them. This is to ensure that if the device ever loses power, it will continue right back where it left off once it comes back online.
Broadcast Receiver
class AlarmBroadcastReceiver :BroadcastReceiver() {
object Constants{
const val ALARM_EVENT_LIGHTS_ON = "your.class.path.AlarmBroadcastReceiver.ALARM_EVENT_LIGHTS_ON"
const val ALARM_EVENT_LIGHTS_OFF = "your.class.path.AlarmBroadcastReceiver.ALARM_EVENT_LIGHTS_OFF"
}
override fun onReceive(context: Context, intent: Intent) {
when (intent.action) {
AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_ON, AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_OFF -> {
val deviceEntity = (context.applicationContext as App).mDatabase!!.deviceDao().getDeviceFromName(intent.getStringExtra("device"))
val json = JSONObject(deviceEntity!!.stateData)
json.put("state",intent.getStringExtra("state"))
deviceEntity.stateData = json.toString()
(context.applicationContext as App).mDatabase!!.deviceDao().updateDeviceInfo(deviceEntity)
val i = Intent(context,BackgroundService::class.java)
i.action = BackgroundService.Constants.UPDATE_DEVICE_CONFIG
i.putExtra("config",json.toString())
i.putExtra("device",intent.getStringExtra("device"))
context.startService(i)
}
}
}
}
The broadcast receiver gets triggered when an alarm goes off and will turn on or off the lights depending on the broadcast action sending it config change to a service.
Intent Service
Now we will create a service that will handle all the processing.
getPushId()
gets an instance id from FCM and then we register it with our backend.
private fun getPushId(){
val id = FirebaseInstanceId.getInstance().getToken("your-fcm-senderid", "FCM")
val registration: Registration = Registration.Builder(AndroidHttp.newCompatibleTransport(),
AndroidJsonFactory(), null).setApplicationName("your-app-eninge-project-name").build()
try{
var pushId: Device = registration.register(id,"device-name",getLocalIpAddress(), Build.ID).execute()
val pref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
pref.edit().putString("key",id).apply()
}catch (e:Exception){
e.printStackTrace()
}
}
updateDeviceConfig()
sends an update to App Engine which then updates the ESP32 device config.
private fun updateDeviceConfig(device:String, config:String){
val deviceConfig = DeviceConfiguration()
deviceConfig.data = config
val deviceStatus = DeviceStatus.Builder(AndroidHttp.newCompatibleTransport(),
AndroidJsonFactory(), null).setApplicationName("your-app-eninge-project-name").build()
deviceStatus.updateDeviceConfig(device,"device-location",deviceConfig).execute()
}
getDeviceConfig
gets the most recent configuration that the ESP32 has and then we store the data in our database and update any alarms.
private fun getDeviceConfig(device:String){
val deviceStatus = DeviceStatus.Builder(AndroidHttp.newCompatibleTransport(),
AndroidJsonFactory(), null).setApplicationName("your-app-eninge-project-name").build()
try{
val deviceStateList = deviceStatus.getDeviceState(device,"device-location").execute()
if(deviceStateList != null && deviceStateList.size > 0){
val state = deviceStateList.items[0]
val data = Base64.decode(state.binaryData, Base64.DEFAULT)
val text = String(data, StandardCharsets.UTF_8)
val json = JSONObject(text)
val lastUpdated = json.getLong("updated")
val deviceEntity = (applicationContext as App).mDatabase!!.deviceDao().getDeviceFromName(device)
if(deviceEntity == null){
val newDevice = DeviceEntity()
newDevice.lastUpdated = lastUpdated
newDevice.stateData = text
newDevice.name = device
(applicationContext as App).mDatabase!!.deviceDao().createDevice(newDevice)
handleDevice(json,device)
}else{
if(lastUpdated > deviceEntity.lastUpdated){
handleDevice(json,device)
}
}
}
}catch (e:Exception){
e.printStackTrace()
}
}
handleDevice
takes the current configuration for a device and updates alarms.
private fun handleDevice(jsonObject: JSONObject,device:String){
//Do device specific logic here
when(device){
"device-name" -> {
val deviceEntity = (applicationContext as App).mDatabase!!.deviceDao().getDeviceFromName(device)
if(deviceEntity != null){
(applicationContext as App).mDatabase!!.alarmDao().deleteAlarmsForDevice(deviceEntity.id)
//set alarms
setupNewAlarm(jsonObject.getInt("onHourOfDay"),jsonObject.getInt("onMinuteOfHour"),
device, AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_ON,jsonObject.getLong("interval"),
"on")
var alarm = AlarmsEntity()
alarm.action = AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_ON
alarm.deviceId = deviceEntity.id
alarm.interval = jsonObject.getLong("interval")
alarm.isRepeating = true
alarm.state = "on"
alarm.triggerHourOfDay = jsonObject.getInt("onHourOfDay")
alarm.triggerMinuteOfDay = jsonObject.getInt("onMinuteOfHour")
(applicationContext as App).mDatabase!!.alarmDao().insertNewAlarm(alarm)
setupNewAlarm(jsonObject.getInt("offHourOfDay"),jsonObject.getInt("offMinuteOfHour"),
device, AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_OFF,jsonObject.getLong("interval"),
"off")
alarm = AlarmsEntity()
alarm.action = AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_OFF
alarm.deviceId = deviceEntity.id
alarm.interval = jsonObject.getLong("interval")
alarm.isRepeating = true
alarm.state = "off"
alarm.triggerHourOfDay = jsonObject.getInt("offHourOfDay")
alarm.triggerMinuteOfDay = jsonObject.getInt("offMinuteOfHour")
(applicationContext as App).mDatabase!!.alarmDao().insertNewAlarm(alarm)
//update device state in database
deviceEntity.stateData = jsonObject.toString()
deviceEntity.lastUpdated = System.currentTimeMillis()
(applicationContext as App).mDatabase!!.deviceDao().updateDeviceInfo(deviceEntity)
}
}
}
}
Here is the full class:
class BackgroundService: IntentService("BackgroundService") {
object Constants{
const val PUSH_ID = "your.package.name.PUSH_ID"
const val UPDATE_DEVICE_CONFIG = "your.package.name.UPDATE_DEVICE_CONFIG"
const val GET_CURRENT_DEVICE_CONFIG = "your.package.name.GET_CURRENT_DEVICE_CONFIG"
}
override fun onHandleIntent(intent: Intent) {
when(intent.action){
Constants.PUSH_ID -> getPushId()
Constants.UPDATE_DEVICE_CONFIG -> updateDeviceConfig(intent.getStringExtra("device"),intent.getStringExtra("config"))
Constants.GET_CURRENT_DEVICE_CONFIG -> getDeviceConfig(intent.getStringExtra("device"))
}
}
private fun getPushId(){
val id = FirebaseInstanceId.getInstance().getToken("your-fcm-senderid", "FCM")
val registration: Registration = Registration.Builder(AndroidHttp.newCompatibleTransport(),
AndroidJsonFactory(), null).setApplicationName("your-app-eninge-project-name").build()
try{
var pushId: Device = registration.register(id,"device-name",getLocalIpAddress(), Build.ID).execute()
val pref: SharedPreferences = PreferenceManager.getDefaultSharedPreferences(this)
pref.edit().putString("key",id).apply()
}catch (e:Exception){
e.printStackTrace()
}
}
private fun getLocalIpAddress(): String? {
try {
val en = NetworkInterface.getNetworkInterfaces()
while (en.hasMoreElements()) {
val intf = en.nextElement()
val enumIpAddr = intf.inetAddresses
while (enumIpAddr.hasMoreElements()) {
val inetAddress = enumIpAddr.nextElement()
if (!inetAddress.isLoopbackAddress && inetAddress is Inet4Address) {
return inetAddress.hostAddress.toString()
}
}
}
} catch (ex: SocketException) {
ex.printStackTrace()
}
return null
}
private fun updateDeviceConfig(device:String, config:String){
val deviceConfig = DeviceConfiguration()
deviceConfig.data = config
val deviceStatus = DeviceStatus.Builder(AndroidHttp.newCompatibleTransport(),
AndroidJsonFactory(), null).setApplicationName("your-app-eninge-project-name").build()
deviceStatus.updateDeviceConfig(device,"device-location",deviceConfig).execute()
}
private fun getDeviceConfig(device:String){
val deviceStatus = DeviceStatus.Builder(AndroidHttp.newCompatibleTransport(),
AndroidJsonFactory(), null).setApplicationName("your-app-eninge-project-name").build()
try{
val deviceStateList = deviceStatus.getDeviceState(device,"device-location").execute()
if(deviceStateList != null && deviceStateList.size > 0){
val state = deviceStateList.items[0]
val data = Base64.decode(state.binaryData, Base64.DEFAULT)
val text = String(data, StandardCharsets.UTF_8)
val json = JSONObject(text)
val lastUpdated = json.getLong("updated")
val deviceEntity = (applicationContext as App).mDatabase!!.deviceDao().getDeviceFromName(device)
if(deviceEntity == null){
val newDevice = DeviceEntity()
newDevice.lastUpdated = lastUpdated
newDevice.stateData = text
newDevice.name = device
(applicationContext as App).mDatabase!!.deviceDao().createDevice(newDevice)
handleDevice(json,device)
}else{
if(lastUpdated > deviceEntity.lastUpdated){
handleDevice(json,device)
}
}
}
}catch (e:Exception){
e.printStackTrace()
}
}
private fun setupNewAlarm(hourOfDay:Int, minute:Int, deviceName:String,action:String,interval:Long,state:String){
val calendar = Calendar.getInstance()
calendar.set(Calendar.HOUR_OF_DAY, hourOfDay)
calendar.set(Calendar.MINUTE, minute)
calendar.timeZone = TimeZone.getTimeZone("America/New_York")
val alarmManager : AlarmManager = systemService()
val i = Intent(this@BackgroundService,AlarmBroadcastReceiver::class.java)
i.action = action
i.putExtra("device",deviceName)
i.putExtra("state",state)
val pendingIntent = PendingIntent.getBroadcast(this@BackgroundService, 0, i, PendingIntent.FLAG_UPDATE_CURRENT)
// AlarmManager.INTERVAL_DAY
alarmManager.setRepeating(AlarmManager.RTC_WAKEUP, calendar.timeInMillis,interval, pendingIntent)
}
private fun cancelDeviceAlarm(deviceName:String,action:String,state:String){
val alarmManager : AlarmManager = systemService()
val i = Intent(this@BackgroundService,AlarmBroadcastReceiver::class.java)
i.action = action
i.putExtra("device",deviceName)
i.putExtra("state",state)
val pendingIntent = PendingIntent.getBroadcast(this@BackgroundService, 0, i, PendingIntent.FLAG_CANCEL_CURRENT)
alarmManager.cancel(pendingIntent)
}
private fun handleDevice(jsonObject: JSONObject,device:String){
//Do device specific logic here
when(device){
"garden_lights" -> {
val deviceEntity = (applicationContext as App).mDatabase!!.deviceDao().getDeviceFromName(device)
if(deviceEntity != null){
(applicationContext as App).mDatabase!!.alarmDao().deleteAlarmsForDevice(deviceEntity.id)
//set alarms
setupNewAlarm(jsonObject.getInt("onHourOfDay"),jsonObject.getInt("onMinuteOfHour"),
device, AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_ON,jsonObject.getLong("interval"),
"on")
var alarm = AlarmsEntity()
alarm.action = AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_ON
alarm.deviceId = deviceEntity.id
alarm.interval = jsonObject.getLong("interval")
alarm.isRepeating = true
alarm.state = "on"
alarm.triggerHourOfDay = jsonObject.getInt("onHourOfDay")
alarm.triggerMinuteOfDay = jsonObject.getInt("onMinuteOfHour")
(applicationContext as App).mDatabase!!.alarmDao().insertNewAlarm(alarm)
setupNewAlarm(jsonObject.getInt("offHourOfDay"),jsonObject.getInt("offMinuteOfHour"),
device, AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_OFF,jsonObject.getLong("interval"),
"off")
alarm = AlarmsEntity()
alarm.action = AlarmBroadcastReceiver.Constants.ALARM_EVENT_LIGHTS_OFF
alarm.deviceId = deviceEntity.id
alarm.interval = jsonObject.getLong("interval")
alarm.isRepeating = true
alarm.state = "off"
alarm.triggerHourOfDay = jsonObject.getInt("offHourOfDay")
alarm.triggerMinuteOfDay = jsonObject.getInt("offMinuteOfHour")
(applicationContext as App).mDatabase!!.alarmDao().insertNewAlarm(alarm)
//update device state in database
deviceEntity.stateData = jsonObject.toString()
deviceEntity.lastUpdated = System.currentTimeMillis()
(applicationContext as App).mDatabase!!.deviceDao().updateDeviceInfo(deviceEntity)
}
}
}
}
}
The final thing to do on the Raspberry Pi is setup Firebase Cloud Messaging which you can read about here.
Arduino ESP32We will need a few libraries for the Arduino device.
ArduinoJson - for parsing configurations. You dont need to use this if you want to have your configuration data in another format.
Adafruit Neopixel - This library is used for interfacing with the LED lights. Another popular library is Fast LED however this does not work with the ESP32, there is a fork of the fast LED library by someone who added compatibility with the ESP32 if you want to use that though.
MQTTClient - This is the only MQTT library that I found to work with IoT Core because of the requirement to us TLS/SSL
Timer - This is for sending a heartbeat back to IoT Core to stay connected (The IoT Core server will automatically disconnect your device after ~20 minutes due to inactivity) sending a heartbeat every 10-15 min will keep the device connected. Also we use it to refresh the JWT since it expires after 24 hours, if this is also not refreshed the device will get disconnected.
TLS/SSL with Arduino and Cloud IoT Core SecurityThis section took me some time to figure out what exactly Cloud IoT Core was looking for and what App Engine was looking for since both need. Not all Arduino device support TLS/SSL so please make sure the device you pick does if you don't use the Adafruit HUZZAH ESP32.
As for the security side of things for IoT Core a Json Web Token can not have an expiration date longer than 24 hours so you have to have a mechanism for updating keys. You can read more about how IoT Core uses JWT here. We solve the problem of updating keys with the Timer library and we just call out App Engine backend to update it.
There are 4 certificate keys that have to be included in the project to be able to connect to Google Cloud via wifi.
The first is the Google Root Certificate, you can get this by downloading them from here.
The next two are the public private certificates you created earlier. The private certificate starts like this -----BEGIN RSA PRIVATE KEY----- and the public certificate starts like this -----BEGIN CERTIFICATE-----. Make sure you keep track of what certificate is what for later.
The final certificate is your App Engine public key which you can get by pinging your App Engine URL from openssl.
To be able to connect to Google Cloud you need to use Arduino's WifiClientSecure library, the normal WifiClient will not work.
Arduino CodeLet's start setting up the Arduino device. We need to import these libraries
#include <Arduino.h>
#include <WiFiClientSecure.h>
#include <ArduinoJson.h>
#include <Adafruit_NeoPixel.h>
#include <MQTTClient.h>
#include <HTTPClient.h>
#include <Timer.h>
Now let's declare the number of LEDs and the pin to use to send the LED data to. Also we create wifi, MQTT client, timer and Adafruit pixel variables.
#define PIN 12
#define NUMPIXELS 96
WiFiClientSecure wifi;
MQTTClient client(1024);
Timer t;
Adafruit_NeoPixel pixels = Adafruit_NeoPixel(NUMPIXELS, PIN, NEO_GRBW + NEO_KHZ800);
Next we declare our constants such as the App Engine URL, your IoT Core device path and certificates.
const char* appEngineUrl = "https://your-project-name.appspot.com/_ah/api/token/v1/getJwt";
const char* device = "projects/your-project-name/locations/your-location/registries/your-registry/devices/device-name";
const char* googleRootCertificate =
"-----BEGIN CERTIFICATE-----\n" \
"MIIEXDCCA0SgAwIBAgINAeOpMBz8cgY4P5pTHTANBgkqhkiG9w0BAQsFADBMMSAw\n" \
"HgYDVQQLExdHbG9iYWxTaWduIFJvb3QgQ0EgLSBSMjETMBEGA1UEChMKR2xvYmFs\n" \
"U2lnbjETMBEGA1UEAxMKR2xvYmFsU2lnbjAeFw0xNzA2MTUwMDAwNDJaFw0yMTEy\n" \
"MTUwMDAwNDJaMFQxCzAJBgNVBAYTAlVTMR4wHAYDVQQKExVHb29nbGUgVHJ1c3Qg\n" \
"U2VydmljZXMxJTAjBgNVBAMTHEdvb2dsZSBJbnRlcm5ldCBBdXRob3JpdHkgRzMw\n" \
"ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDKUkvqHv/OJGuo2nIYaNVW\n" \
"XQ5IWi01CXZaz6TIHLGp/lOJ+600/4hbn7vn6AAB3DVzdQOts7G5pH0rJnnOFUAK\n" \
"71G4nzKMfHCGUksW/mona+Y2emJQ2N+aicwJKetPKRSIgAuPOB6Aahh8Hb2XO3h9\n" \
"RUk2T0HNouB2VzxoMXlkyW7XUR5mw6JkLHnA52XDVoRTWkNty5oCINLvGmnRsJ1z\n" \
"ouAqYGVQMc/7sy+/EYhALrVJEA8KbtyX+r8snwU5C1hUrwaW6MWOARa8qBpNQcWT\n" \
"kaIeoYvy/sGIJEmjR0vFEwHdp1cSaWIr6/4g72n7OqXwfinu7ZYW97EfoOSQJeAz\n" \
"AgMBAAGjggEzMIIBLzAOBgNVHQ8BAf8EBAMCAYYwHQYDVR0lBBYwFAYIKwYBBQUH\n" \
"AwEGCCsGAQUFBwMCMBIGA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFHfCuFCa\n" \
"Z3Z2sS3ChtCDoH6mfrpLMB8GA1UdIwQYMBaAFJviB1dnHB7AagbeWbSaLd/cGYYu\n" \
"MDUGCCsGAQUFBwEBBCkwJzAlBggrBgEFBQcwAYYZaHR0cDovL29jc3AucGtpLmdv\n" \
"b2cvZ3NyMjAyBgNVHR8EKzApMCegJaAjhiFodHRwOi8vY3JsLnBraS5nb29nL2dz\n" \
"cjIvZ3NyMi5jcmwwPwYDVR0gBDgwNjA0BgZngQwBAgIwKjAoBggrBgEFBQcCARYc\n" \
"aHR0cHM6Ly9wa2kuZ29vZy9yZXBvc2l0b3J5LzANBgkqhkiG9w0BAQsFAAOCAQEA\n" \
"HLeJluRT7bvs26gyAZ8so81trUISd7O45skDUmAge1cnxhG1P2cNmSxbWsoiCt2e\n" \
"ux9LSD+PAj2LIYRFHW31/6xoic1k4tbWXkDCjir37xTTNqRAMPUyFRWSdvt+nlPq\n" \
"wnb8Oa2I/maSJukcxDjNSfpDh/Bd1lZNgdd/8cLdsE3+wypufJ9uXO1iQpnh9zbu\n" \
"FIwsIONGl1p3A8CgxkqI/UAih3JaGOqcpcdaCIzkBaR9uYQ1X4k2Vg5APRLouzVy\n" \
"7a8IVk6wuy6pm+T7HT4LY8ibS5FEZlfAFLSW8NwsVz9SBK2Vqn1N0PIMn5xA6NZV\n" \
"c7o835DLAFshEWfC7TIe3g==\n" \
"-----END CERTIFICATE-----\n";
const char* yourPrivateCert =
"-----BEGIN RSA PRIVATE KEY-----\n"
"-----END RSA PRIVATE KEY-----\n";
const char* yourPublicCertificate =
"-----BEGIN CERTIFICATE-----\n"
"-----END CERTIFICATE-----\n";
const char* appEngineCert =
"-----BEGIN CERTIFICATE-----\n"
"-----END CERTIFICATE-----\n";
In the setup method, we connect to the wifi and setup the MQTT client, get a new JWT and set the timers.
void setup() {
Serial.begin(9600);
pixels.begin();
Serial.println();
Serial.println("Starting...");
WiFi.begin(ssid,password);
while(WiFi.status() != WL_CONNECTED){
delay(500);
Serial.print(".");
}
Serial.println();
Serial.print("Connected, IP address: ");
Serial.println(WiFi.localIP());
wifi.setCACert(googleRootCertificate);
wifi.setCertificate(yourPublicCertificate);
wifi.setPrivateKey(yourPrivateCert);
getNewToken();
client.begin("mqtt.googleapis.com", 8883, wifi);
client.onMessage(messageReceived);
t.every(900000,sendHeartbeat);
t.every(86400000,updateToken);
}
Getting a JWTWe created a rest call in our backend to get a JWT easily, so now we need to call it in our Arduino code.
void getNewToken(){
HTTPClient http;
http.begin(appEngineUrl,appEngineCert);
int httpCode = http.GET();
if(httpCode == 200){
String json = http.getString();
StaticJsonBuffer<500> jsonBuffer;
JsonObject& root = jsonBuffer.parseObject(json);
jwt = root["token"].asString();
Serial.println(jwt);
}else{
Serial.println("Error on HTTP request");
}
http.end();
}
We need to give the http client the App Engine URL and the App Engine public certificate. Then we simply call http.GET, check the response code, and if successful, we hold the JWT in a variable.
In the loop method, we constantly check the wifi connection to make sure that we are connected, and reconnect if we lose connection, then check the connection to the MQTT server in IoT Core and connect if we are not.
MQTT Client Connectvoid reconnect() {
Serial.println("Connecting to mqtt");
//max time jwt can be good for is 1 day
while (!client.connect(device,"unused",jwt.c_str())) {
handleError(client);
Serial.print("Trying again in ");
Serial.print(exponentialBackoff/1000);
Serial.print(" seconds");
Serial.println();
delay(exponentialBackoff);
exponentialBackoff = exponentialBackoff*2;
if(exponentialBackoff > 300000){
Serial.println("Resetting exponentialBackoff");
break;
}
}
if(client.connected()){
Serial.println();
Serial.println("Connected to mqtt");
exponentialBackoff = 2000;
if(client.subscribe("/devices/device-name/config")){
Serial.println("Listening for config changes");
}else{
Serial.println("Error subscribing to config changes");
}
}else{
exponentialBackoff = 2000;
}
}
To connect to the MQTT server we need to send the device path and the JWT in the password field. Note that IoT Cloud ignores the username. To learn more about connecting to the MQTT server you can read here. Once we are connected we subscribe to the device config change topic. Also note that there are only. select few topics you can pub/sub to so please make sure you review them, it took me longer than I would like to admin to figure out you cannot create your own custom topics.
Sending a heartbeat
So that we stay connected to the MQTT server we send a heartbeat telemetry event to the server every ten minutes.
void sendHeartbeat(){
if(client.connected()){
client.publish("/devices/device-name/events","heartbeat");
Serial.println("Heartbeat sent");
}
}
Listening for config changes
Now that we have subscribed to the config change topic we need to have somewhere for the message to go. We create a callback method where we get notified when a config change happens then update the LEDs accordingly.
void messageReceived(String &topic, String &payload) {
Serial.println("---Config Change---");
Serial.println(payload);
DynamicJsonBuffer buffer(1024);
JsonObject& root = buffer.parseObject(payload);
handleConfig(root);
}
Changing LightsMy configuration that gets sent to the Arduino device is a simple Json string that looks like this:
{
"state": "off",
"brightness": 255,
"onHourOfDay": 20,
"onMinuteOfHour": 30,
"offHourOfDay": 6,
"offMinuteOfHour": 0,
"interval": 86400000,
"updated": 1525666013977,
"R": 0,
"G": 0,
"B": 0,
"W": 255
}
In our handleConfig is where we update the LEDs to the RBGW values of the config.
void handleConfig(JsonObject& config){
if(config.success()){
if(config["state"] == "on"){
turnOnLights(config["R"],config["G"],config["B"],config["W"],config["brightness"]);
}else{
turnOffLights();
}
}else{
Serial.println("Unable to parse json");
}
}
We check the state, if the state is on, we then pass the RGBW values to the LEDs.
for(int i=0;i<NUMPIXELS;i++){
if(r > 0 || g > 0 || b> 0){
w = 0;
}
pixels.setPixelColor(i, pixels.Color(r,g,b,w));
}
pixels.show();
We simply loop through each LED and set the values. I added in extra logic so that if we send in a RGB value, we turn off the white part of the LED, and if there is no RGB value, we use the white part of the LED.
To turn off the LEDs we simply set the RGBW values to 0.
void turnOffLights(){
for(int i=0;i<NUMPIXELS;i++){
pixels.setPixelColor(i, pixels.Color(0,0,0,0));
}
pixels.show();
}
Building the lightsNow that we have the code all setup, it is time to build the lights. The NeoPixel rings have 6 pins on them but we only need to use 4: the Power, Ground, Data in and Data out. The lights require a 5V power supply to power them, the rings can be chained but I instead decided to just tap into a long power line that stretches to the last light so that I have consistent power to all the lights.
I then took apart the solar lights, taking out the inside lights/battery, then cut a hole in them so that the connector would sit at the top and the wires would come down a hole in the middle.
I also just put some epoxy on the bottom of the ring to glue them to the light. I also drilled a hole all down through the light so that I could feed the wire out at the bottom to connect to the main power line with the waterproof wire connectors.
The wiring looks like this:
Red is power and power connects to power.
Black is ground and ground connects to ground.
Green is data, starting from the Arduino data, goes to the data in, then a line from the data out on the lights go to the data in of the next light.
The blue squares are the waterproof connectors that you bury under the ground.
I also made a quick mobile app to change the time and color of the lights with a color picker but that is up to you if you want to create that. It's not needed and you can change the configuration of the ESP32 from the Google Cloud IoT console.
Comments