diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..bdb0cabc --- /dev/null +++ b/.gitattributes @@ -0,0 +1,17 @@ +# Auto detect text files and perform LF normalization +* text=auto + +# Custom for Visual Studio +*.cs diff=csharp + +# Standard to msysgit +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain diff --git a/.gitignore b/.gitignore new file mode 100644 index 00000000..77b7014f --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +release.properties +dependency-reduced-pom.xml +buildNumber.properties +*.iml +data +.idea diff --git a/.settings/org.eclipse.jdt.core.prefs b/.settings/org.eclipse.jdt.core.prefs new file mode 100644 index 00000000..714351ae --- /dev/null +++ b/.settings/org.eclipse.jdt.core.prefs @@ -0,0 +1,5 @@ +eclipse.preferences.version=1 +org.eclipse.jdt.core.compiler.codegen.targetPlatform=1.8 +org.eclipse.jdt.core.compiler.compliance=1.8 +org.eclipse.jdt.core.compiler.problem.forbiddenReference=warning +org.eclipse.jdt.core.compiler.source=1.8 diff --git a/.settings/org.eclipse.m2e.core.prefs b/.settings/org.eclipse.m2e.core.prefs new file mode 100644 index 00000000..f897a7f1 --- /dev/null +++ b/.settings/org.eclipse.m2e.core.prefs @@ -0,0 +1,4 @@ +activeProfiles= +eclipse.preferences.version=1 +resolveWorkspaceProjects=true +version=1 diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..8f71f43f --- /dev/null +++ b/LICENSE @@ -0,0 +1,202 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright {yyyy} {name of copyright owner} + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + diff --git a/README.md b/README.md new file mode 100644 index 00000000..ec45c345 --- /dev/null +++ b/README.md @@ -0,0 +1,38 @@ +# amazon-echo-ha-bridge-compact +emulates philips hue api to other home automation gateways. The Amazon echo now supports wemo and philip hue... great news if you own any of those devices! +My house is pretty heavily invested in the z-wave using the Vera as the gateway and thought it would be nice bridge the Amazon Echo to it. + +Build +----- +The server defaults to running on port 8080. If you're already running a server (like openHAB) on 8080, edit ```server.port``` in ```src/main/resources/application.properties``` to your desired port before building the jar. Alternately you can pass in a command line argument to override ```server.port```. + +To customize and build it yourself, build a new jar with maven: +``` +mvn install +``` +Then locate the jar and start the server with: +``` +java -jar -Dupnp.config.address=192.168.1.Z target/amazon-echo-bridge-compact0.X.Y.jar +``` +replace the --upnp.config.address value with the server ipv4 address. + +Then configure by going to the /configurator.html url +``` +http://192.168.1.240:8080 +``` +or Register a device, via REST by binding some sort of on/off (vera style) url +``` +POST http://host:8080/api/devices +{ +"name" : "bedroom light", +"deviceType" : "switch", + "onUrl" : "http://192.168.1.201:3480/data_request?id=action&output_format=json&serviceId=urn:upnp-org:serviceId:SwitchPower1&action=SetTarget&newTargetValue=1&DeviceNum=41", + "offUrl" : "http://192.168.1.201:3480/data_request?id=action&output_format=json&serviceId=urn:upnp-org:serviceId:SwitchPower1&action=SetTarget&newTargetValue=0&DeviceNum=41" +} +``` + +After this Tell Alexa: "Alexa, discover my devices" + +Then you can say "Alexa, Turn on the office light" or whatever name you have given your configured devices. + +To view or remove devices that Alexa knows about, you can use the mobile app Menu / Settings / Connected Home diff --git a/pom.xml b/pom.xml new file mode 100644 index 00000000..446932f6 --- /dev/null +++ b/pom.xml @@ -0,0 +1,86 @@ + + + 4.0.0 + + com.bwssytems.HABridge + amazon-echo-bridge-compact + 0.1.0 + jar + + Amazon Echo Bridge Compact + Emulates a Philips Hue bridge to allow the Amazon Echo to hook up to other HA using lightweight frameworks + + + 1.8 + 1.8 + 1.8 + + + + + com.sparkjava + spark-core + 2.2 + + + org.apache.httpcomponents + httpclient + 4.3.6 + + + org.slf4j + slf4j-simple + 1.7.5 + + + com.google.code.gson + gson + 2.2.4 + + + com.fasterxml.jackson.core + jackson-databind + 2.6.0 + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 2.3 + + true + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + + + + + + + package + + shade + + + + + com.bwssytems.HABridge.AmazonEchoBridge + + + + + + + + + diff --git a/src/main/java/com/bwssytems/HABridge/AmazonEchoBridge.java b/src/main/java/com/bwssytems/HABridge/AmazonEchoBridge.java new file mode 100644 index 00000000..66e11d3f --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/AmazonEchoBridge.java @@ -0,0 +1,59 @@ +package com.bwssytems.HABridge; + +import static spark.Spark.*; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.bwssytems.HABridge.devicemanagmeent.*; +import com.bwssytems.HABridge.hue.HueMulator; +import com.bwssytems.HABridge.upnp.UpnpListener; +import com.bwssytems.HABridge.upnp.UpnpSettingsResource; + +public class AmazonEchoBridge { + + /* + * This program is based on the work of armzilla from this github repository: + * https://github.com/armzilla/amazon-echo-ha-bridge + * + * This is the main entry point to start the amazon echo bridge. + * + * This program is using sparkjava rest server to build all the http calls. + * Sparkjava is a microframework that uses Jetty webserver module to host + * its' calls. This is a very compact system than using the spring frameworks + * that was previously used. + * + * There is a custom upnp listener that is started to handle discovery. + * + * This application does not store the lights configuration persistently. + * + * + */ + public static void main(String[] args) { + Logger log = LoggerFactory.getLogger(AmazonEchoBridge.class); + DeviceResource theResources; + HueMulator theHueMulator; + UpnpSettingsResource theSettingResponder; + UpnpListener theUpnpListener; + + // sparkjava config directive to set ip address for the web server to listen on + ipAddress(System.getProperty("upnp.config.address", "0.0.0.0")); + // sparkjava config directive to set port for the web server to listen on + port(Integer.valueOf(System.getProperty("server.port", "8080"))); + // sparkjava config directive to set html static file location for Jetty + staticFileLocation("/public"); + log.debug("Starting setup...."); + // setup the class to handle the resource setup rest api + theResources = new DeviceResource(); + // setup the class to handle the hue emulator rest api + theHueMulator = new HueMulator(theResources.getDeviceRepository()); + // setup the class to handle the upnp response rest api + theSettingResponder = new UpnpSettingsResource(); + // wait for the sparkjava initialization of the rest api classes to be complete + awaitInitialization(); + // start the upnp ssdp discovery listener + theUpnpListener = new UpnpListener(); + log.debug("Done setup, application to run...."); + theUpnpListener.startListening(); + } +} diff --git a/src/main/java/com/bwssytems/HABridge/JsonTransformer.java b/src/main/java/com/bwssytems/HABridge/JsonTransformer.java new file mode 100644 index 00000000..c8b57823 --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/JsonTransformer.java @@ -0,0 +1,17 @@ +package com.bwssytems.HABridge; + +import com.google.gson.Gson; +import spark.ResponseTransformer; +/* + * Implementation of a Json renderer through google GSON utility. + */ +public class JsonTransformer implements ResponseTransformer { + + private Gson gson = new Gson(); + + @Override + public String render(Object model) { + return gson.toJson(model); + } + +} \ No newline at end of file diff --git a/src/main/java/com/bwssytems/HABridge/api/Device.java b/src/main/java/com/bwssytems/HABridge/api/Device.java new file mode 100644 index 00000000..06fd68ce --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/api/Device.java @@ -0,0 +1,43 @@ +package com.bwssytems.HABridge.api; + +/** + * Created by arm on 4/13/15. + */ +public class Device { + private String name; + private String deviceType; + private String offUrl; + private String onUrl; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDeviceType() { + return deviceType; + } + + public void setDeviceType(String deviceType) { + this.deviceType = deviceType; + } + + public String getOffUrl() { + return offUrl; + } + + public void setOffUrl(String offUrl) { + this.offUrl = offUrl; + } + + public String getOnUrl() { + return onUrl; + } + + public void setOnUrl(String onUrl) { + this.onUrl = onUrl; + } +} diff --git a/src/main/java/com/bwssytems/HABridge/api/hue/DeviceResponse.java b/src/main/java/com/bwssytems/HABridge/api/hue/DeviceResponse.java new file mode 100644 index 00000000..d92f2322 --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/api/hue/DeviceResponse.java @@ -0,0 +1,122 @@ +package com.bwssytems.HABridge.api.hue; + +import java.util.HashMap; +import java.util.LinkedList; +import java.util.List; +import java.util.Map; + +/** + * Created by arm on 4/14/15. + */ +public class DeviceResponse { + private DeviceState state; + private String type; + private String name; + private String modelid; + private String manufacturername; + private String uniqueid; + private String swversion; + private Map pointsymbol; + + public DeviceState getState() { + return state; + } + + public void setState(DeviceState state) { + this.state = state; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getModelid() { + return modelid; + } + + public void setModelid(String modelid) { + this.modelid = modelid; + } + + public String getManufacturername() { + return manufacturername; + } + + public void setManufacturername(String manufacturername) { + this.manufacturername = manufacturername; + } + + public String getUniqueid() { + return uniqueid; + } + + public void setUniqueid(String uniqueid) { + this.uniqueid = uniqueid; + } + + public String getSwversion() { + return swversion; + } + + public void setSwversion(String swversion) { + this.swversion = swversion; + } + + public Map getPointsymbol() { + Map dummyValue = new HashMap<>(); + dummyValue.put("1", "none"); + dummyValue.put("2", "none"); + dummyValue.put("3", "none"); + dummyValue.put("4", "none"); + dummyValue.put("5", "none"); + dummyValue.put("6", "none"); + dummyValue.put("7", "none"); + dummyValue.put("8", "none"); + + return dummyValue; + } + + public void setPointsymbol(Map pointsymbol) { + this.pointsymbol = pointsymbol; + } + + public static DeviceResponse createResponse(String name, String id){ + DeviceState deviceState = new DeviceState(); + DeviceResponse response = new DeviceResponse(); + response.setState(deviceState); + deviceState.setOn(false); + deviceState.setReachable(true); + deviceState.setEffect("none"); + deviceState.setAlert("none"); + deviceState.setBri(254); + deviceState.setHue(15823); + deviceState.setSat(88); + deviceState.setCt(313); + + List xv = new LinkedList<>(); + xv.add(0.4255); + xv.add(0.3998); + deviceState.setXy(xv); + deviceState.setColormode("ct"); + response.setName(name); + response.setUniqueid(id); + response.setManufacturername("Philips"); + response.setType("Extended color light"); + response.setModelid("LCT001"); + response.setSwversion("65003148"); + + return response; + } +} diff --git a/src/main/java/com/bwssytems/HABridge/api/hue/DeviceState.java b/src/main/java/com/bwssytems/HABridge/api/hue/DeviceState.java new file mode 100644 index 00000000..8779f436 --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/api/hue/DeviceState.java @@ -0,0 +1,107 @@ +package com.bwssytems.HABridge.api.hue; + +import java.util.List; + +/** + * Created by arm on 4/14/15. + */ +public class DeviceState { + private boolean on; + private int bri = 255; + private int hue; + private int sat; + private String effect; + private int ct; + private String alert; + private String colormode; + private boolean reachable; + private List xy; + + public boolean isOn() { + return on; + } + + public void setOn(boolean on) { + this.on = on; + } + + public int getBri() { + return bri; + } + + public void setBri(int bri) { + this.bri = bri; + } + + public int getHue() { + return hue; + } + + public void setHue(int hue) { + this.hue = hue; + } + + public int getSat() { + return sat; + } + + public void setSat(int sat) { + this.sat = sat; + } + + public String getEffect() { + return effect; + } + + public void setEffect(String effect) { + this.effect = effect; + } + + public int getCt() { + return ct; + } + + public void setCt(int ct) { + this.ct = ct; + } + + public String getAlert() { + return alert; + } + + public void setAlert(String alert) { + this.alert = alert; + } + + public String getColormode() { + return colormode; + } + + public void setColormode(String colormode) { + this.colormode = colormode; + } + + public boolean isReachable() { + return reachable; + } + + public void setReachable(boolean reachable) { + this.reachable = reachable; + } + + public List getXy() { + return xy; + } + + public void setXy(List xy) { + this.xy = xy; + } + + @Override + public String toString() { + return "DeviceState{" + + "on=" + on + + ", bri=" + bri + + '}'; + } +} diff --git a/src/main/java/com/bwssytems/HABridge/api/hue/HueApiResponse.java b/src/main/java/com/bwssytems/HABridge/api/hue/HueApiResponse.java new file mode 100644 index 00000000..f89ba756 --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/api/hue/HueApiResponse.java @@ -0,0 +1,20 @@ +package com.bwssytems.HABridge.api.hue; + +import java.util.Map; + +import com.bwssytems.HABridge.api.hue.DeviceResponse; + +/** + * Created by arm on 4/14/15. + */ +public class HueApiResponse { + private Map lights; + + public Map getLights() { + return lights; + } + + public void setLights(Map lights) { + this.lights = lights; + } +} diff --git a/src/main/java/com/bwssytems/HABridge/dao/DeviceDescriptor.java b/src/main/java/com/bwssytems/HABridge/dao/DeviceDescriptor.java new file mode 100644 index 00000000..b412da9c --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/dao/DeviceDescriptor.java @@ -0,0 +1,51 @@ +package com.bwssytems.HABridge.dao; +/* + * Object to handle the device configuration + */ +public class DeviceDescriptor{ + private String id; + private String name; + private String deviceType; + private String offUrl; + private String onUrl; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getDeviceType() { + return deviceType; + } + + public void setDeviceType(String deviceType) { + this.deviceType = deviceType; + } + + public String getOffUrl() { + return offUrl; + } + + public void setOffUrl(String offUrl) { + this.offUrl = offUrl; + } + + public String getOnUrl() { + return onUrl; + } + + public void setOnUrl(String onUrl) { + this.onUrl = onUrl; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } +} diff --git a/src/main/java/com/bwssytems/HABridge/dao/DeviceRepository.java b/src/main/java/com/bwssytems/HABridge/dao/DeviceRepository.java new file mode 100644 index 00000000..620b4206 --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/dao/DeviceRepository.java @@ -0,0 +1,54 @@ +package com.bwssytems.HABridge.dao; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Map; +import java.util.Random; + +import com.bwssytems.HABridge.dao.DeviceDescriptor; + +import java.util.List; +/* + * This is an in memory list to manage the configured devices. + * + */ +public class DeviceRepository { + Map devices; + final Random random = new Random(); + + public DeviceRepository() { + super(); + devices = new HashMap(); + } + + public List findAll() { + List list = new ArrayList(devices.values()); + return list; + } + + public List findByDeviceType(String aType) { + List list = new ArrayList(devices.values()); + return list; + } + + public DeviceDescriptor findOne(String id) { + return devices.get(id); + + } + + public void save(DeviceDescriptor aDescriptor) { + int id = random.nextInt(Integer.MAX_VALUE); + aDescriptor.setId(String.valueOf(id)); + devices.put(String.valueOf(id),aDescriptor); + } + + public String delete(DeviceDescriptor aDescriptor) { + if (aDescriptor != null) { + devices.remove(aDescriptor.getId()); + return "Device with id '" + aDescriptor.getId() + "' deleted"; + } else { + return "Device not found"; + } + + } +} diff --git a/src/main/java/com/bwssytems/HABridge/devicemanagmeent/DeviceResource.java b/src/main/java/com/bwssytems/HABridge/devicemanagmeent/DeviceResource.java new file mode 100644 index 00000000..ce4a9ef1 --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/devicemanagmeent/DeviceResource.java @@ -0,0 +1,106 @@ +package com.bwssytems.HABridge.devicemanagmeent; + +import com.bwssytems.HABridge.JsonTransformer; +import com.bwssytems.HABridge.dao.DeviceDescriptor; +import com.bwssytems.HABridge.dao.DeviceRepository; + +import static spark.Spark.get; +import static spark.Spark.post; +import static spark.Spark.put; +import static spark.Spark.delete; + +import java.util.List; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; + +/** + spark core server for bridge configuration + */ +public class DeviceResource { + private static final String API_CONTEXT = "/api/devices"; + private static final Logger log = LoggerFactory.getLogger(DeviceResource.class); + + private DeviceRepository deviceRepository; + + + public DeviceResource() { + super(); + deviceRepository = new DeviceRepository(); + setupEndpoints(); + } + + public DeviceRepository getDeviceRepository() { + return deviceRepository; + } + + private void setupEndpoints() { + log.debug("Setting up endpoints"); + post(API_CONTEXT + "/", "application/json", (request, response) -> { + log.debug("Create a Device - request body: " + request.body()); + DeviceDescriptor device = new Gson().fromJson(request.body(), DeviceDescriptor.class); + DeviceDescriptor deviceEntry = new DeviceDescriptor(); + deviceEntry.setName(device.getName()); + log.debug("Create a Device - device json name: " + deviceEntry.getName()); + deviceEntry.setDeviceType(device.getDeviceType()); + log.debug("Create a Device - device json type:" + deviceEntry.getDeviceType()); + deviceEntry.setOnUrl(device.getOnUrl()); + log.debug("Create a Device - device json on URL:" + deviceEntry.getOnUrl()); + deviceEntry.setOffUrl(device.getOffUrl()); + log.debug("Create a Device - device json off URL:" + deviceEntry.getOffUrl()); + + deviceRepository.save(deviceEntry); + log.debug("Created a Device"); + + response.status(201); + return deviceEntry; + }, new JsonTransformer()); + + put (API_CONTEXT + "/:id", "application/json", (request, response) -> { + log.debug("Saved a Device"); + DeviceDescriptor device = new Gson().fromJson(request.body(), DeviceDescriptor.class); + DeviceDescriptor deviceEntry = deviceRepository.findOne(request.params(":id")); + if(deviceEntry == null){ + return null; + } + + deviceEntry.setName(device.getName()); + deviceEntry.setDeviceType(device.getDeviceType()); + deviceEntry.setOnUrl(device.getOnUrl()); + deviceEntry.setOffUrl(device.getOffUrl()); + + deviceRepository.save(deviceEntry); + return deviceEntry; + }, new JsonTransformer()); + + get (API_CONTEXT + "/", "application/json", (request, response) -> { + List deviceList = deviceRepository.findAll(); + log.debug("Get all devices"); + JsonTransformer aRenderer = new JsonTransformer(); + String theStream = aRenderer.render(deviceList); + log.debug("The Device List: " + theStream); + return deviceList; + }, new JsonTransformer()); + + get (API_CONTEXT + "/:id", "application/json", (request, response) -> { + log.debug("Get a device"); + DeviceDescriptor descriptor = deviceRepository.findOne(request.params(":id")); + if(descriptor == null){ + return null; + } + return descriptor; + }, new JsonTransformer()); + + delete (API_CONTEXT + "/:id", "application/json", (request, response) -> { + log.debug("Delete a device"); + DeviceDescriptor deleted = deviceRepository.findOne(request.params(":id")); + if(deleted == null){ + return null; + } + deviceRepository.delete(deleted); + return null; + }, new JsonTransformer()); + } +} diff --git a/src/main/java/com/bwssytems/HABridge/hue/HueMulator.java b/src/main/java/com/bwssytems/HABridge/hue/HueMulator.java new file mode 100644 index 00000000..13661a5d --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/hue/HueMulator.java @@ -0,0 +1,197 @@ +package com.bwssytems.HABridge.hue; + +import com.bwssytems.HABridge.api.hue.DeviceResponse; +import com.bwssytems.HABridge.api.hue.DeviceState; +import com.bwssytems.HABridge.api.hue.HueApiResponse; +import com.bwssytems.HABridge.dao.*; +import com.bwssytems.HABridge.JsonTransformer; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; + +import static spark.Spark.get; +import static spark.Spark.post; +import static spark.Spark.put; + +import org.apache.http.HttpResponse; +import org.apache.http.client.HttpClient; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.HttpClients; +import org.apache.http.util.EntityUtils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * Based on Armzilla's HueMulator - a Philips Hue emulator using sparkjava rest server + */ + +public class HueMulator { + private static final Logger log = LoggerFactory.getLogger(HueMulator.class); + private static final String INTENSITY_PERCENT = "${intensity.percent}"; + private static final String INTENSITY_BYTE = "${intensity.byte}"; + private static final String HUE_CONTEXT = "/api"; + + private DeviceRepository repository; + private HttpClient httpClient; + private ObjectMapper mapper; + + + public HueMulator(DeviceRepository aDeviceRepository){ + httpClient = HttpClients.createMinimal(); + mapper = new ObjectMapper(); //armzilla: work around Echo incorrect content type and breaking mapping. Map manually + mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + repository = aDeviceRepository; + setupEndpoints(); + } + +// This function sets up the sparkjava rest calls for the hue api + private void setupEndpoints() { +// http://ip_address:port/api/{userId}/lights returns json objects of all lights configured + get(HUE_CONTEXT + "/:userid/lights", "application/json", (request, response) -> { + String userId = request.params(":userid"); + log.info("hue lights list requested: " + userId + " from " + request.ip()); + List deviceList = repository.findByDeviceType("switch"); + JsonTransformer aRenderer = new JsonTransformer(); + String theStream = aRenderer.render(deviceList); + log.debug("The Device List: " + theStream); + Map deviceResponseMap = new HashMap<>(); + for (DeviceDescriptor device : deviceList) { + deviceResponseMap.put(device.getId(), device.getName()); + } + response.status(200); + return deviceResponseMap; + } , new JsonTransformer()); + +// http://ip_address:port/api/* returns json object for a test call + post(HUE_CONTEXT + "/*", "application/json", (request, response) -> { + response.status(200); + return "[{\"success\":{\"username\":\"lights\"}}]"; + } ); + +// http://ip_address:port/api/{userId} returns json objects for the list of names of lights + get(HUE_CONTEXT + "/:userid", "application/json", (request, response) -> { + String userId = request.params(":userid"); + log.info("hue api root requested: " + userId + " from " + request.ip()); + List descriptorList = repository.findByDeviceType("switch"); + if (descriptorList == null) { + response.status(404); + return null; + } + Map deviceList = new HashMap<>(); + + descriptorList.forEach(descriptor -> { + DeviceResponse deviceResponse = DeviceResponse.createResponse(descriptor.getName(), descriptor.getId()); + deviceList.put(descriptor.getId(), deviceResponse); + } + ); + HueApiResponse apiResponse = new HueApiResponse(); + apiResponse.setLights(deviceList); + + response.status(200); + return apiResponse; + }, new JsonTransformer()); + +// http://ip_address:port/api/{userId}/lights/{lightId} returns json object for a given light + get(HUE_CONTEXT + "/:userid/lights/:id", "application/json", (request, response) -> { + String userId = request.params(":userid"); + String lightId = request.params(":id"); + log.info("hue light requested: " + lightId + "for user: " + userId + " from " + request.ip()); + DeviceDescriptor device = repository.findOne(lightId); + if (device == null) { + response.status(404); + return null; + } else { + log.info("found device named: " + device.getName()); + } + DeviceResponse lightResponse = DeviceResponse.createResponse(device.getName(), device.getId()); + + response.status(200); + return lightResponse; + }, new JsonTransformer()); + +// http://ip_address:port/api/{userId}/lights/{lightId}/state uses json object to set the lights state + put(HUE_CONTEXT + "/:userid/lights/:id/state", "application/json", (request, response) -> { + /** + * strangely enough the Echo sends a content type of application/x-www-form-urlencoded even though + * it sends a json object + */ + String userId = request.params(":userid"); + String lightId = request.params(":id"); + log.info("hue state change requested: " + userId + " from " + request.ip()); + log.info("hue stage change body: " + request.body() ); + + DeviceState state = null; + try { + state = mapper.readValue(request.body(), DeviceState.class); + } catch (IOException e) { + log.info("object mapper barfed on input", e); + response.status(400); + return null; + } + + DeviceDescriptor device = repository.findOne(lightId); + if (device == null) { + response.status(404); + return null; + } + + String responseString; + String url; + if (state.isOn()) { + responseString = "[{\"success\":{\"/lights/" + lightId + "/state/on\":true}}]"; + url = device.getOnUrl(); + } else { + responseString = "[{\"success\":{\"/lights/" + lightId + "/state/on\":false}}]"; + url = device.getOffUrl(); + } + + /* light weight templating here, was going to use free marker but it was a bit too + * heavy for what we were trying to do. + * + * currently provides only two variables: + * intensity.byte : 0-255 brightness. this is raw from the echo + * intensity.percent : 0-100, adjusted for the vera + */ + if(url.contains(INTENSITY_BYTE)){ + String intensityByte = String.valueOf(state.getBri()); + url = url.replace(INTENSITY_BYTE, intensityByte); + }else if(url.contains(INTENSITY_PERCENT)){ + int percentBrightness = (int) Math.round(state.getBri()/255.0*100); + String intensityPercent = String.valueOf(percentBrightness); + url = url.replace(INTENSITY_PERCENT, intensityPercent); + } + + //make call + if(!doHttpGETRequest(url)){ + response.status(503); + return null; + } + + response.status(200); + return responseString; + }); + } + +// This function executes the url from the device repository against the vera + protected boolean doHttpGETRequest(String url) { + log.info("calling GET on URL: " + url); + HttpGet httpGet = new HttpGet(url); + try { + HttpResponse response = httpClient.execute(httpGet); + EntityUtils.consume(response.getEntity()); //close out inputstream ignore content + log.info("GET on URL responded: " + response.getStatusLine().getStatusCode()); + if(response.getStatusLine().getStatusCode() == 200){ + return true; + } + } catch (IOException e) { + log.error("Error calling out to HA gateway", e); + } + return false; + } +} diff --git a/src/main/java/com/bwssytems/HABridge/upnp/UpnpListener.java b/src/main/java/com/bwssytems/HABridge/upnp/UpnpListener.java new file mode 100644 index 00000000..5b215d1f --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/upnp/UpnpListener.java @@ -0,0 +1,108 @@ +package com.bwssytems.HABridge.upnp; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.net.*; + +import java.util.Enumeration; +import org.apache.http.conn.util.*; + + +public class UpnpListener { + private Logger log = LoggerFactory.getLogger(UpnpListener.class); + private static final int UPNP_DISCOVERY_PORT = 1900; + private static final String UPNP_MULTICAST_ADDRESS = "239.255.255.250"; + + private int upnpResponsePort; + + private int httpServerPort; + + private String responseAddress; + + public UpnpListener() { + super(); + upnpResponsePort = Integer.valueOf(System.getProperty("upnp.response.port", "50000")); + httpServerPort = Integer.valueOf(System.getProperty("server.port", "8080")); + responseAddress = System.getProperty("upnp.config.address", "192.168.14.136"); + } + + public void startListening(){ + log.info("Starting UPNP Discovery Listener"); + + try (DatagramSocket responseSocket = new DatagramSocket(upnpResponsePort); + MulticastSocket upnpMulticastSocket = new MulticastSocket(UPNP_DISCOVERY_PORT);) { + InetSocketAddress socketAddress = new InetSocketAddress(UPNP_MULTICAST_ADDRESS, UPNP_DISCOVERY_PORT); + Enumeration ifs = NetworkInterface.getNetworkInterfaces(); + + while (ifs.hasMoreElements()) { + NetworkInterface xface = ifs.nextElement(); + Enumeration addrs = xface.getInetAddresses(); + String name = xface.getName(); + int IPsPerNic = 0; + + while (addrs.hasMoreElements()) { + InetAddress addr = addrs.nextElement(); + log.debug(name + " ... has addr " + addr); + if (InetAddressUtils.isIPv4Address(addr.getHostAddress())) { + IPsPerNic++; + } + } + log.debug("Checking " + name + " to our interface set"); + if (IPsPerNic > 0) { + upnpMulticastSocket.joinGroup(socketAddress, xface); + log.debug("Adding " + name + " to our interface set"); + } + } + + while(true){ //trigger shutdown here + byte[] buf = new byte[1024]; + DatagramPacket packet = new DatagramPacket(buf, buf.length); + upnpMulticastSocket.receive(packet); + String packetString = new String(packet.getData()); + if(isSSDPDiscovery(packetString)){ + log.debug("Got SSDP Discovery packet from " + packet.getAddress().getHostAddress() + ":" + packet.getPort()); + sendUpnpResponse(responseSocket, packet.getAddress(), packet.getPort()); + } + } + + } catch (IOException e) { + log.error("UpnpListener encountered an error. Shutting down", e); + + } + log.info("UPNP Discovery Listener Stopped"); + + } + + /** + * very naive ssdp discovery packet detection + * @param body + * @return + */ + protected boolean isSSDPDiscovery(String body){ + if(body != null && body.startsWith("M-SEARCH * HTTP/1.1") && body.contains("MAN: \"ssdp:discover\"")){ + return true; + } + return false; + } + + String discoveryTemplate = "HTTP/1.1 200 OK\r\n" + + "CACHE-CONTROL: max-age=86400\r\n" + + "EXT:\r\n" + + "LOCATION: http://%s:%s/upnp/amazon-ha-bridge/setup.xml\r\n" + + "OPT: \"http://schemas.upnp.org/upnp/1/0/\"; ns=01\r\n" + + "01-NLS: %s\r\n" + + "ST: urn:schemas-upnp-org:device:basic:1\r\n" + + "USN: uuid:Socket-1_0-221438K0100073::urn:Belkin:device:**\r\n\r\n"; + protected void sendUpnpResponse(DatagramSocket socket, InetAddress requester, int sourcePort) throws IOException { + String discoveryResponse = String.format(discoveryTemplate, responseAddress, httpServerPort, getRandomUUIDString()); + log.debug("sndUpnpResponse: " + discoveryResponse); + DatagramPacket response = new DatagramPacket(discoveryResponse.getBytes(), discoveryResponse.length(), requester, sourcePort); + socket.send(response); + } + + protected String getRandomUUIDString(){ + return "88f6698f-2c83-4393-bd03-cd54a9f8595"; // https://xkcd.com/221/ + } +} diff --git a/src/main/java/com/bwssytems/HABridge/upnp/UpnpSettingsResource.java b/src/main/java/com/bwssytems/HABridge/upnp/UpnpSettingsResource.java new file mode 100644 index 00000000..ec820472 --- /dev/null +++ b/src/main/java/com/bwssytems/HABridge/upnp/UpnpSettingsResource.java @@ -0,0 +1,56 @@ +package com.bwssytems.HABridge.upnp; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import static spark.Spark.get; + +/** + * + */ +public class UpnpSettingsResource { + private static final String UPNP_CONTEXT = "/upnp"; + + private Logger log = LoggerFactory.getLogger(UpnpSettingsResource.class); + + private String hueTemplate = "\n" + "\n" + + "\n" + "1\n" + "0\n" + "\n" + + "http://%s:%s/\n" + // hostname string + "\n" + "urn:schemas-upnp-org:device:Basic:1\n" + + "Amazon-Echo-HA-Bridge (%s)\n" + + "Royal Philips Electronics\n" + + "http://www.armzilla..com\n" + + "Hue Emulator for Amazon Echo bridge\n" + + "Philips hue bridge 2012\n" + "929000226503\n" + + "http://www.armzilla.com/amazon-echo-ha-bridge\n" + + "01189998819991197253\n" + + "uuid:88f6698f-2c83-4393-bd03-cd54a9f8595\n" + "\n" + "\n" + + "(null)\n" + "(null)\n" + + "(null)\n" + "(null)\n" + + "(null)\n" + "\n" + "\n" + + "index.html\n" + "\n" + "\n" + + "image/png\n" + "48\n" + "48\n" + + "24\n" + "hue_logo_0.png\n" + "\n" + "\n" + + "image/png\n" + "120\n" + "120\n" + + "24\n" + "hue_logo_3.png\n" + "\n" + "\n" + "\n" + + "\n"; + + public UpnpSettingsResource() { + super(); + setupListener(); + } + + private void setupListener () { +// http://ip_address:port/upnp/:id/setup.xml which returns the xml configuration for the location of the hue emulator + get(UPNP_CONTEXT + "/:id/setup.xml", "application/xml", (request, response) -> { + log.info("upnp device settings requested: " + request.params(":id") + " from " + request.ip()); + String hostName = System.getProperty("upnp.config.address", "192.168.1.1"); + String portNumber = Integer.toString(request.port()); + String filledTemplate = String.format(hueTemplate, hostName, portNumber, hostName); + log.debug("upnp device settings response: " + filledTemplate); + response.status(201); + + return filledTemplate; + } ); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties new file mode 100644 index 00000000..05d4948f --- /dev/null +++ b/src/main/resources/application.properties @@ -0,0 +1,3 @@ +upnp.response.port=50000 +server.port=8080 +upnp.config.address=192.168.4.136 \ No newline at end of file diff --git a/src/main/resources/public/index.html b/src/main/resources/public/index.html new file mode 100644 index 00000000..eca186db --- /dev/null +++ b/src/main/resources/public/index.html @@ -0,0 +1,179 @@ + + + + + + + Amazon Echo Bridge Configuration + + + + + + + + + + + + + +
+
+ +
+

Bridge settings

+
+ +
+ + +
+ +
+ + +
+
+
+ +
+ +
+ +
+

Current devices

+ + + + + + + + + + + + + + + +
IDNameTypeActions
{{device.id}}{{device.name}}{{device.deviceType}} + + + + +
+
+
+ +
+
+

Add a new device

+
    +
  • +

    You can generate on/off URLs by filling in the Vera server URL, port, and + device ID; or you can fill them out manually in the lower section.

    + +
    +
    + + +
    + +
    +
    +
    + + +
    + +
    + + + +
    + +
    + +
    +
    +
  • +
  • +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    + + +
    + +
    + +
    +
    +
  • +
+
+
+
+ + + + \ No newline at end of file diff --git a/src/main/resources/public/scripts/app.js b/src/main/resources/public/scripts/app.js new file mode 100644 index 00000000..0ba11a9c --- /dev/null +++ b/src/main/resources/public/scripts/app.js @@ -0,0 +1,145 @@ +angular.module('amazonechobridge', []) + .service('bridgeService', ["$http", function ($http) { + var self = this; + this.state = {base: window.location.origin + "/api/devices/", devices: [], error: ""}; + + this.viewDevices = function () { + this.state.error = ""; + return $http.get(this.state.base).then( + function (response) { + self.state.devices = response.data; + }, + function (error) { + if (error.data) { + self.state.error = error.data.message; + } else { + self.state.error = "If you're not seeing any devices, you may be running into problems with CORS. " + + "You can work around this by running a fresh launch of Chrome with the --disable-web-security flag."; + } + console.log(error); + } + ); + }; + + this.addDevice = function (id, name, type, onUrl, offUrl) { + this.state.error = ""; + if (id) { + var putUrl = this.state.base + "/" + id; + return $http.put(putUrl, { + id: id, + name: name, + deviceType: type, + onUrl: onUrl, + offUrl: offUrl + }).then( + function (response) { + self.viewDevices(); + }, + function (error) { + if (error.data) { + self.state.error = error.data.message; + } + console.log(error); + } + ); + } else { + return $http.post(this.state.base, { + name: name, + deviceType: type, + onUrl: onUrl, + offUrl: offUrl + }).then( + function (response) { + self.viewDevices(); + }, + function (error) { + if (error.data) { + self.state.error = error.data.message; + } + console.log(error); + } + ); + } + }; + + this.deleteDevice = function (id) { + this.state.error = ""; + return $http.delete(this.state.base + "/" + id).then( + function (response) { + self.viewDevices(); + }, + function (error) { + if (error.data) { + self.state.error = error.data.message; + } + console.log(error); + } + ); + }; + + this.editDevice = function (id, name, type, onUrl, offUrl) { + this.device.id = id; + this.device.name = name; + this.device.onUrl = onUrl; + this.device.offUrl = offUrl; + }; + }]) + + .controller('ViewingController', ["$scope", "bridgeService", function ($scope, bridgeService) { + bridgeService.viewDevices(); + $scope.bridge = bridgeService.state; + $scope.deleteDevice = function (device) { + bridgeService.deleteDevice(device.id); + }; + $scope.testUrl = function (url) { + window.open(url, "_blank"); + }; + $scope.setBridgeUrl = function (url) { + bridgeService.state.base = url; + bridgeService.viewDevices(); + }; + $scope.editDevice = function (device) { + bridgeService.editDevice(device.id, device.name, device.type, device.onUrl, device.offUrl); + }; + }]) + + .controller('AddingController', ["$scope", "bridgeService", function ($scope, bridgeService) { + + $scope.bridge = bridgeService.state; + $scope.device = {id: "", name: "", type: "switch", onUrl: "", offUrl: ""}; + $scope.vera = {base: "", port: "3480", id: ""}; + bridgeService.device = $scope.device; + + $scope.buildUrls = function () { + if ($scope.vera.base.indexOf("http") < 0) { + $scope.vera.base = "http://" + $scope.vera.base; + } + $scope.device.onUrl = $scope.vera.base + ":" + $scope.vera.port + + "/data_request?id=action&output_format=json&serviceId=urn:upnp-org:serviceId:SwitchPower1&action=SetTarget&newTargetValue=1&DeviceNum=" + + $scope.vera.id; + $scope.device.offUrl = $scope.vera.base + ":" + $scope.vera.port + + "/data_request?id=action&output_format=json&serviceId=urn:upnp-org:serviceId:SwitchPower1&action=SetTarget&newTargetValue=0&DeviceNum=" + + $scope.vera.id; + }; + + $scope.testUrl = function (url) { + window.open(url, "_blank"); + }; + + $scope.addDevice = function () { + bridgeService.addDevice($scope.device.id, $scope.device.name, $scope.device.type, $scope.device.onUrl, $scope.device.offUrl).then( + function () { + $scope.device.id = ""; + $scope.device.name = ""; + $scope.device.onUrl = ""; + $scope.device.offUrl = ""; + }, + function (error) { + } + ); + } + }]) + + .controller('ErrorsController', ["$scope", "bridgeService", function ($scope, bridgeService) { + $scope.bridge = bridgeService.state; + }]); \ No newline at end of file diff --git a/src/test/java/demo/DemoApplicationTests.java b/src/test/java/demo/DemoApplicationTests.java new file mode 100644 index 00000000..52e70372 --- /dev/null +++ b/src/test/java/demo/DemoApplicationTests.java @@ -0,0 +1,14 @@ +package demo; + +import com.bwssytems.HABridge.AmazonEchoBridge; + +/* + * Dummy test holder + */ + +public class DemoApplicationTests { + + public void contextLoads() { + } + +}