diff --git a/SmartApp.groovy b/Obsolete/SmartApp.groovy similarity index 100% rename from SmartApp.groovy rename to Obsolete/SmartApp.groovy diff --git a/iaq-vent.groovy b/Obsolete/iaq-vent.groovy similarity index 99% rename from iaq-vent.groovy rename to Obsolete/iaq-vent.groovy index 3c05df8..2708589 100644 --- a/iaq-vent.groovy +++ b/Obsolete/iaq-vent.groovy @@ -58,4 +58,4 @@ def handleLevel(evt) { thermostat.each {it.setThermostatFanMode("auto");} } -} \ No newline at end of file +} diff --git a/Obsolete/readme b/Obsolete/readme new file mode 100644 index 0000000..d2b37c7 --- /dev/null +++ b/Obsolete/readme @@ -0,0 +1 @@ +This folder contains the smartapp and devicehandlers before I used the GitHubIntegration. diff --git a/README.md b/README.md index 00223e8..6c90718 100644 --- a/README.md +++ b/README.md @@ -1,28 +1,47 @@ -# AirMentorPro2_SmartThings - Overall Setup Icon -What you need: - -Raspberry PI 3 with Apache2 and PHP5 installed properly

- Raspberry Pi PHP and Apache installation instructions (https://www.raspberrypi.org/documentation/remote-access/web-server/apache.md) -Assign a static IP address to your raspberry on your local network. This project works only if your Hub and raspberry are on same network(otherwise the HubAction won't work and you need to implement external HTTPrequest instead)

-Optional: 1 USB dongle BT-LE (Plugable Dual-Mode BT-LE/BT model USB-BT4LE) I didn't make it with the internal BT-LE of the Pi using the oringinal image on it, I used this external one then. Then recently, the Raspberry Pi internal BTLE works fine using Linux raspberrypi 4.4.50-v7+ #970 SMP Mon Feb 20 19:18:29 GMT 2017 armv7l GNU/Linux< image from the Raspberry website

-Additional installation on Raspberry:

-
  • Bluez (http://www.elinux.org/RPi_Bluetooth_LE)

  • -
  • BluePy (https://github.com/IanHarvey/bluepy)

  • -
  • requests (http://raspberrypi-aa.github.io/session4/requests.html)

  • -Put in Raspberry /var/www/html folder the file : airmentorpro2.php

    -Put in /home/pi/Documents the python script airmentorpro2.py

    -You will launch this first python script by: sudo /usr/bin/python /var/www/html/airmentorpro2.py [your AirMentor MAC] [your hci#] &

    Example: sudo /usr/bin/python /var/www/html/airmentorpro2.py fe:ed:fa:ce:be:ef 0 & 

    As this script runs an infinit loop, better to fork it with &

    -Put in /home/pi/Documents the python script undergroundweather.py

    -This requires you to get a Weather UnderGround API key from https://www.wunderground.com/weather/api/

    The information is used to provide more data about outside conditions. If you don't want to use this, check the previous versions of the DTH and html page on this GitHub.

    -

    You will launche this script by: sudo /usr/bin/python /var/www/html/undergroundweather.py [yourAPI key] [state] [city] & -

    As this script runs an infinit loop, better to fork it with & too

    -In Smartthings IDE: Create a Device Handler (then save and publish for yourself) from air-mentor-pro-2.groovy

    -In Smartthings IDE: Create a SmartApp (then save and publish for yourself) from SmartApp.groovy. The Smartapp is here to allow the alerting on pollution level and polluant. This is an optional app, if you don't want to be notified, you can ignore it.

    -In Smartthings IDE: Create a SmartApp (then save and publish for yourself) from iaq-vent.groovy. The Smartapp is here to allow piloting vents/swtiches based on selected levels of IAQ

    -Create a device in Smartthings web page based on this device handler. Put anything as Device Network Id as the Device Handler will overwrite it at first run. Don't ever change it after if your raspberry doesn't change its static IP address otherwise, the parse method is sent for some reason to the former device despite the HubAction is sent by the new instance...

    -Configure the Smarthing device with the IP, port of the Raspberry and URL of the webpage and self-refreshing regularly.You can also access the web page directly by a http://[yourraspberry IP]/airmentorpro2.php?Action=get

    -IMPORTANT: use pollster smartapp to cadence the polling (every 5min) otherwise, Smartthing known issue will let the DTH stoping the polling after 24h or so.

    +

    Raspberry Pi 3 side: + +

    1. Apache2 and PHP5 properly installed (see here)

    +Assign a static IP address to your raspberry on your local network. This project works only if your Hub and raspberry are on same network (otherwise the HubAction won't work and you need to implement external HTTPrequest instead)

    +Optional: 1 USB dongle BT-LE (Plugable Dual-Mode BT-LE/BT model USB-BT4LE) I didn't make it with the internal BT-LE of the Pi using the oringinal image on it, I used this external one then. Then recently, the Raspberry Pi internal BTLE works fine using Linux raspberrypi 4.4.50-v7+ #970 SMP Mon Feb 20 19:18:29 GMT 2017 armv7l GNU/Linux< image from the Raspberry website (or above)

    +

    2. Additional installation on Raspberry:

    +

    Bluez (http://www.elinux.org/RPi_Bluetooth_LE)

    +

    BluePy (https://github.com/IanHarvey/bluepy)

    +

    >requests (http://raspberrypi-aa.github.io/session4/requests.html)

    +

    3. This project files installation:

    +

  • Put in Raspberry /var/www/html folder the file : airmentorpro2.php
  • +
  • Put in /home/pi the python script airmentorpro2.py
  • +
  • Put in /home/pi the python script ssdp_server.py (this file uses wlan0 as interface. You can change the code to use eth0 or other)
  • +
  • Put in /home/pi the python script undergroundweather.py
  • +This requires you to get a Weather UnderGround API key from https://www.wunderground.com/weather/api/

    The information is used to provide more data about outside conditions. If you don't want to use this, check the previous versions of the DTH and html page on this GitHub. +
  • Create a folder "lib" in /home/pi
  • +
  • Put in /home/pi/lib the 2 files ssdp.py and upnp_http_server.py.
  • +This is mandatory the 2 files are in a lib folder and the lib folder at the same location as ssdp_server.py +
  • In /etc/rc.local, just before the exit 0 (last line):
  • +
  • add: sudo /usr/bin/python /home/pi/airmentorpro2.py [your AirMentor MAC] [your hci#] &

    Example: sudo /usr/bin/python /home/pi/airmentorpro2.py fe:ed:fa:ce:be:ef 0 & 

  • + +
  • add: /usr/bin/python /home/pi/ssdp.py &
  • +
  • add sudo /usr/bin/python /var/www/html/undergroundweather.py [yourAPI key] [state] [city] &
  • + +

    Smarthings IDE side:

    + +

  • with a github enabled SmartThings IDE (see here), import the namespace philippeportesppo and repository AirMentorPro2_SmartThings on master branch
  • and the following device handlers and smartapps: +

    SmartApps: +
  • AirMentor Pro UPnP Service Manager : the smartapp managing the ssdp discover.
  • +
  • IAQ_vent : the smartapp managing vents and AC fans upon air quality notification
  • +
  • Notify Me When for AirMentor Pro : the smartapp managing events and notify you about the air quality.
  • +

    Device Handler: +
  • Air Mentor Pro 2 : the device handler to access the AirMentor Pro 2
  • +

    +If you cannot access github integration, you might have to create the devicehandler and smartapps manually from the code. + +

    SmartThing Mobile app:

    +

  • Go to Smartapps section and add the AirMentor Pro UPnP Service Manager smartapp from "My Apps"
  • +
  • Start the research, few seconds later the pi will be discovered, select it, press next and save.
  • +
  • AirMentor Pro 2 will be added + + +

    IMPORTANT:

    use pollster smartapp to cadence the polling (every 5min) otherwise, Smartthing known issue will let the DTH stoping the polling after 24h or so.

    Hope you like it. + diff --git a/air-mentor-pro-2.groovy b/devicetypes/philippeportesppo/air-mentor-pro-2.src/air-mentor-pro-2.groovy similarity index 96% rename from air-mentor-pro-2.groovy rename to devicetypes/philippeportesppo/air-mentor-pro-2.src/air-mentor-pro-2.groovy index 3179685..20470ff 100644 --- a/air-mentor-pro-2.groovy +++ b/devicetypes/philippeportesppo/air-mentor-pro-2.src/air-mentor-pro-2.groovy @@ -28,9 +28,9 @@ metadata { preferences { section { - input "internal_ip", "text", title: "Internal IP", required: true - input "internal_port", "text", title: "Internal Port (80)", required: true - input "internal_query_path", "text", title: "Internal query Path (/airmentorpro2.php?Action=get)", required: true + //input "internal_ip", "text", title: "Internal IP", required: true + //input "internal_port", "text", title: "Internal Port (80)", required: true + //input "internal_query_path", "text", title: "Internal query Path", defaultValue: "/airmentorpro2.php?Action=get", required: true } } @@ -216,6 +216,20 @@ tiles(scale: 2) { def installed() { log.debug "Executing 'installed'" + log.debug getDataValue("ip") + log.debug getDataValue("port") + + state.IAQ_event = "" + state.CO2_event = "" + state.PM25_event= "" + state.PM10_event= "" + state.TVOC_event= "" + state.requestCounter = 0 + + log.debug "state events initialized..." + + refresh() + } @@ -227,14 +241,7 @@ def updated() { } def initialize() { - state.IAQ_event = "" - state.CO2_event = "" - state.PM25_event= "" - state.PM10_event= "" - state.TVOC_event= "" - state.requestCounter = 0 - log.debug "state events initialized..." } @@ -375,11 +382,6 @@ def parse(description) { events << createEvent(name: "UGWtemperaturecallevel", value: convertTemperature(UGW_Temp_float.toFloat(),temperatureScale), unit: temperatureScale) events << createEvent(name: "UGW_Icon_UrlIcon", value: UGW_Icon_Nt.toString()+UGW_Icon_Url.toString()) - - - state.refreshCounter = state.refreshCounter + 1 - // log.debug state.refreshCounter - log.debug "Generating alerts if not good" def map = generate_app_event( "IAQ",iaq_int.toInteger(), state.IAQ_event, 50, 100,150, 200) @@ -497,17 +499,23 @@ private String convertPortToHex(port) { def refresh() { log.debug "Executing refresh" - def host = internal_ip - def port = internal_port - def hosthex = convertIPtoHex(host) - def porthex = convertPortToHex(port) - //log.debug "The device id before update is: $device.deviceNetworkId" - device.deviceNetworkId = "$hosthex:$porthex" + def host = getDataValue("ip")//internal_ip + log.debug "Executing refresh 2" + + def port = getDataValue("port")//internal_port + log.debug "Executing refresh 3" + + //def hosthex = convertIPtoHex(host) + log.debug "Executing refresh 4" + + //def porthex = convertPortToHex(port) + log.debug "The device id before update is: $device.deviceNetworkId" + device.deviceNetworkId = "$host:$port" - //log.debug "The device id configured is: $device.deviceNetworkId" + log.debug "The device id configured is: $device.deviceNetworkId" - def path = internal_query_path - //log.debug "path is: $path" + def path = getDataValue("query_path") + log.debug "path is: $path" def headers = [:] headers.put("HOST", "$host:$port") diff --git a/smartapps/philippeportesppo/airmentor-pro-upnp-service-manager.src/airmentor-pro-upnp-service-manager.groovy b/smartapps/philippeportesppo/airmentor-pro-upnp-service-manager.src/airmentor-pro-upnp-service-manager.groovy new file mode 100644 index 0000000..c71c37c --- /dev/null +++ b/smartapps/philippeportesppo/airmentor-pro-upnp-service-manager.src/airmentor-pro-upnp-service-manager.groovy @@ -0,0 +1,198 @@ +/** + * Generic UPnP Service Manager + * + * Copyright 2018 Philippe Portes based on SmartThings original UPnP Service Manager SmartApp + * + * 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. + * + */ +definition( + name: "AirMentor Pro UPnP Service Manager", + namespace: "philippeportesppo", + author: "Philippe Portes", + description: "Discover and configures your Raspberry Pi bridge to AirMentor Pro (see https://github.com/philippeportesppo/AirMentorPro2_SmartThings).", + category: "SmartThings Labs", + iconUrl: "https://raw.githubusercontent.com/philippeportesppo/AirMentorPro2_SmartThings/master/images/app-icon_bw.png", + iconX2Url: "https://raw.githubusercontent.com/philippeportesppo/AirMentorPro2_SmartThings/master/images/app-icon_bw.png", + iconX3Url: "https://raw.githubusercontent.com/philippeportesppo/AirMentorPro2_SmartThings/master/images/app-icon_bw.png") + + +preferences { + page(name: "searchTargetSelection", title: "UPnP AirMentor via Pi Search Target", nextPage: "deviceDiscovery") { + section("Search Target") { + input "searchTarget", "string", title: "Search Target", defaultValue: "urn:schemas-upnp-org:device:AirMentorPro2:1", required: true + } + } + page(name: "deviceDiscovery", title: "UPnP Device Setup", content: "deviceDiscovery") +} + +def deviceDiscovery() { + def options = [:] + def devices = getVerifiedDevices() + devices.each { + def value = it.value.name ?: "AirMentorPro ${it.value.ssdpUSN.split(':')[1][-3..-1]}" + def key = it.value.mac + options["${key}"] = value + } + + ssdpSubscribe() + + ssdpDiscover() + verifyDevices() + + return dynamicPage(name: "deviceDiscovery", title: "Discovery Started!", nextPage: "", refreshInterval: 5, install: true, uninstall: true) { + section("Please wait while we discover your AirMentorPro Device. Discovery can take five minutes or more, so sit back and relax! Select your device below once discovered.") { + input "selectedDevices", "enum", required: false, title: "Select Devices (${options.size() ?: 0} found)", multiple: true, options: options + } + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + + initialize() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + + unsubscribe() + initialize() +} + +def initialize() { + unsubscribe() + unschedule() + + ssdpSubscribe() + + if (selectedDevices) { + log.debug "Selected device" + addDevices() + } + + runEvery5Minutes("ssdpDiscover") +} + +void ssdpDiscover() { + sendHubCommand(new physicalgraph.device.HubAction("lan discovery ${searchTarget}", physicalgraph.device.Protocol.LAN)) +} + +void ssdpSubscribe() { + subscribe(location, "ssdpTerm.${searchTarget}", ssdpHandler) +} + +Map verifiedDevices() { + def devices = getVerifiedDevices() + def map = [:] + devices.each { + def value = it.value.name ?: "AirMentorPro ${it.value.ssdpUSN.split(':')[1][-3..-1]}" + def key = it.value.mac + map["${key}"] = value + log.debug map + } + map +} + +void verifyDevices() { + def devices = getDevices().findAll { it?.value?.verified != true } + devices.each { + int port = convertHexToInt(it.value.deviceAddress) + String ip = convertHexToIP(it.value.networkAddress) + String host = "${ip}:${port}" + sendHubCommand(new physicalgraph.device.HubAction("""GET ${it.value.ssdpPath} HTTP/1.1\r\nHOST: $host\r\n\r\n""", physicalgraph.device.Protocol.LAN, host, [callback: deviceDescriptionHandler])) + } +} + +def getVerifiedDevices() { + getDevices().findAll{ it.value.verified == true } +} + +def getDevices() { + if (!state.devices) { + state.devices = [:] + } + state.devices +} + +def addDevices() { + def devices = getDevices() + + log.debug devices + + selectedDevices.each { dni -> + def selectedDevice = devices.find { it.value.mac == dni } + log.debug selectedDevice + def d + if (selectedDevice) { + d = getChildDevices()?.find { + it.deviceNetworkId == selectedDevice.value.mac + } + } + + if (!d) { + addChildDevice("philippeportesppo", "Air-Mentor-Pro-2", selectedDevice.value.mac, selectedDevice?.value.hub, [ + "label": selectedDevice?.value?.name ?: "AirMentorPro", + "data": [ + "mac": selectedDevice.value.mac, + "ip": selectedDevice.value.networkAddress, + "port": "0050", + "query_path":"/airmentorpro2.php?Action=get" + ] + ]) + } + } +} + +def ssdpHandler(evt) { + def description = evt.description + def hub = evt?.hubId + + def parsedEvent = parseLanMessage(description) + parsedEvent << ["hub":hub] + + def devices = getDevices() + String ssdpUSN = parsedEvent.ssdpUSN.toString() + + if (devices."${ssdpUSN}") { + def d = devices."${ssdpUSN}" + //log.debug d + //log.debug parsedEvent + if (d.networkAddress != parsedEvent.networkAddress || d.deviceAddress != parsedEvent.deviceAddress) { + d.networkAddress = parsedEvent.networkAddress + d.deviceAddress = parsedEvent.deviceAddress + def child = getChildDevice(parsedEvent.mac) + log.debug "Child: " + if (child) { + log.debug child + child.sync(parsedEvent.networkAddress, parsedEvent.deviceAddress) + } + } + } else { + devices << ["${ssdpUSN}": parsedEvent] + } +} + +void deviceDescriptionHandler(physicalgraph.device.HubResponse hubResponse) { + def body = hubResponse.xml + def devices = getDevices() + def device = devices.find { it?.key?.contains(body?.device?.UDN?.text()) } + if (device) { + device.value << [name: body?.device?.roomName?.text(), model:body?.device?.modelName?.text(), serialNumber:body?.device?.serialNum?.text(), verified: true] + } +} + +private Integer convertHexToInt(hex) { + Integer.parseInt(hex,16) +} + +private String convertHexToIP(hex) { + [convertHexToInt(hex[0..1]),convertHexToInt(hex[2..3]),convertHexToInt(hex[4..5]),convertHexToInt(hex[6..7])].join(".") +} \ No newline at end of file diff --git a/smartapps/philippeportesppo/iaq-vent.src/iaq-vent.groovy b/smartapps/philippeportesppo/iaq-vent.src/iaq-vent.groovy new file mode 100644 index 0000000..c7fa5b5 --- /dev/null +++ b/smartapps/philippeportesppo/iaq-vent.src/iaq-vent.groovy @@ -0,0 +1,61 @@ +/** + * IAQ_vent based on CO2_vent from Brian Steere + * + * Copyright 2018 Philippe PORTES + * + * 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. + * + */ +definition( + name: "IAQ_vent", + namespace: "philippeportesppo", + author: "Philippe PORTES", + description: "IAQ_vent update", + category: "Health & Wellness", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png", + iconX3Url: "https://s3.amazonaws.com/smartapp-icons/Convenience/Cat-Convenience@2x.png") + + +preferences { + section("IAQ Sensor") { + input "Air_Mentor_Pro_2", "capability.carbonDioxideMeasurement", title: "IAQ Sensor", required: true + input "level", "enum", title: "CO2 Level for vents action", options: ["good","moderate","unhealthy sensitive persons", "unhealthy", "very unhealthy"], required: true, multiple: true +} + section("Ventilation Fan") { + input "switches", "capability.switch", title: "Switches", required: no, multiple: true + input "thermostat","capability.thermostat", title: "thermostat", required: no, multiple: true + } +} +def installed() { +log.debug "Installed with settings: ${settings}" +initialize() +} +def updated() { +log.debug "Updated with settings: ${settings}" +unsubscribe() +initialize() +} +def initialize() { + + subscribe(Air_Mentor_Pro_2, "IAQ", handleLevel) +} +def handleLevel(evt) { + if (evt.name=="IAQ" && level.contains(evt.value)) { + log.debug "Turning on" + switches.each { it.on(); } + thermostat.each {it.setThermostatFanMode("on");} + } else { + log.debug "Turning off" + switches.each { it.off(); } + thermostat.each {it.setThermostatFanMode("auto");} + + } +} \ No newline at end of file diff --git a/smartapps/philippeportesppo/notify-me-when-for-airmentor-pro.src/notify-me-when-for-airmentor-pro.groovy b/smartapps/philippeportesppo/notify-me-when-for-airmentor-pro.src/notify-me-when-for-airmentor-pro.groovy new file mode 100644 index 0000000..e680e49 --- /dev/null +++ b/smartapps/philippeportesppo/notify-me-when-for-airmentor-pro.src/notify-me-when-for-airmentor-pro.groovy @@ -0,0 +1,191 @@ +/** + * Copyright 2015 SmartThings + * + * 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. + * + * Notify Me When + * + * Author: SmartThings + * Date: 2013-03-20 + * + * Change Log: + * 1. Todd Wackford + * 2014-10-03: Added capability.button device picker and button.pushed event subscription. For Doorbell. + * 2017-03/09: Philippe Portes added CO2 device type and Air Mentor Pro 2 event management. + */ +definition( + name: "Notify Me When for AirMentor Pro", + namespace: "philippeportesppo", + author: "Philippe Portes", + description: "Receive notifications when anything happens in your home.", + category: "Convenience", + iconUrl: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact.png", + iconX2Url: "https://s3.amazonaws.com/smartapp-icons/Meta/window_contact@2x.png" +) + +preferences { + section("Choose one or more, when..."){ + input "button", "capability.button", title: "Button Pushed", required: false, multiple: true //tw + input "motion", "capability.motionSensor", title: "Motion Here", required: false, multiple: true + input "contact", "capability.contactSensor", title: "Contact Opens", required: false, multiple: true + input "contactClosed", "capability.contactSensor", title: "Contact Closes", required: false, multiple: true + input "acceleration", "capability.accelerationSensor", title: "Acceleration Detected", required: false, multiple: true + input "mySwitch", "capability.switch", title: "Switch Turned On", required: false, multiple: true + input "mySwitchOff", "capability.switch", title: "Switch Turned Off", required: false, multiple: true + input "arrivalPresence", "capability.presenceSensor", title: "Arrival Of", required: false, multiple: true + input "departurePresence", "capability.presenceSensor", title: "Departure Of", required: false, multiple: true + input "smoke", "capability.smokeDetector", title: "Smoke Detected", required: false, multiple: true + input "water", "capability.waterSensor", title: "Water Sensor Wet", required: false, multiple: true + // Added to original Notify Me to include Air Mentor Pro 2 + input "co2", "capability.carbonDioxideMeasurement", title: "IAQ Sensor", required: false, multiple: true + input "poluant", "enum", title: "IAQ and poluant type for notification", options: ["IAQ","CO2","PM2_5", "PM10", "TVOC"], required: false, multiple: true + input "level", "enum", title: "IAQ and poluant levels for notification", options: ["good","moderate","unhealthy sensitive persons", "unhealthy", "very unhealthy"], required: false, multiple: true + + } + section("Send this message (optional, sends standard status message if not specified)"){ + input "messageText", "text", title: "Message Text", required: false + } + section("Via a push notification and/or an SMS message"){ + input("recipients", "contact", title: "Send notifications to") { + input "phone", "phone", title: "Enter a phone number to get SMS", required: false + paragraph "If outside the US please make sure to enter the proper country code" + input "pushAndPhone", "enum", title: "Notify me via Push Notification", required: false, options: ["Yes", "No"] + } + } + section("Minimum time between messages (optional, defaults to every message)") { + input "frequency", "decimal", title: "Minutes", required: false + } +} + +def installed() { + log.debug "Installed with settings: ${settings}" + subscribeToEvents() +} + +def updated() { + log.debug "Updated with settings: ${settings}" + log.debug "location.currentMode = ${location.currentMode}" + + unsubscribe() + subscribeToEvents() +} + +def subscribeToEvents() { + subscribe(button, "button.pushed", eventHandler) //tw + subscribe(contact, "contact.open", eventHandler) + subscribe(contactClosed, "contact.closed", eventHandler) + subscribe(acceleration, "acceleration.active", eventHandler) + subscribe(motion, "motion.active", eventHandler) + subscribe(mySwitch, "switch.on", eventHandler) + subscribe(mySwitchOff, "switch.off", eventHandler) + subscribe(arrivalPresence, "presence.present", eventHandler) + subscribe(departurePresence, "presence.not present", eventHandler) + subscribe(smoke, "smoke.detected", eventHandler) + subscribe(smoke, "smoke.tested", eventHandler) + subscribe(smoke, "carbonMonoxide.detected", eventHandler) + subscribe(water, "water.wet", eventHandler) + + // Added to original Notify Me When + subscribe(co2, "IAQ", eventHandler) + subscribe(co2, "CO2", eventHandler) + subscribe(co2, "PM2_5", eventHandler) + subscribe(co2, "PM10", eventHandler) + subscribe(co2, "TVOC", eventHandler) + + + +} + +def eventHandler(evt) { + log.debug "Notify got evt ${evt}" + if (frequency) { + def lastTime = state[evt.deviceId] + if (lastTime == null || now() - lastTime >= frequency * 60000) { + sendMessage(evt) + } + } + else { + sendMessage(evt) + } +} + +private sendMessage(evt) { + String msg = messageText + Map options = [:] + def all_poluant = ["IAQ","CO2","PM2_5", "PM10", "TVOC"] + log.debug "Received $evt.name:$evt.value" + + if (all_poluant.contains(evt.name)) + log.debug "Proceed $evt.name" + + if (!poluant.contains(evt.name) || !level.contains(evt.value)) { + // ignore message + log.debug "Ignoring $evt.name:$evt.value" + return } + + if (!messageText) { + msg = defaultText(evt) + options = [translatable: true, triggerEvent: evt] + } + log.debug "$evt.name:$evt.value, pushAndPhone:$pushAndPhone, '$msg'" + + if (location.contactBookEnabled) { + sendNotificationToContacts(msg, recipients, options) + } else { + if (phone) { + options.phone = phone + if (pushAndPhone != 'No') { + log.debug 'Sending push and SMS' + options.method = 'both' + } else { + log.debug 'Sending SMS' + options.method = 'phone' + } + } else if (pushAndPhone != 'No') { + log.debug 'Sending push' + options.method = 'push' + } else { + log.debug 'Sending nothing' + options.method = 'none' + } + sendNotification(msg, options) + } + if (frequency) { + state[evt.deviceId] = now() + } +} + +private defaultText(evt) { + if (evt.name == 'presence') { + if (evt.value == 'present') { + if (includeArticle) { + '{{ triggerEvent.linkText }} has arrived at the {{ location.name }}' + } + else { + '{{ triggerEvent.linkText }} has arrived at {{ location.name }}' + } + } else { + if (includeArticle) { + '{{ triggerEvent.linkText }} has left the {{ location.name }}' + } + else { + '{{ triggerEvent.linkText }} has left {{ location.name }}' + } + } + } else { + '{{ triggerEvent.descriptionText }}' + } +} + +private getIncludeArticle() { + def name = location.name.toLowerCase() + def segs = name.split(" ") + !(["work","home"].contains(name) || (segs.size() > 1 && (["the","my","a","an"].contains(segs[0]) || segs[0].endsWith("'s")))) +} \ No newline at end of file diff --git a/ssdp_upnp/lib/readme b/ssdp_upnp/lib/readme new file mode 100644 index 0000000..f6952dc --- /dev/null +++ b/ssdp_upnp/lib/readme @@ -0,0 +1,3 @@ +The files are originally from ZeWarren repository but I replaced the logged by prints to avoid the additional complexity of the logger. +I also adapted the threading to Python 2.7. +Finally I adapted to the AirMentor device and service descriptors. diff --git a/ssdp_upnp/lib/ssdp.py b/ssdp_upnp/lib/ssdp.py new file mode 100644 index 0000000..25581cf --- /dev/null +++ b/ssdp_upnp/lib/ssdp.py @@ -0,0 +1,229 @@ +# Licensed under the MIT license +# http://opensource.org/licenses/mit-license.php + +# Copyright 2005, Tim Potter +# Copyright 2006 John-Mark Gurney +# Copyright (C) 2006 Fluendo, S.A. (www.fluendo.com). +# Copyright 2006,2007,2008,2009 Frank Scholz +# Copyright 2016 Erwan Martin +# +# Implementation of a SSDP server. +# + +import random +import time +import socket +import logging +from email.utils import formatdate +from errno import ENOPROTOOPT + +SSDP_PORT = 1900 +SSDP_ADDR = '239.255.255.250' +SERVER_ID = 'Raspberry Pi SSDP Server' + + +#logger = logging.getLogger() + + +class SSDPServer: + """A class implementing a SSDP server. The notify_received and + searchReceived methods are called when the appropriate type of + datagram is received by the server.""" + known = {} + + def __init__(self): + self.sock = None + + def run(self): + self.sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + if hasattr(socket, "SO_REUSEPORT"): + try: + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEPORT, 1) + except socket.error as le: + # RHEL6 defines SO_REUSEPORT but it doesn't work + if le.errno == ENOPROTOOPT: + pass + else: + raise + + addr = socket.inet_aton(SSDP_ADDR) + interface = socket.inet_aton('0.0.0.0') + cmd = socket.IP_ADD_MEMBERSHIP + self.sock.setsockopt(socket.IPPROTO_IP, cmd, addr + interface) + self.sock.bind(('0.0.0.0', SSDP_PORT)) + self.sock.settimeout(1) + + while True: + try: + data, addr = self.sock.recvfrom(1024) + self.datagram_received(data, addr) + except socket.timeout: + continue + self.shutdown() + + def shutdown(self): + for st in self.known: + if self.known[st]['MANIFESTATION'] == 'local': + self.do_byebye(st) + + def datagram_received(self, data, host_port): + """Handle a received multicast datagram.""" + + (host, port) = host_port + + try: + header, payload = data.decode().split('\r\n\r\n')[:2] + except ValueError as err: + print(err) + return + + lines = header.split('\r\n') + cmd = lines[0].split(' ') + lines = map(lambda x: x.replace(': ', ':', 1), lines[1:]) + lines = filter(lambda x: len(x) > 0, lines) + + headers = [x.split(':', 1) for x in lines] + headers = dict(map(lambda x: (x[0].lower(), x[1]), headers)) + + print('SSDP command %s %s - from %s:%d' % (cmd[0], cmd[1], host, port)) + #print('with headers: {}.'.format(headers)) + if cmd[0] == 'M-SEARCH' and cmd[1] == '*': + print ('SSDP discovery') + self.discovery_request(headers, (host, port)) + elif cmd[0] == 'NOTIFY' and cmd[1] == '*': + # SSDP presence + print('NOTIFY *') + else: + print('Unknown SSDP command %s %s' % (cmd[0], cmd[1])) + + def register(self, manifestation, usn, st, location, server=SERVER_ID, cache_control='max-age=1800', silent=False, + host=None): + """Register a service or device that this SSDP server will + respond to.""" + + print('Registering %s (%s)' % (st, location)) + + self.known[usn] = {} + self.known[usn]['USN'] = usn + self.known[usn]['LOCATION'] = location + self.known[usn]['ST'] = st + self.known[usn]['EXT'] = '' + self.known[usn]['SERVER'] = server + self.known[usn]['CACHE-CONTROL'] = cache_control + + self.known[usn]['MANIFESTATION'] = manifestation + self.known[usn]['SILENT'] = silent + self.known[usn]['HOST'] = host + self.known[usn]['last-seen'] = time.time() + + if manifestation == 'local' and self.sock: + self.do_notify(usn) + + def unregister(self, usn): + print("Un-registering %s" % usn) + del self.known[usn] + + def is_known(self, usn): + return usn in self.known + + def send_it(self, response, destination, delay, usn): + #print('send discovery response delayed by %ds for %s to %r' % (delay, usn, destination)) + try: + self.sock.sendto(response.encode(), destination) + except (AttributeError, socket.error) as msg: + print("failure sending out byebye notification: %r" % msg) + + def discovery_request(self, headers, host_port): + """Process a discovery request. The response must be sent to + the address specified by (host, port).""" + + (host, port) = host_port + + print('Discovery request from (%s,%d) for %s' % (host, port, headers['st'])) + if headers['st'] == "urn:schemas-upnp-org:device:AirMentorPro2:1": + print('Discovery request for %s' % headers['st']) + print self.known.values() + + # Do we know about this service? + for i in self.known.values(): + if i['MANIFESTATION'] == 'remote': + continue + if headers['st'] == 'ssdp:all' and i['SILENT']: + continue + if i['ST'] == headers['st'] or headers['st'] == 'ssdp:all': + print "HTTP/1.1 200 OK" + response = ['HTTP/1.1 200 OK'] + + usn = None + for k, v in i.items(): + if k == 'USN': + usn = v + if k not in ('MANIFESTATION', 'SILENT', 'HOST'): + response.append('%s: %s' % (k, v)) + + if usn: + response.append('DATE: %s' % formatdate(timeval=None, localtime=False, usegmt=True)) + + response.extend(('', '')) + delay = random.randint(0, int(headers['mx'])) + + self.send_it('\r\n'.join(response), (host, port), delay, usn) + + def do_notify(self, usn): + """Do notification""" + + if self.known[usn]['SILENT']: + return + # print('Sending alive notification for %s' % usn) + + resp = [ + 'NOTIFY * HTTP/1.1', + 'HOST: %s:%d' % (SSDP_ADDR, SSDP_PORT), + 'NTS: ssdp:alive', + ] + stcpy = dict(self.known[usn].items()) + stcpy['NT'] = stcpy['ST'] + del stcpy['ST'] + del stcpy['MANIFESTATION'] + del stcpy['SILENT'] + del stcpy['HOST'] + del stcpy['last-seen'] + + resp.extend(map(lambda x: ': '.join(x), stcpy.items())) + resp.extend(('', '')) + # print('do_notify content', resp) + try: + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, SSDP_PORT)) + self.sock.sendto('\r\n'.join(resp).encode(), (SSDP_ADDR, SSDP_PORT)) + except (AttributeError, socket.error) as msg: + print("failure sending out alive notification: %r" % msg) + + def do_byebye(self, usn): + """Do byebye""" + + # print('Sending byebye notification for %s' % usn) + + resp = [ + 'NOTIFY * HTTP/1.1', + 'HOST: %s:%d' % (SSDP_ADDR, SSDP_PORT), + 'NTS: ssdp:byebye', + ] + try: + stcpy = dict(self.known[usn].items()) + stcpy['NT'] = stcpy['ST'] + del stcpy['ST'] + del stcpy['MANIFESTATION'] + del stcpy['SILENT'] + del stcpy['HOST'] + del stcpy['last-seen'] + resp.extend(map(lambda x: ': '.join(x), stcpy.items())) + resp.extend(('', '')) + # print('do_byebye content', resp) + if self.sock: + try: + self.sock.sendto('\r\n'.join(resp), (SSDP_ADDR, SSDP_PORT)) + except (AttributeError, socket.error) as msg: + print("failure sending out byebye notification: %r" % msg) + except KeyError as msg: + print("error building byebye notification: %r" % msg) diff --git a/ssdp_upnp/lib/upnp_http_server.py b/ssdp_upnp/lib/upnp_http_server.py new file mode 100644 index 0000000..00ca6ed --- /dev/null +++ b/ssdp_upnp/lib/upnp_http_server.py @@ -0,0 +1,133 @@ + +from BaseHTTPServer import BaseHTTPRequestHandler, HTTPServer +import threading + +PORT_NUMBER = 8090 + + +class UPNPHTTPServerHandler(BaseHTTPRequestHandler): + """ + A HTTP handler that serves the UPnP XML files. + """ + + # Handler for the GET requests + def do_GET(self): + print "===============",self.path + + if self.path == '/airmentor_wsd.xml': + self.send_response(200) + self.send_header('Content-type', 'application/xml') + self.end_headers() + self.wfile.write(self.get_wsd_xml().encode()) + return + if self.path == '/airmentorpro2.xml': + self.send_response(200) + self.send_header('Content-type', 'application/xml') + self.end_headers() + self.wfile.write(self.get_device_xml().encode()) + return + else: + self.send_response(404) + self.send_header('Content-type', 'text/html') + self.end_headers() + self.wfile.write(b"Not found.") + return + + def get_device_xml(self): + """ + Get the main device descriptor xml file. + """ + xml = """ + + 1 + 0 + + + urn:schemas-upnp-org:device:AirMentorPro2:1 + {friendly_name} + {manufacturer} + {manufacturer_url} + {model_description} + {model_name} + {model_number} + {model_url} + {serial_number} + uuid:{uuid} + + + https://www.air-mentor.com/web2017/product/air_mentor_8096ap + urn:airmentor:service:AirMentorPro2:1 + urn:airmentor:serviceId:AirMentorPro2 + /AirMentorPro2 + + /AirMentorPro2_wsd.xml + + + {presentation_url} + +""" + return xml.format(friendly_name=self.server.friendly_name, + manufacturer=self.server.manufacturer, + manufacturer_url=self.server.manufacturer_url, + model_description=self.server.model_description, + model_name=self.server.model_name, + model_number=self.server.model_number, + model_url=self.server.model_url, + serial_number=self.server.serial_number, + uuid=self.server.uuid, + presentation_url=self.server.presentation_url) + + @staticmethod + def get_wsd_xml(): + """ + Get the device WSD file. + """ + return """ + +1 +0 + +""" + + +class UPNPHTTPServerBase(HTTPServer): + """ + A simple HTTP server that knows the information about a UPnP device. + """ + def __init__(self, server_address, request_handler_class): + HTTPServer.__init__(self, server_address, request_handler_class) + self.port = None + self.friendly_name = None + self.manufacturer = None + self.manufacturer_url = None + self.model_description = None + self.model_name = None + self.model_url = None + self.serial_number = None + self.uuid = None + self.presentation_url = None + + +class UPNPHTTPServer(threading.Thread): + """ + A thread that runs UPNPHTTPServerBase. + """ + + def __init__(self, port, friendly_name, manufacturer, manufacturer_url, model_description, model_name, + model_number, model_url, serial_number, uuid, presentation_url): + threading.Thread.__init__(self) + self.server = UPNPHTTPServerBase(('', port), UPNPHTTPServerHandler) + self.server.port = port + self.server.friendly_name = friendly_name + self.server.manufacturer = manufacturer + self.server.manufacturer_url = manufacturer_url + self.server.model_description = model_description + self.server.model_name = model_name + self.server.model_number = model_number + self.server.model_url = model_url + self.server.serial_number = serial_number + self.server.uuid = uuid + self.server.presentation_url = presentation_url + + def run(self): + self.server.serve_forever() diff --git a/ssdp_upnp/ssdp_server.py b/ssdp_upnp/ssdp_server.py new file mode 100644 index 0000000..2e8117b --- /dev/null +++ b/ssdp_upnp/ssdp_server.py @@ -0,0 +1,151 @@ +#The MIT License (MIT) +#Copyright (c) 2016 Erwan Martin +#Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: +#The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +#THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +# adapter by Philippe Portes 2018 from orignal ssdp_server.py written by ZeWarren on https://github.com/ZeWaren/python-upnp-ssdp-example/blob/master/__main__.py + +import sys +sys.path.append("/home/pi/lib") + +from ssdp import SSDPServer + +from upnp_http_server import UPNPHTTPServer + +import uuid + +import netifaces as ni + +from time import sleep + +#import logging + + + +NETWORK_INTERFACE = 'wlan0' # or 'eth0' + + + +#logger = logging.getLogger() + +#logger.setLevel(logging.DEBUG) + + + + + +#def setup_debugging(): +# +# """ +# +# Load PyCharm's egg and start a remote debug session. +# +# :return: None +# +# +# """ + +# import sys + +# sys.path.append('/root/pycharm-debug-py3k.egg') + +# import pydevd + +# pydevd.settrace('192.168.1.49', port=5422, stdoutToServer=True, stderrToServer=True, suspend=False) + + + + + +#setup_debugging() + + + + + +def get_network_interface_ip_address(interface='wlan0'): + + """ + + Get the first IP address of a network interface. + + :param interface: The name of the interface. + + :return: The IP address. + + """ + + while True: + + if NETWORK_INTERFACE not in ni.interfaces(): + + print('Could not find interface %s.' % (interface,)) + + exit(1) + + interface = ni.ifaddresses(interface) + + if (2 not in interface) or (len(interface[2]) == 0): + + print('Could not find IP of interface %s. Sleeping.' % (interface,)) + + sleep(60) + + continue + + return interface[2][0]['addr'] + + +print "Start..." + + +device_uuid = uuid.uuid4() + +print "Device_uuid:", device_uuid + +local_ip_address = get_network_interface_ip_address(NETWORK_INTERFACE) + +print local_ip_address + +http_server = UPNPHTTPServer(8090, + + friendly_name="Air Mentor Pro 2", + + manufacturer="Air Mentor", + + manufacturer_url='https://www.air-mentor.com/web2017/?lang=en', + + model_description='Air Mentor Pro 2', + + model_name="Air Mentor", + + model_number="Air Mentor Pro 2", + + model_url="https://www.air-mentor.com/web2017/product/air_mentor_8096ap", + + serial_number="N/A", + + uuid=device_uuid, + + presentation_url="http://{}:5000/".format(local_ip_address)) + +http_server.start() + +print "Server started" + +ssdp = SSDPServer() + +print "SSDP started" + +ssdp.register('local', + + 'uuid:{}::urn:schemas-upnp-org:device:AirMentorPro2:1'.format(device_uuid), + + 'urn:schemas-upnp-org:device:AirMentorPro2:1', + + 'http://{}:8090/airmentorpro2.xml'.format(local_ip_address)) + +print "SSDP registered" + +ssdp.run()