Sorry, I figured it out!
So life happened (as it often does) and I lost track of this for a bit. I got back to it and decided to just go biblical on it and flood it and start again. Removed everything. Reinstalled from Package Manager and now I'm getting this odd response when I try to get an AuthToken:
[dev:520] 2024-10-17 04:48:50.529 PM [error] java.lang.IllegalArgumentException: No json exists for response on line 280 (method getAccessTokenCallback)
[dev:520] 2024-10-17 04:48:50.351 PM [error] java.lang.IllegalArgumentException: No json exists for response on line 154 (method samplesCallback)
[dev:520] 2024-10-17 04:48:39.574 PM [error] java.lang.IllegalArgumentException: No json exists for response on line 249 (method getAuthTokenCallback)
I verified with cURL that I CAN get a token with my user credentials and then use that to get a list of devices so I just not sure what to think here.
Yeah, life has been a lot like that for me this year....
Thanks for taking the time to work through it from the start like this.
I get this one more commonly myself:
I'm a little lazy with handling error codes in the HTTP responses for most (all) of my drivers. My suspicion for both of these errors is that there is some kind of error (Status Code) being returned that either does not include a body or it is at least not JSON.
I'll need to do some digging, at least with the samples issue that I periodically see. I might also set my gateway up on one of my dev hubs from scratch to see if I have the same issue you are seeing with the Auth Token.
I'll report back once I have anything more...
Following on my post back in August:
I have released an update to the driver:
v1.0.5 - Improved handling of temperature and humidity changes to help in device monitoring and triggering of automations.
Basically, temperature and humidity events will only be recorded if there is an actual change to the value currently recorded on the device in HE. Previously an event would be recorded each time the sensor readings were polled by the HE driver, regardless of whether there was a change in the reading or not, producing a lot of "noise" and potential inaccuracies when monitoring device activity.
This change achieves two things:
- Reduces the number of events recorded for the device, thus reducing the triggering of automations to only be when a change has actually occurred
- Limits changes to the Last Activity At value for each device to only when changes are detected in temperature or humidity, allowing more accurate monitoring of the device health
Changes are available in HPM or you can always update the driver manually if you prefer (look at the original post on this topic for the link and instructions).
I will hopefully look at some of the other points I mentioned back in August in a later release, such as additional logging and other minor improvements.
This v1.0.5 release won't address any of your current issues @Dread_Pirate_Roberts , I will need to do some more testing and troubleshooting for these.
Hey, been working with this a little more lately. Do you have any plans to add offsets? Currently the incoming information is not accurate if you do a temp/humidity offset within the app itself. I have found I have to do offset apps for these to be accurate. Thanks!
I didn't have any plans, but should be easy enough to add. Can't say when but would hope in the next couple of months I might get some time to look at it.
I can probably do it on my own if you lead me to where to edit it. I could not find anything about the temp/humid sensors themselves. Do you happen to have the default code for Virtual Temperature and Virtual Humidity sensors? It seems your app uses them as the driver for the actual sensors. I could not find anything on them or configs other than the gateway.
It shouldn't be too complicated for me to do, but will likely result in me setting up a driver for the sensors.... So I will need to think about it a little more....
Perhaps I should explain a little more... Or feel free to accept the "I need some time" response... Apologies if I am being too simplistic, or long-winded in my explanation (it's been a little while since I had a chance to write a lengthy response...)
One minor point... The code I developed for these sensors are actually drivers, not an app. Don't worry, took me a while to make the distinction, and I work in a roundabout way in this space.....
That aside.... When developing a driver, we can have the "parent" driver create child devices, which I chose to do here, i.e. the gateway driver/device creating child devices for each sensorpush sensor (x2). Child devices can use any driver, including custom drivers we write or Hubitat built-in drivers, like the virtual temperature or humidity sensor drivers you mentioned. The code for these "virtual" drivers, and any other built-in Hubitat code, are not open source, meaning that I and other developers don't have access to the code. On the one hand, this means we can make use of these drivers where we need to, while not having access to their code, but we can also develop drivers to match their setup with different behaviour.... e.g. offsets... Hopefully that makes sense...
Alongside this explanation about how Hubitat works... The driver I wrote for the SensorPush Gateway.... It currently creates 2x child devices for each SensorPush sensor, one for a temperature sensor and one for a humidity sensor, each using the relevant built-in drivers from Hubitat, i.e. a temperature sensor driver and a humidity sensor driver.
My first inclination is to develop a new "SensorPush Sensor" driver that includes both temperature and humidity in it. But I am conscious of how this may / could others already using these drivers where two separate devices are being created. This is mostly where I want to "think about it". The simpler option would be to create a "SensorPush Temperature" and "SenssorPush Humidity" drivers, but this seems unnecessary...
I'll think about it and let you know...
Thank you for the explanation. It is quite alright. I was just saying since I am a C# developer I figured I could make the child driver on my own pretty easily. In fact, I could probably help you out on it and that is what I meant. Let me know if you get around to adding this as I noticed I have had to make a separate app for "combined" sensors for each one to get the offset. While the offset is not large at all, some of them I use for heaters and some other things that require a little bit of a buffer which I use the offset for.
Then a simple temperature and humidity driver, with a couple of tips should be a walk in the park... Let me get in touch when I start to look at this and run you through it.... Or feel free to explore on your own... Looking through other drivers and the documentation is a good start, plus asking questions on the Community.
Awesome. It looks like Python? I am not entirely sure as I have not looked into it deeply. The only struggle I have right now is understanding the "parent" children thing. Specifically, how your app talks to the drivers for each device. If I could edit the driver for the temp and humidity driver's I will be golden. Currently, I have to do a "combine" app for EACH humidity and temp which is annoying and wish I could just have it in the driver itself.
Hey there, so I finally got around to really digging into this.
Here is the updated driver. It provides all information from the sensors including an ENUM to provide if the sensor is high or low based on the sensor thresholds set within the app.
Main highlights
- Creates a single device instead of a humidity and temperature device.
- Adds all capabilities of the sensor (Max/Min, Calibrations, rssi, battery level, activity polling, and if the sensor is HIGH or LOW under status, and the model of the sensor). This is provided by the SensorPush application.
- One single button press to authenticate and refresh. Enter the username and password then just press refresh. This will automatically authenticate and add the sensors.
- Added "Last Refresh Time" attribute to the Gateway. This is good to know in case the sensors did not refresh and you can then set an alert or something to let you know.
Items to take note.
- No testing was done with Celcius, just F. I presume it all will transfer. The change it made from the Sensorpush application itself.
- Precision is 1 decimal instead of 1 or more. Automatically rounds number.
Parent Driver
/*
SensorPush 2.0 Gateway Driver
Copyright 2025 Demariners
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.
This application improves on the SensorPush Gateway application made by Simon Burke.
In no way do I not acknowlegde his hard work on his application. I took most of his code and improved on it to provide more functionality.
This must be used with the "SensorPush 2.0 Sensor Driver"
Sets up gateway for communication with SensorPush Gateway
Change History:
Version 2.0 - Initial application.
*/
metadata {
definition (name: "SensorPush 2.0 Gateway Driver", namespace: "Demariners", author: "Derrick Picker") {
capability "Refresh"
attribute "spAuthCode", "string"
attribute "spAccessToken", "string"
attribute "lastRefreshed", "string"
}
preferences {
input(name: "spBaseURL", type: "string", title:"SensorPush Base URL",
description: "Enter the base URL for the SensorPush Cloud Service",
defaultValue: "https://api.sensorpush.com/api/v1", required: true, displayDuringSetup: true)
input(name: "UserName", type: "string", title:"SensorPush Username / Email",
description: "Username / Email used to authenticate on SensorPush cloud",
displayDuringSetup: true)
input(name: "Password", type: "password", title:"SensorPush Account Password",
description: "Password for authenticating on SensorPush cloud",
displayDuringSetup: true)
input(name: "AutoPolling", type: "bool", title:"Enable Auto Polling",
defaultValue: true, required: true, displayDuringSetup: true)
input(name: "PollingInterval", type: "number", title:"Polling Interval (minutes)",
defaultValue: 1, required: true, displayDuringSetup: true)
input(name: "debugEnable", type: "bool", title: "Enable debug logging", defaultValue: true)
}
}
def installed() {
logDebug "installed()"
initialize()
}
def updated() {
logDebug "updated()"
initialize()
}
def initialize() {
logDebug "initialize()"
unschedule()
if (AutoPolling) {
schedule("0 */${PollingInterval} * ? * *", "refresh")
}
}
def refresh() {
logDebug "refresh()"
// Update the lastRefreshed timestamp
sendEvent(name: "lastRefreshed", value: new Date().format("yyyy-MM-dd HH:mm:ss"))
getAuthToken()
}
// Authentication methods
def getAuthToken() {
logDebug "getAuthToken()"
def headers = [:]
headers.put("accept", "application/json")
def postParams = [
uri: "${spBaseURL}/oauth/authorize",
headers: headers,
contentType: "application/json",
body: [
email: UserName,
password: Password
]
]
try {
asynchttpPost('authTokenCallback', postParams)
} catch (e) {
log.error "Error getting auth token: ${e}"
}
}
def authTokenCallback(response, data) {
logDebug "authTokenCallback()"
if (response?.status == 200) {
sendEvent(name: "spAuthCode", value: response.json.authorization)
getAccessToken()
} else {
log.error "Auth token request failed: ${response.status}"
}
}
def getAccessToken() {
logDebug "getAccessToken()"
def headers = [:]
headers.put("accept", "application/json")
def postParams = [
uri: "${spBaseURL}/oauth/accesstoken",
headers: headers,
contentType: "application/json",
body: [
authorization: device.currentValue("spAuthCode")
]
]
try {
asynchttpPost('accessTokenCallback', postParams)
} catch (e) {
log.error "Error getting access token: ${e}"
}
}
def accessTokenCallback(response, data) {
logDebug "accessTokenCallback()"
if (response?.status == 200) {
sendEvent(name: "spAccessToken", value: response.json.accesstoken)
querySensors()
} else {
log.error "Access token request failed: ${response.status}"
}
}
def querySensors() {
logDebug "querySensors()"
def headers = [:]
headers.put("accept", "application/json")
headers.put("Authorization", device.currentValue("spAccessToken"))
def postParams = [
uri: "${spBaseURL}/devices/sensors",
headers: headers,
contentType: "application/json",
body: [:]
]
try {
asynchttpPost('sensorListCallback', postParams)
} catch (e) {
log.error "Error querying sensors: ${e}"
}
}
def sensorListCallback(response, data) {
logDebug "sensorListCallback()"
if (response?.status == 200) {
response.json.each { id, sensorData ->
def dni = "${device.deviceNetworkId}-${id}"
def existing = getChildDevice(dni)
if (!existing) {
logDebug "Creating new sensor: ${sensorData.name}"
existing = addChildDevice(
"Demariners",
"SensorPush 2.0 Sensor Driver",
dni,
[
name: "SensorPush 2.0 Sensor",
label: sensorData.name,
isComponent: false
]
)
}
// Update sensor configuration
existing.updateConfig(sensorData)
}
getSensorData()
} else {
log.error "Sensor list request failed: ${response.status}"
}
}
def getSensorData() {
logDebug "getSensorData()"
def headers = [:]
headers.put("accept", "application/json")
headers.put("Authorization", device.currentValue("spAccessToken"))
def postParams = [
uri: "${spBaseURL}/samples",
headers: headers,
contentType: "application/json",
body: [
limit: 1
]
]
try {
asynchttpPost('sensorDataCallback', postParams)
} catch (e) {
log.error "Error getting sensor data: ${e}"
}
}
def sensorDataCallback(response, data) {
logDebug "sensorDataCallback()"
if (response?.status == 200) {
// Update lastRefreshed after successful data retrieval
sendEvent(name: "lastRefreshed", value: new Date().format("yyyy-MM-dd HH:mm:ss"))
response.json.sensors.each { id, sensorData ->
def child = getChildDevice("${device.deviceNetworkId}-${id}")
if (child) {
logDebug "Updating sensor ${id} with temp: ${sensorData.temperature}, humidity: ${sensorData.humidity}"
child.updateReadings(sensorData)
}
}
} else {
log.error "Sensor data request failed: ${response.status}"
}
}
private logDebug(msg) {
if (debugEnable) log.debug(msg)
}
Child Driver
/*
SensorPush 2.0 Sensor Driver
Copyright 2025 Demariners
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.
This application improves on the SensorPush Gateway application made by Simon Burke.
In no way do I not acknowlegde his hard work on his application. I took most of his code and improved on it to provide more functionality.
This must be used with the "SensorPush 2.0 Gateway Driver"
Provides all statuses from a single Sensor. Humidity And Temperature
***THIS IS NOT A STANDALONE DRIVER***
Requires a gateway to be used along with it's corresponding driver.
Change History:
Version 2.0 - Initial application.
*/
metadata {
definition (name: "SensorPush 2.0 Sensor Driver", namespace: "Demariners", author: "Derrick Picker") {
capability "Sensor"
capability "Temperature Measurement"
capability "RelativeHumidityMeasurement"
capability "Battery"
capability "SignalStrength"
attribute "lastUpdated", "string"
attribute "temperatureCalibration", "number"
attribute "humidityCalibration", "number"
attribute "temperatureMin", "number"
attribute "temperatureMax", "number"
attribute "humidityMin", "number"
attribute "humidityMax", "number"
attribute "activeStatus", "string"
attribute "deviceModel", "string"
attribute "firmwareVersion", "string"
attribute "batteryType", "string"
attribute "temperatureStatus", "enum", ["HIGH", "NORMAL", "LOW"]
attribute "humidityStatus", "enum", ["HIGH", "NORMAL", "LOW"]
}
preferences {
input name: "debugEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}
}
def installed() {
logDebug "installed()"
}
def updated() {
logDebug "updated()"
}
def updateConfig(config) {
logDebug "updateConfig - Raw config: ${config}"
// Update device metadata
if (config.active != null) {
sendEvent(name: "activeStatus", value: config.active ? "active" : "inactive")
}
if (config.type) {
sendEvent(name: "deviceModel", value: config.type)
}
// Handle battery - now from battery_voltage
if (config.battery_voltage) {
// Convert voltage to percentage (2.4V = 0%, 3.0V = 100%)
def voltage = config.battery_voltage as float
def percentage = Math.round(((voltage - 2.4) / 0.6 * 100))
percentage = Math.max(0, Math.min(100, percentage))
sendEvent(name: "battery", value: percentage, unit: "%")
logDebug "Battery updated from voltage ${voltage}V to ${percentage}%"
}
// Handle RSSI
if (config.rssi) {
sendEvent(name: "rssi", value: config.rssi)
logDebug "RSSI updated: ${config.rssi}"
}
// Update calibration values
if (config.calibration?.temperature != null) {
def rawCal = config.calibration.temperature as float
def convertedCal = (rawCal * 9.0 / 5.0)
def roundedCal = String.format("%.1f", convertedCal).toFloat()
sendEvent(name: "temperatureCalibration", value: roundedCal)
logDebug "Temperature calibration: raw=${rawCal}, converted=${convertedCal}, rounded=${roundedCal}"
}
if (config.calibration?.humidity != null) {
def humidityCal = config.calibration.humidity as float
sendEvent(name: "humidityCalibration", value: humidityCal)
logDebug "Humidity calibration: ${humidityCal}"
}
// Update thresholds
if (config.alerts?.temperature?.min != null) {
sendEvent(name: "temperatureMin", value: config.alerts.temperature.min)
}
if (config.alerts?.temperature?.max != null) {
sendEvent(name: "temperatureMax", value: config.alerts.temperature.max)
}
if (config.alerts?.humidity?.min != null) {
sendEvent(name: "humidityMin", value: config.alerts.humidity.min)
}
if (config.alerts?.humidity?.max != null) {
sendEvent(name: "humidityMax", value: config.alerts.humidity.max)
}
}
def updateReadings(data) {
logDebug "updateReadings - Raw data: ${data}"
// Retrieve current calibration values, defaulting to 0 if not set
def temperatureCalibration = device.currentValue('temperatureCalibration') ?: 0.0f
def humidityCalibration = device.currentValue('humidityCalibration') ?: 0.0f
// Retrieve min and max thresholds
def temperatureMin = device.currentValue('temperatureMin')
def temperatureMax = device.currentValue('temperatureMax')
def humidityMin = device.currentValue('humidityMin')
def humidityMax = device.currentValue('humidityMax')
// Since data is an array, get the first element
if (data && data[0]) {
def reading = data[0]
// Update temperature with calibration
if (reading.temperature) {
def rawTemp = reading.temperature as float
def roundedTemp = String.format("%.1f", rawTemp).toFloat()
def adjustedTemp = roundedTemp + temperatureCalibration
def finalRoundedTemp = String.format("%.1f", adjustedTemp).toFloat()
// Determine temperature status
def temperatureStatus = "NORMAL"
if (temperatureMin != null && finalRoundedTemp < temperatureMin) {
temperatureStatus = "LOW"
} else if (temperatureMax != null && finalRoundedTemp > temperatureMax) {
temperatureStatus = "HIGH"
}
sendEvent(name: "temperature", value: finalRoundedTemp, unit: "°F")
sendEvent(name: "temperatureStatus", value: temperatureStatus)
logDebug "Temperature: raw=${rawTemp}, rounded=${roundedTemp}, calibration=${temperatureCalibration}, final=${finalRoundedTemp}, status=${temperatureStatus}"
}
// Update humidity with calibration
if (reading.humidity) {
def rawHumidity = reading.humidity as float
def roundedHumidity = String.format("%.1f", rawHumidity).toFloat()
def adjustedHumidity = roundedHumidity + humidityCalibration
def finalRoundedHumidity = String.format("%.1f", adjustedHumidity).toFloat()
// Determine humidity status
def humidityStatus = "NORMAL"
if (humidityMin != null && finalRoundedHumidity < humidityMin) {
humidityStatus = "LOW"
} else if (humidityMax != null && finalRoundedHumidity > humidityMax) {
humidityStatus = "HIGH"
}
sendEvent(name: "humidity", value: finalRoundedHumidity, unit: "%")
sendEvent(name: "humidityStatus", value: humidityStatus)
logDebug "Humidity: raw=${rawHumidity}, rounded=${roundedHumidity}, calibration=${humidityCalibration}, final=${finalRoundedHumidity}, status=${humidityStatus}"
}
// Update last reading time - handle ISO8601 format
if (reading.observed) {
sendEvent(name: "lastUpdated", value: reading.observed)
logDebug "Last updated: ${reading.observed}"
}
}
}
private logDebug(msg) {
if (debugEnable) log.debug(msg)
}
Here is a supplemental application for notifications that ties directly to the above application.
Main highlights
- Notifies via Push notifications on the Hubitat application. Mobile device is selectable to only notify the mobile devices you wish to use.
- Test notification panel.
- Allows selection of any number of sensors to get notifications from. You can choose as many or as little as you wish.
- Allows selection to get notified when temp or humidity is outside the bounds of the Min/Min assigned within the SensorPush application. You have full control of what is selected for the notification. For example, you can only get HIGH notification for Humidity on Sensor #1.
- Allows fully customized notification message with whatever attributes you wish to use.
- If multple sensors are out of range at the same time of refresh, it will send a single notification with multiple messages.
- Ability to use a switch or virtual switch to enable or disable the application.
- Ability to use time frames when to send notifications, both time and Sunrise/Sunset.
- Ability to repeat notifications, both time frame and amount of them.
- Ability to delay the notifications trigger if the sensor reports the value for the specified time in minutes.
/*
/*
SensorPush 2.0 Sensor Notifications
Copyright 2025 Demariners
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.
This application improves on the SensorPush Gateway application made by Simon Burke.
In no way do I not acknowlegde his hard work on his application. I took most of his code and improved on it to provide more functionality.
***THIS IS NOT A STANDALONE APPLICATION***
This must be used with the "SensorPush 2.0 Gateway Driver" & "SensorPush 2.0 Sensor Driver"
Provides a notification based on High or Low for temperature or humitidy from the SensorPush sensors Max/Min setting.
Change History:
Version 2.0 - Initial application.
*/
definition(
name: "SensorPush 2.0 Sensor Notifications",
namespace: "Demariners",
author: "Derrick Picker",
description: "Sends notifications for sensor status changes with configurable restrictions",
category: "Convenience",
iconUrl: "",
iconX2Url: ""
)
preferences {
page(name: "mainPage")
page(name: "testPage")
}
// Add this helper method to get all appropriate sensors
private getDevices() {
def devices = []
getAllChildDevices().each { device ->
if (device.hasCapability("temperatureMeasurement") && device.hasCapability("relativeHumidityMeasurement")) {
devices << device
}
}
return devices
}
def mainPage() {
dynamicPage(name: "mainPage", install: true, uninstall: true) {
section {
href "testPage", title: "Send Test Notification", description: "Tap to test notification"
}
section {
// Add the select all toggle here
input "selectAll", "bool", title: "Select All Sensors", defaultValue: false, submitOnChange: true
if (selectAll) {
// When select all is true, find all temp+humidity sensors and assign to sensors input
def allSensors = getDevices()
app.updateSetting("sensors", allSensors*.id)
paragraph "All sensors have been selected"
} else {
input(name: "sensors", type: "capability.temperatureMeasurement", title: "Select Sensors", multiple: true, required: false)
}
// Add new delay option here
input "statusDelay", "number",
title: "Delay notifications until status persists for (minutes)",
defaultValue: 1,
required: false,
range: "1..60"
input "temperatureEnabled", "bool", title: "Monitor Temperature Status", defaultValue: true, submitOnChange: true
if (temperatureEnabled) {
input "selectedTempStatuses", "enum",
title: "When temperature status is...",
options: ["HIGH":"High", "LOW":"Low"],
multiple: true,
required: false
}
input "humidityEnabled", "bool", title: "Monitor Humidity Status", defaultValue: true, submitOnChange: true
if (humidityEnabled) {
input "selectedHumidStatuses", "enum",
title: "When humidity status is...",
options: ["HIGH":"High", "LOW":"Low"],
multiple: true,
required: false
}
}
section {
input "notificationDevices", "capability.notification", title: "Send Notifications To", multiple: true, required: false
}
section("Message Template") {
paragraph """%name% - Sensor name
%type% - Temperature or Humidity
%status% - Current status (HIGH/LOW)
%value% - Current value
%unit% - Unit (°F or %)
%min% - Minimum threshold
%max% - Maximum threshold"""
input "notificationTemplate", "text",
title: "Message Template",
required: true,
defaultValue: "%name%: %type% is %status% (Current: %value%%unit%, Range: %min% - %max%)"
}
section {
input "notificationRepeat", "bool",
title: "Enable Notification Repeat",
defaultValue: false,
submitOnChange: true
if (notificationRepeat) {
input "repeatInterval", "number",
title: "Repeat Interval (minutes)",
required: false,
defaultValue: 30
input "repeatCount", "number",
title: "Number of Repeats (0 for unlimited)",
required: false,
defaultValue: 0
}
}
section("Restrictions") {
paragraph "Switch Control"
input "switchEnabled", "bool",
title: "Enable only when a specific switch is turned on",
defaultValue: false,
submitOnChange: true
if (switchEnabled) {
input "switchDevice", "capability.switch",
title: "Select switch",
required: false
}
paragraph " "
paragraph "Time Control"
input "timeRestrictionEnabled", "bool",
title: "Enable only during a specific time",
defaultValue: false,
submitOnChange: true
if (timeRestrictionEnabled) {
input "startTimeType", "enum",
title: "Start Time Type",
options: ["time":"Specific Time", "sunrise":"Sunrise", "sunset":"Sunset"],
defaultValue: "time",
submitOnChange: true
if (startTimeType == "time") {
input "startTime", "time", title: "Start Time"
}
if (startTimeType == "sunrise") {
input "startSunriseOffset", "number", title: "Minutes from Sunrise", range: "-180..180"
}
if (startTimeType == "sunset") {
input "startSunsetOffset", "number", title: "Minutes from Sunset", range: "-180..180"
}
input "endTimeType", "enum",
title: "End Time Type",
options: ["time":"Specific Time", "sunrise":"Sunrise", "sunset":"Sunset"],
defaultValue: "time",
submitOnChange: true
if (endTimeType == "time") {
input "endTime", "time", title: "End Time"
}
if (endTimeType == "sunrise") {
input "endSunriseOffset", "number", title: "Minutes from Sunrise", range: "-180..180"
}
if (endTimeType == "sunset") {
input "endSunsetOffset", "number", title: "Minutes from Sunset", range: "-180..180"
}
}
}
}
}
def testPage() {
dynamicPage(name: "testPage", install: false, uninstall: false) {
section {
paragraph "This will send a test notification to verify your configuration"
input "testNotificationDevices", "capability.notification", title: "Send test notification to", multiple: true, required: false
if (testNotificationDevices) {
input "testButton", "button", title: "Send Test Notification", submitOnChange: true
}
}
}
}
def appButtonHandler(btn) {
switch(btn) {
case "testButton":
if (testNotificationDevices) {
testNotificationDevices.each { device ->
device.deviceNotification("Test Message From SensorPush 2.0")
}
}
break
}
}
private sendNotification(data) {
if (!notificationDevices) return
def message = notificationTemplate
message = message.replace("%name%", data.name)
message = message.replace("%type%", data.type)
message = message.replace("%status%", data.status)
message = message.replace("%value%", "${data.value}")
message = message.replace("%unit%", data.unit)
message = message.replace("%min%", "${data.min}")
message = message.replace("%max%", "${data.max}")
notificationDevices.each { device ->
device.deviceNotification(message)
}
}
def installed() {
initialize()
}
def updated() {
unsubscribe()
initialize()
}
def initialize() {
if (!sensors) return
if (temperatureEnabled && selectedTempStatuses) {
sensors.each { sensor ->
subscribe(sensor, "temperatureStatus", handleStatusChange)
}
}
if (humidityEnabled && selectedHumidStatuses) {
sensors.each { sensor ->
subscribe(sensor, "humidityStatus", handleStatusChange)
}
}
}
def handleStatusChange(evt) {
def device = evt.device
def type = evt.name.contains("temperature") ? "Temperature" : "Humidity"
// Check restrictions
if (switchEnabled && switchDevice?.currentValue("switch") != "on") return
if (timeRestrictionEnabled && !timeOk()) return
def selectedStatuses = type == "Temperature" ? selectedTempStatuses : selectedHumidStatuses
if (!selectedStatuses?.contains(evt.value)) return
// Create a unique key for this device and type
def stateKey = "${device.id}_${type}_status"
def timeKey = "${device.id}_${type}_time"
// If this is a new status or different from last status, update the time
if (state[stateKey] != evt.value) {
state[stateKey] = evt.value
state[timeKey] = now()
return // Exit and wait for next status check
}
// Get delay in milliseconds (convert from minutes)
def delayMs = (statusDelay ?: 1) * 60000
// Check if enough time has passed
if ((now() - state[timeKey]) < delayMs) return // Not enough time has passed, exit
if (state.pendingNotifications == null) {
state.pendingNotifications = []
}
def value = type == "Temperature" ? device.currentValue("temperature") : device.currentValue("humidity")
def unit = type == "Temperature" ? "°F" : "%"
def min = type == "Temperature" ? device.currentValue("temperatureMin") : device.currentValue("humidityMin")
def max = type == "Temperature" ? device.currentValue("temperatureMax") : device.currentValue("humidityMax")
def data = [
name: device.displayName,
type: type,
status: evt.value,
value: value,
unit: unit,
min: min,
max: max
]
// Add to pending notifications
state.pendingNotifications << data
// Schedule sending of batched notifications after a short delay
runIn(2, 'sendBatchedNotifications')
}
def sendBatchedNotifications() {
if (!state.pendingNotifications || state.pendingNotifications.isEmpty()) return
if (!notificationDevices) return
def messages = []
state.pendingNotifications.each { data ->
def message = notificationTemplate
message = message.replace("%name%", data.name)
message = message.replace("%type%", data.type)
message = message.replace("%status%", data.status)
message = message.replace("%value%", "${data.value}")
message = message.replace("%unit%", data.unit)
message = message.replace("%min%", "${data.min}")
message = message.replace("%max%", "${data.max}")
messages << message
}
// Join all messages with newlines
def combinedMessage = messages.join("\n")
// Send to all notification devices
notificationDevices.each { device ->
device.deviceNotification(combinedMessage)
}
// Clear pending notifications
state.pendingNotifications = []
}
private timeOk() {
if (!timeRestrictionEnabled) return true
def now = new Date()
def start = getDateTime(startTimeType, startTime, startSunriseOffset, startSunsetOffset)
def end = getDateTime(endTimeType, endTime, endSunriseOffset, endSunsetOffset)
return timeOfDayIsBetween(start, end, now, location.timeZone)
}
private getDateTime(type, time, sunriseOffset, sunsetOffset) {
def result
switch(type) {
case "time":
result = timeToday(time)
break
case "sunrise":
result = getSunrise()
if (sunriseOffset) {
result = new Date(result.time + (sunriseOffset * 60000))
}
break
case "sunset":
result = getSunset()
if (sunsetOffset) {
result = new Date(result.time + (sunsetOffset * 60000))
}
break
}
return result
}