/**
* Driver: Ecowitt WiFi Gateway
* Author: Simon Burke (Original author Mirco Caramori - github.com/mircolino)
* Repository: https://github.com/sburke781/ecowitt
* Import URL: https://raw.githubusercontent.com/sburke781/ecowitt/main/ecowitt_gateway.groovy
*
* 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.
*
* Change Log:
*
* 2020.04.24 - Initial implementation
* 2020.04.29 - Added GitHub versioning
* - Added support for more sensors: WH40, WH41, WH43, WS68 and WS80
* 2020.04.29 - Added sensor battery range conversion to 0-100%
* 2020.05.03 - Optimized state dispatch and removed unnecessary attributes
* 2020.05.04 - Added metric/imperial unit conversion
* 2020.05.05 - Gave child sensors a friendlier default name
* 2020.05.08 - Further state optimization and release to stable
* 2020.05.11 - HTML templates
* - Normalization of floating values
* 2020.05.12 - Added windDirectionCompass, ultravioletDanger, ultravioletColor, aqiIndex, aqiDanger, aqiColor attributes
* 2020.05.13 - Improved PM2.5 -> AQI range conversion
* - HTML template syntax checking and optimization
* - UI error handling using red-colored state text messages
* 2020.05.14 - Major refactoring and architectural change
* - PWS like the WS2902 are recognized and no longer split into multiple child sensors
* - Rain (WH40), Wind and Solar (WH80) and Outdoor Temp/Hum (WH32) if detected, are combined into a single
* virtual WS2902 PWS to improve HTML Templating
* - Fixed several imperial-metric conversion issues
* - Metric pressure is now converted to hPa
* - Laid the groundwork for identification and support of sensors WH41, WH55 and WH57
* - Added several calculated values such as windChill, dewPoint, heatIndex etc. with color and danger levels
* - Time of data received converted from UTC to hubitat default locale format
* - Added error handling using state variables
* - Code optimization
* 2020.05.22 - Added orphaned sensor garbage collection using "Resync Sensors" commands
* 2020.05.23 - Fixed a bug in the PM2.5 to AQI conversion
* 2020.05.24 - Fixed a possible command() and parse() race condition
* 2020.05.26 - Added icons support in the HTML template
* 2020.05.30 - Added HTML template repository
* - Added support for multiple (up to 5) HTML template to each child sensor
* - Fixed wind icon as direction is reported as "from" where the wind originates
* 2020.06.01 - Fixed a cosmetic bug where "pending" status would not be set on non-existing attributes
* 2020.06.02 - Added visual confirmation of "resync sensors pending"
* 2020.06.03 - Added last data received timestamp to the child drivers to easily spot if data is not being received from the sensor
* - Added battery icons (0%, 20%, 40%, 60%, 80%, 100%)
* - Reorganized error e/o status reporting, now displayed in a dedicated "status" attribute
* 2020.06.04 - Added the ability to enter the MAC address directly as a DNI in the parent device creation page
* 2020.06.05 - Added support for both MAC and IP addresses (since MACs don't work across VLANs)
* 2020.06.06 - Add importURL for easier updating
* 2020.06.08 - Added support for Lightning Detection Sensor (WH57)
* 2020.06.08 - Added support for Multi-channel Water Leak Sensor (WH55)
* 2020.06.21 - Added support for pressure correction to sea level based on altitude and temperature
* 2020.06.22 - Added preference to let the end-user decide whether to compound or not outdoor sensors
* Added custom battery attributes in bundled PWS sensors
* 2020.08.27 - Added user defined min/max voltage values to fine-tune battery status in sensors reporting it as voltage range
* Added Hubitat Package Manager repository tags
* 2020.08.27 - Fixed null exception caused by preferences being set asynchronously
* - Removed sensor "time" attribute which could cause excessive sendEvent activity
* 2020.08.31 - Added support for new Indoor Air Quality Sensor (WH45)
* - Optimized calculation of inferred values: dewPoint, heatIndex, windChill and AQI
* 2020.09.08 - Added support for Water/Soil Temperature Sensor (WH34)
* 2020.09.17 - Added (back) real-time AQI index, color and danger
* 2020.09.20 - https://github.com/lymanepp: Added Summer Simmer Index attributes
* - Added preferences to selectively calculate HeatIndex, SimmerIndex, WindChill and DewPoint on a per-sensor basis
* 2020.09.21 - https://github.com/lymanepp: Improved accuracy of dew point calculations
* 2020.10.02 - Added WeatherFlow Smart Weather Stations local UDP support
* 2020.10.06 - Fixed a minor issue with lightning attributes
* - Added new templates to the template repository
* 2020.10.09 - Fixed a regression causing a null exception when the lightning sensor reports no strikes
* 2020.10.27 - Changed the sensor DNI naming scheme which prevented the support for multiple gateways
* 2020.10.29 - In a virtual (bundled) PWS, now each individual component is correctly identified if orphaned
* - Added safeguards for heat, summer simmer and wind chill indexes to prevent invalid values when temperature is
* above or below a certain threshold
* 2021.02.04 - Added support for humidityAbs (absolute humidity) based on current relative humidity and temperature
* 2021.02.06 - Fixed WH45 temperature and humidity signature
* 2021.02.08 - Added "Carbon Dioxide Measurement" capability
* - Renamed attributes "co2" to native "carbonDioxide" and "co2_avg_24h" to "carbonDioxide_avg_24h"
* - When a sensor is on USB power, battery attributes are no longer created
* 2021.05.18 - streamlined double conversion in attributeUpdateDewPoint()
* 2021.06.02 - bug fixing
* 2021.08.11 - updated status attribute to be deleted when no error
* - added the ability to set the number of digits for temperature and pressure
* - added the ability to completely disable html template support including all related attributes
* - fixed a bug where the soil moisture sensor would incorreclty display Dew Point and Heat Index preferences
* - used the new (2.2.8) API deleteCurrentState() to remove stale attributes when toggling Dew Point, Heat Index
* and Wind Chill support
* - improved and optimized device orphaned status detection
* 2021.08.18 - relocated repository: mircolino -> padus
* 2021.08.25 - relocated repository: padus -> sburke781
* - moved to ecowitt namespace
* 2021.12.04 - Replaced "time" attribute with lastUpdate, thanks to @kahn-hubitat for writing and testing this change
* 2021.12.04 - Added nameserver lookup for remote gateways where their public IP address can change, thanks again to @kahn-hubitat
* 2022.02.02 - Added Air Quality capability and population of associated Air Quality attribute in sensor driver, thanks @kahn-hubitat
* 2022.02.03 - Fixed bug with Air Quality update where it would only happen when HTML tile was enabled
* 2022.06.17 - Added support for Leaf Wetness Sensor
* 2022.06.17 - Leaf Sensor adjustments for version handling
* 2022.07.04 - Fix for WH31 Battery Readings not being picked up correctly
* 2022.07.09 - Formatting of Dynamic DNS Preference title and description
* 2022.10.22 - Add Wittboy Weather Station support (WH/WS90) - developed by @kahn-hubitat
* 2022.12.15 - Added Wittboy (WS90) rain readings to child sensor driver
* 2023.01.01 - Added wh90batt to sensor detection for detecting WittBoy PWS
* 2023-02-05 - Added ws90cap_volt reading (Wittboy Battery)
* 2023-02-18 - Added version check to parse method
* 2023-07-02 - Fix for Dew Point in Celsius
* 2023-09-24 - Updates for Wittboy battery readings and firmware (made by @xcguy)
* 2023-09-24 - New runtime attribute, dateutc stored in data value and detection of gain30_piezo (not stored)
* 2023-09-25 - Fixed error in Lightning Distance reporting in KMs instead of miles
* 2023-10-22 - Added option to forward data feed on to another hub
* 2023-12-03 - Added Git Repo Version Monitoring setting and logic
*/
import groovy.json.JsonSlurper;
public static String version() { return "v1.34.13"; }
public static String gitHubUser() { return "sburke781"; }
public static String gitHubRepo() { return "ecowitt"; }
public static String gitHubBranch() { return "main"; }
// Metadata -------------------------------------------------------------------------------------------------------------------
metadata {
definition(name: "Ecowitt WiFi Gateway", namespace: "ecowitt", author: "Simon Burke", importUrl: "https://raw.githubusercontent.com/${gitHubUser()}/${gitHubRepo()}/${gitHubBranch()}/ecowitt_gateway.groovy") {
capability "Sensor";
command "resyncSensors";
// Gateway info
attribute "driver", "string"; // Driver version (new version notification)
attribute "mac", "string"; // Address (either MAC or IP)
attribute "model", "string"; // Model number
attribute "firmware", "string"; // Firmware version
attribute "rf", "string"; // Sensors radio frequency
attribute "passkey", "string"; // PASSKEY
attribute "lastUpdate", "string"; // Time last data was posted
attribute "status", "string"; // Display current driver status
attribute "dynamicIPResult","string" // Result of nameserver lookup
attribute "runtime","number" // Run time
}
preferences {
input(name: "macAddress", type: "string", title: "MAC / IP Address", description: "Wi-Fi gateway MAC or IP address", defaultValue: "", required: true);
input(name: "DDNSName", type: "text", title: "DDNS Name", description: "Dynamic DNS Name to use to resolve a changing ip address. Leave Blank if not used.", required: false)
input(name: "DDNSRefreshTime", type: "number", title: "DDNS Refresh Time (Hours)",description: "How often (in Hours) to check/resolve the DDNS Name to discover an IP address change on a remote weather station? (Range 1 - 720, Default 24)?", range: "1..720", defaultValue: 3, required: false)
input(name: "forwardAddress", type: "string", title: "Forwarding IP Address", description: "IP address of hub to forward data feed to (optional)", defaultValue: "", required: false);
input(name: "forwardPort", type: "string", title: "Forwarding Port", description: "Port of hub to forward data feed to (optional)", defaultValue: "", required: false);
input(name: "forwardPath", type: "string", title: "Forwarding Path", description: "Path of hub to forward data feed to (optional)", defaultValue: "", required: false);
input(name: "bundleSensors", type: "bool", title: "Compound Outdoor Sensors", description: "Combine sensors in a virtual PWS array", defaultValue: true);
input(name: "unitSystem", type: "enum", title: "System of Measurement", description: "Unit system all values are converted to", options: [0:"Imperial", 1:"Metric"], multiple: false, defaultValue: 0, required: true);
input(name: "monitorGitVersion", type: "bool", title: "Monitor Git Driver Version", description: "Check Git Repository for New Driver Version", defaultValue: true);
input(name: "logLevel", type: "enum", title: "Log Verbosity", description: "Default: 'Debug' for 30 min and 'Info' thereafter", options: [0:"Error", 1:"Warning", 2:"Info", 3:"Debug", 4:"Trace"], multiple: false, defaultValue: 3, required: true);
}
}
/*
* Data variables used by the driver:
*
* "sensorResync" // User command triggered condition to cleanup/resynchronize the sensors
* "sensorMap" // Map of whether sensors have been combined or not into a PWS
* "sensorBundled" // "true" is we have an actual bundled PWS
* "sensorList" // List of children IDs
*/
// Preferences ----------------------------------------------------------------------------------------------------------------
private String gatewayMacAddress() {
//
// Return the MAC or IP address as entered by the user, or the current DNI if one hasn't been entered yet
//
String address = settings.macAddress as String;
if (address == null) {
//
// *** This is a timing hack ***
// When the users sets the DNI at installation, we update the settings before
// calling update() but when we get here the setting is still null!
//
address = device.getDeviceNetworkId();
}
return (address);
}
// ------------------------------------------------------------
private Boolean bundleOutdoorSensors() {
//
// Return true if outdoor sensors are to be bundled together
//
if (settings.bundleSensors != null) return (settings.bundleSensors);
return (true);
}
// ------------------------------------------------------------
Boolean unitSystemIsMetric() {
//
// Return true if the selected unit system is metric
// Declared public because it's being used by the child-devices
//
if (settings.unitSystem != null) return (settings.unitSystem.toInteger() != 0);
return (false);
}
// ------------------------------------------------------------
private Boolean monitorGitVersion() {
//
// Return true if we are monitoring the Git repository for updates
//
if (settings.monitorGitVersion != null) return (settings.monitorGitVersion);
return (true);
}
// ------------------------------------------------------------
Integer logGetLevel() {
//
// Get the log level as an Integer:
//
// 0) log only Errors
// 1) log Errors and Warnings
// 2) log Errors, Warnings and Info
// 3) log Errors, Warnings, Info and Debug
// 4) log Errors, Warnings, Info, Debug and Trace/diagnostic (everything)
//
// If the level is not yet set in the driver preferences, return a default of 2 (Info)
// Declared public because it's being used by the child-devices as well
//
if (settings.logLevel != null) return (settings.logLevel.toInteger());
return (2);
}
// Versioning -----------------------------------------------------------------------------------------------------------------
private Map versionExtract(String ver) {
//
// Given any version string (e.g. version 2.5.78-prerelease) will return a Map as following:
// Map.major version
// Map.minor version
// Map.build version
// Map.desc version
// or "null" if no version info was found in the given string
//
Map val = null;
if (ver) {
String pattern = /.*?(\d+)\.(\d+)\.(\d+).*/;
java.util.regex.Matcher matcher = ver =~ pattern;
if (matcher.groupCount() == 3) {
val = [:];
val.major = matcher[0][1].toInteger();
val.minor = matcher[0][2].toInteger();
val.build = matcher[0][3].toInteger();
val.desc = "v${val.major}.${val.minor}.${val.build}";
}
}
return (val);
}
// ------------------------------------------------------------
Boolean versionUpdate() {
//
// Return true is a new version is available
//
logDebug("versionUpdate()");
Boolean ok = false;
Boolean devOk = false;
String attribute = "driver";
// Retrieve current version from the driver
Map verCur = versionExtract(version());
// Retrieve the current version captured on the device
String devVer = device.currentValue(attribute);
// If the driver state variable has not been recorded on the device, update it
if (devVer == null || devVer == "") {
logDebug("versionUpdate: device driver version was empty, populating it now");
devOk = attributeUpdateString(verCur.desc, attribute);
devVer = verCur.desc;
}
// If we are monitoring Git for new driver version, check the manifest file and compare to the current driver version
if(monitorGitVersion()) {
try {
if (verCur) {
// Retrieve latest version from GitHub repository manifest
// If the file is not found, it will throw an exception
Map verNew = null;
String manifestText = "https://raw.githubusercontent.com/${gitHubUser()}/${gitHubRepo()}/${gitHubBranch()}/packageManifest.json".toURL().getText();
if (manifestText) {
// text -> json
Object parser = new groovy.json.JsonSlurper();
Object manifest = parser.parseText(manifestText);
verNew = versionExtract(manifest.version);
if (verNew) {
// Compare versions
if (verCur.major > verNew.major) verNew = null;
else if (verCur.major == verNew.major) {
if (verCur.minor > verNew.minor) verNew = null;
else if (verCur.minor == verNew.minor) {
if (verCur.build >= verNew.build) verNew = null;
}
}
}
}
String version = verCur.desc;
if (verNew) version = "${verCur.desc} (${verNew.desc} available)";
ok = attributeUpdateString(version, attribute);
}
}
catch (Exception e) {
logError("Exception in versionUpdate(): ${e}");
}
}
else {
ok = true;
// Capturing the situation where Git Repo monitoring has been turned off and a version update is still captured in the driver attribute
if(devVer != verCur.desc) {
logDebug("versionUpdate: Device driver version does not match the code, updating it now");
devOk = attributeUpdateString(verCur.desc, attribute);
}
}
return (ok);
}
// DNI ------------------------------------------------------------------------------------------------------------------------
private Map dniIsValid(String str) {
//
// Return null if not valid
// otherwise return both hex and canonical version
//
List val = [];
try {
List token = str.replaceAll(" ", "").tokenize(".:");
if (token.size() == 4) {
// Regular IPv4
token.each {
Integer num = Integer.parseInt(it, 10);
if (num < 0 || num > 255) throw new Exception();
val.add(num);
}
}
else if (token.size() == 6) {
// Regular MAC
token.each {
Integer num = Integer.parseInt(it, 16);
if (num < 0 || num > 255) throw new Exception();
val.add(num);
}
}
else if (token.size() == 1) {
// Hexadecimal IPv4 or MAC
str = token[0];
if ((str.length() != 8 && str.length() != 12) || str.replaceAll("[a-fA-F0-9]", "").length()) throw new Exception();
for (Integer idx = 0; idx < str.length(); idx += 2) val.add(Integer.parseInt(str.substring(idx, idx + 2), 16));
}
}
catch (Exception ignored) {
val.clear();
}
Map dni = null;
if (val.size() == 4) {
dni = [:];
dni.hex = sprintf("%02X%02X%02X%02X", val[0], val[1], val[2], val[3]);
dni.canonical = sprintf("%d.%d.%d.%d", val[0], val[1], val[2], val[3]);
}
if (val.size() == 6) {
dni = [:];
dni.hex = sprintf("%02X%02X%02X%02X%02X%02X", val[0], val[1], val[2], val[3], val[4], val[5]);
dni.canonical = sprintf("%02x:%02x:%02x:%02x:%02x:%02x", val[0], val[1], val[2], val[3], val[4], val[5]);
}
return (dni);
}
// ------------------------------------------------------------
private String dniUpdate() {
//
// Get the gateway address (either MAC or IP) from the properties and, if valid and not done already, update the driver DNI
// Return "error") invalid address entered by the user
// null) same address as before
// "") new valid address
//
logDebug("dniUpdate()");
String error = "";
String attribute = "mac";
String setting = gatewayMacAddress();
Map dni = dniIsValid(setting);
if (dni) {
if ((device.currentValue(attribute) as String) == dni.canonical) {
// The address hasn't changed: we do nothing
error = null;
}
else {
// Save the new address as an attribute for later comparison
attributeUpdateString(dni.canonical, attribute);
// Update the DNI
device.setDeviceNetworkId(dni.hex);
}
}
else {
error = "\"${setting}\" is not a valid MAC or IP address";
}
return (error);
}
def nsCallback(resp, data) {
logDebug("in callback")
// test change
def jSlurp = new JsonSlurper()
Map ipData = (Map)jSlurp.parseText((String)resp.data)
def String newIP = ipData.Answer.data[0]
sendEvent(name:"dynamicIPResult", value:ipData.Answer.data[0])
// now compare ip to our own and if different reset and log
if ((newIP != null) && (newIP != ""))
{
def String currentIP = settings.macAddress
logInfo("Comparing resolved IP: $newIP to $currentIP")
if (currentIP != newIP)
{
logInfo("IP address has Changed !!! Resetting DNI !")
Map dni = dniIsValid(newIP);
// Update Device Network ID
logDebug("got back dni = $dni")
if (dni)
{
device.updateSetting("macAddress", [type: "string", value: dni.canonical]);
dniUpdate();
resyncSensors();
}
}
}
}
void DNSCheckCallback() {
logInfo("Dns Update Check Callback Startup")
updated()
}
// Logging --------------------------------------------------------------------------------------------------------------------
void logDebugOff() {
//
// runIn() callback to disable "Debug" logging after 30 minutes
// Cannot be private
//
if (logGetLevel() > 2) device.updateSetting("logLevel", [type: "enum", value: "2"]);
}
// ------------------------------------------------------------
private void logError(String str) { log.error(str); }
private void logWarning(String str) { if (logGetLevel() > 0) log.warn(str); }
private void logInfo(String str) { if (logGetLevel() > 1) log.info(str); }
private void logDebug(String str) { if (logGetLevel() > 2) log.debug(str); }
private void logTrace(String str) { if (logGetLevel() > 3) log.trace(str); }
// ------------------------------------------------------------
private void logData(Map data) {
//
// Log all data received from the wifi gateway
// Used only for diagnostic/debug purposes
//
if (logGetLevel() > 3) {
data.each {
logTrace("$it.key = $it.value");
}
}
}
// Device Status --------------------------------------------------------------------------------------------------------------
private Boolean devStatus(String str = null, String color = null) {
if (str) {
if (color) str = "${str}";
return (attributeUpdateString(str, "status"));
}
if (device.currentValue("status") != null) {
device.deleteCurrentState("status");
return (true);
}
return (false);
}
// ------------------------------------------------------------
private Boolean devStatusIsError() {
String str = device.currentValue("status") as String;
if (str && str.contains("")) return (true);
return (false);
}
// Sensor handling ------------------------------------------------------------------------------------------------------------
String sensorIdToDni(String sid) {
String pid = device.getId().concat("-");
if (sid.startsWith(pid)) return (sid);
return (pid.concat(sid));
}
// ------------------------------------------------------------
String sensorDniToId(String dni) {
String pid = device.getId().concat("-");
if (dni.startsWith(pid)) return (dni.substring(pid.size()));
return (dni);
}
// ------------------------------------------------------------
/*
* Outdoor
* Temperature Wind
* & Humidity Rain & Solar
* ------------- ------------- --------------
* WH26 X
* WH40 X
* WH68 X
* WH80 X X
* WH65/WH69 X X X
* WS90 X X X
*
*/
private void sensorMapping(Map data) {
//
// Remap sensors, boundling or decoupling devices, depending on what's present
//
// 0 1 2 3 4 5 6 7 8 9 10 11 12 13
String[] sensorMap = ["WH69", "WH25", "WH26", "WH31", "WH40", "WH41", "WH51", "WH55", "WH57", "WH80", "WH34", "WFST", "WN35", "WS90"];
logDebug("sensorMapping()");
// Detect outdoor sensors by their battery signature
Boolean wh26 = data.containsKey("wh26batt");
Boolean wh40 = data.containsKey("wh40batt");
Boolean wh68 = data.containsKey("wh68batt");
Boolean wh80 = data.containsKey("wh80batt");
Boolean wh69 = data.containsKey("wh65batt");
Boolean ws90 = data.containsKey("ws90batt") || data.containsKey("wh90batt");
// Count outdoor sensor
Integer outdoorSensors = 0;
if (wh26) outdoorSensors += 1;
if (wh40) outdoorSensors += 1;
if (wh68) outdoorSensors += 1;
if (wh80) outdoorSensors += 1;
// A bit of sanity check
if (wh69 && outdoorSensors) logWarning("The PWS should be the only outdoor sensor");
if (ws90 && outdoorSensors) logWarning("The PWS should be the only outdoor sensor");
if (wh80 && wh26) logWarning("Both WH80 and WH26 are present with overlapping sensors");
if (wh80) {
//
// WH80 (includes temp & humidity)
//
sensorMap[2] = sensorMap[9];
}
if (wh69) {
//
// We have a real WH65/WH69 PWS
//
sensorMap[2] = sensorMap[0];
sensorMap[4] = sensorMap[0];
sensorMap[9] = sensorMap[0];
}
if (ws90) {
//
// We have a real ws90 PWS
//
sensorMap[2] = sensorMap[13];
sensorMap[4] = sensorMap[13];
sensorMap[9] = sensorMap[13];
}
else if (bundleOutdoorSensors() && outdoorSensors > 1) {
//
// We are requested to bundle outdoor sensors and we have more than 1
//
sensorMap[2] = sensorMap[0];
sensorMap[4] = sensorMap[0];
sensorMap[9] = sensorMap[0];
device.updateDataValue("sensorBundled", "WH69");
}
// Save the mapping in the state variables
device.updateDataValue("sensorMap", sensorMap.toString());
}
// ------------------------------------------------------------
String sensorModel(Integer id) {
// assert (id >= 0 && id <= 10);
// 0 1 2 3 4 5 6 7 8 9 10 11 12
// String sensorMap = "[WH69, WH25, WH26, WH31, WH40, WH41, WH51, WH55, WH57, WH80, WH34, WFST, WN35]";
//
String sensorMap = device.getDataValue("sensorMap");
id *= 6;
return (sensorMap.substring(id + 1, id + 5));
}
// ------------------------------------------------------------
private String sensorName(Integer id, Integer channel) {
Map sensorId = ["WH69": "PWS Sensor",
"WH25": "Indoor Ambient Sensor",
"WH26": "Outdoor Ambient Sensor",
"WH31": "Ambient Sensor",
"WH40": "Rain Gauge Sensor",
"WH41": "Air Quality Sensor",
"WH51": "Soil Moisture Sensor",
"WH55": "Water Leak Sensor",
"WH57": "Lightning Detection Sensor",
"WH80": "Wind Solar Sensor",
"WH34": "Water/Soil Temperature Sensor",
"WFST": "WeatherFlow Station",
"WN35": "Leaf Wetness Sensor"];
String model = sensorId."${sensorModel(id)}";
return (channel? "${model} ${channel}": model);
}
// ------------------------------------------------------------
private String sensorId(Integer id, Integer channel) {
String model = sensorModel(id);
return (channel? "${model}_CH${channel}": model);
}
// ------------------------------------------------------------
private Boolean sensorIsBundled(Integer id, Integer channel) {
return (sensorModel(id) == device.getDataValue("sensorBundled"));
}
// ------------------------------------------------------------
private void sensorGarbageCollect() {
//
// Match the new (soon to be created) sensor list with the existing one
// and delete sensors in the existing list that are not in the new one
//
ArrayList sensorList = [];
String value = device.getDataValue("sensorList");
if (value) sensorList = value.tokenize("[, ]");
List list = getChildDevices();
if (list) list.each {
String dni = it.getDeviceNetworkId();
if (sensorList.contains(sensorDniToId(dni)) == false) deleteChildDevice(dni);
}
}
// ------------------------------------------------------------
private Boolean sensorEnumerate(String key, String value, Integer id = null, Integer channel = null) {
//
// Enumerate sensors needed for the current data
//
if (id) {
String sid = sensorId(id, channel);
ArrayList sensorList = [];
value = device.getDataValue("sensorList");
if (value) sensorList = value.tokenize("[, ]");
if (sensorList.contains(sid) == false) {
sensorList.add(sid);
// Save the list in the state variables
device.updateDataValue("sensorList", sensorList.toString());
}
}
return (true);
}
// ------------------------------------------------------------
private Boolean sensorUpdate(String key, String value, Integer id = null, Integer channel = null) {
//
// If not present, add the child sensor corresponding to the specified id/channel
// and, if child sensor is present, update the attribute
//
// If id is null we broadcast to all children
//
Boolean updated = false;
try {
if (id) {
String dni = sensorIdToDni(sensorId(id, channel));
com.hubitat.app.ChildDeviceWrapper sensor = getChildDevice(dni);
if (sensor == null) {
//
// Support for sensors with legacy DNI (without the parent ID)
//
sensor = getChildDevice(sensorDniToId(dni));
if (sensor) {
// Found existing sensor with legacy name: update it
sensor.setDeviceNetworkId(dni);
}
else {
//
// Sensor doesn't exist: we need to create it
//
sensor = addChildDevice("Ecowitt RF Sensor", dni, [name: sensorName(id, channel), isComponent: true]);
if (sensor && sensorIsBundled(id, channel)) sensor.updateDataValue("isBundled", "true");
}
devStatus();
}
if (sensor) updated = sensor.attributeUpdate(key, value);
}
else {
// We broadcast to all children
List list = getChildDevices();
if (list) list.each { if (it.attributeUpdate(key, value)) updated = true; }
}
}
catch (Exception e) {
if (e instanceof com.hubitat.app.exception.UnknownDeviceTypeException) {
logError("Unable to create child sensor device. Please make sure the \"ecowitt_sensor.groovy\" driver is installed.");
devStatus("Unable to create child sensor device. Please make sure the \"ecowitt_sensor.groovy\" driver is installed", "red");
}
else logError("Exception in sensorUpdate(${id}, ${channel}): ${e}");
}
return (updated);
}
// Attribute handling ---------------------------------------------------------------------------------------------------------
private Boolean attributeUpdateString(String val, String attribute) {
//
// Only update "attribute" if different
// Return true if "attribute" has actually been updated/created
//
if ((device.currentValue(attribute) as String) != val) {
sendEvent(name: attribute, value: val);
return (true);
}
return (false);
}
private Boolean attributeUpdateNumber(Number val, String attribute) {
//
// Only update "attribute" if different
// Return true if "attribute" has actually been updated/created
//
if ((device.currentValue(attribute) as Number) != val) {
sendEvent(name: attribute, value: val);
return (true);
}
return (false);
}
// ------------------------------------------------------------
private Boolean attributeUpdate(Map data, Closure sensor) {
//
// Dispatch parent/childs attribute changes to hub
//
Boolean updated = false;
data.each {
switch (it.key) {
//
// Gateway attributes
//
case "model":
// Eg: model = GW1000_Pro
updated = attributeUpdateString(it.value, "model");
break;
case "stationtype":
// Eg: firmware = GW1000B_V1.5.7
Map ver = versionExtract(it.value);
if (ver) it.value = ver.desc;
updated = attributeUpdateString(it.value, "firmware");
break;
case "freq":
// Eg: rf = 915M
updated = attributeUpdateString(it.value, "rf");
break;
case "PASSKEY":
// Eg: passkey = 15CF2C872932F570B34AC469540099A4
updated = attributeUpdateString(it.value, "passkey");
break;
//
// Integrated/Indoor Ambient Sensor (WH25)
//
case "wh25batt":
case "tempinf":
case "humidityin":
case "baromrelin":
case "baromabsin":
updated = sensor(it.key, it.value, 1);
break;
// Leaf Wetness Sensor
case ~/leaf_batt([1-8])/:
case ~/leafwetness_ch([1-8])/:
updated = sensor(it.key, it.value, 12);
break;
//
// Outdoor Ambient Sensor (WH26 -> WH80 -> WH69)
//
case "wh26batt":
case "tempf":
case "humidity":
updated = sensor(it.key, it.value, 2);
break;
//
// Multi-channel Ambient Sensor (WH31)
//
case ~/batt([1-8])/:
case ~/temp([1-8])f/:
case ~/humidity([1-8])/:
updated = sensor(it.key, it.value, 3, java.util.regex.Matcher.lastMatcher.group(1).toInteger());
break;
//
// Rain Gauge Sensor (WH40 -> WH69)
//
case "wh40batt":
case "rainratein":
case "eventrainin":
case "hourlyrainin":
case "dailyrainin":
case "weeklyrainin":
case "monthlyrainin":
case "yearlyrainin":
case "totalrainin":
updated = sensor(it.key, it.value, 4);
break;
// Rain (ws90)
case "rrain_piezo":
case "erain_piezo":
case "hrain_piezo":
case "drain_piezo":
case "wrain_piezo":
case "mrain_piezo":
case "yrain_piezo":
case "train_piezo":
updated = sensor(it.key, it.value, 4);
break;
//
// Multi-channel Air Quality Sensor (WH41)
//
case ~/pm25batt([1-4])/:
case ~/pm25_ch([1-4])/:
case ~/pm25_avg_24h_ch([1-4])/:
updated = sensor(it.key, it.value, 5, java.util.regex.Matcher.lastMatcher.group(1).toInteger());
break;
//
// Air Quality Monitor (WH45)
//
case "tf_co2":
case "humi_co2":
case "pm25_co2":
case "pm25_24h_co2":
case "pm10_co2":
case "pm10_24h_co2":
case "co2":
case "co2_24h":
case "co2_batt":
updated = sensor(it.key, it.value, 5);
break;
//
// Multi-channel Soil Moisture Sensor (WH51)
//
case ~/soilbatt([1-8])/:
case ~/soilmoisture([1-8])/:
updated = sensor(it.key, it.value, 6, java.util.regex.Matcher.lastMatcher.group(1).toInteger());
break;
//
// Multi-channel Water Leak Sensor (WH55)
//
case ~/leakbatt([1-4])/:
case ~/leak_ch([1-4])/:
updated = sensor(it.key, it.value, 7, java.util.regex.Matcher.lastMatcher.group(1).toInteger());
break;
//
// Lightning Detection Sensor (WH57)
//
case "wh57batt":
case "lightning":
case "lightning_num":
case "lightning_time":
updated = sensor(it.key, it.value, 8);
break;
//
// Wind & Solar Sensor (WH80 -> WH69, WS90)
//
case "wh65batt":
case "wh68batt":
case "wh80batt":
case "wh90batt":
case "ws80cap_volt":
case "ws90cap_volt":
case "ws80_ver":
case "ws90_ver":
case "winddir":
case "winddir_avg10m":
case "windspeedmph":
case "windspdmph_avg10m":
case "windgustmph":
case "maxdailygust":
case "uv":
case "solarradiation":
updated = sensor(it.key, it.value, 9);
break;
//
// Multi-channel Water Leak Sensor (WH34)
//
case ~/tf_batt([1-8])/:
case ~/tf_ch([1-8])/:
updated = sensor(it.key, it.value, 10, java.util.regex.Matcher.lastMatcher.group(1).toInteger());
break;
//
// WeatherFlow Station (WFST)
//
case ~/batt_wf([1-8])/:
case ~/tempf_wf([1-8])/:
case ~/humidity_wf([1-8])/:
case ~/baromrelin_wf([1-8])/:
case ~/baromabsin_wf([1-8])/:
case ~/lightning_wf([1-8])/:
case ~/lightning_time_wf([1-8])/:
case ~/lightning_energy_wf([1-8])/:
case ~/lightning_num_wf([1-8])/:
case ~/uv_wf([1-8])/:
case ~/solarradiation_wf([1-8])/:
case ~/rainratein_wf([1-8])/:
case ~/eventrainin_wf([1-8])/:
case ~/hourlyrainin_wf([1-8])/:
case ~/dailyrainin_wf([1-8])/:
case ~/weeklyrainin_wf([1-8])/:
case ~/monthlyrainin_wf([1-8])/:
case ~/yearlyrainin_wf([1-8])/:
case ~/totalrainin_wf([1-8])/:
case ~/winddir_wf([1-8])/:
case ~/winddir_avg10m_wf([1-8])/:
case ~/windspeedmph_wf([1-8])/:
case ~/windspdmph_avg10m_wf([1-8])/:
case ~/windgustmph_wf([1-8])/:
case ~/maxdailygust_wf([1-8])/:
updated = sensor(it.key, it.value, 11, java.util.regex.Matcher.lastMatcher.group(1).toInteger());
break;
case "runtime":
if(it.value.isInteger()) { updated = attributeUpdateNumber(it.value.toInteger(), "runtime"); }
break;
case "dateutc":
state.dateutc = it.value;
updated = true;
break;
case "gain30_piezo":
// we won't handle this one for now, need to work out what it relates to...
updated = true;
break;
case "endofdata":
// Special key to notify all drivers (parent and children) of end-od-data status
updated = sensor(it.key, it.value);
// Last thing we do on the driver
if (attributeUpdateString(it.value, "lastUpdate")) updated = true;
break;
default:
logDebug("Unrecognized attribute: ${it.key} = ${it.value}");
break;
}
}
return (updated);
}
// Commands -------------------------------------------------------------------------------------------------------------------
void resyncSensors() {
//
// This will trigger a sensor remapping and cleanup
//
try {
logDebug("resyncSensors()");
if (dniIsValid(device.getDeviceNetworkId())) {
// We have a valid gateway dni
devStatus("Sensor sync pending", "blue");
device.updateDataValue("sensorResync", "true");
}
}
catch (Exception e) {
logError("Exception in resyncSensors(): ${e}");
}
}
// Driver lifecycle -----------------------------------------------------------------------------------------------------------
void installed() {
//
// Called once when the driver is created
//
try {
logDebug("installed()");
Map dni = dniIsValid(device.getDeviceNetworkId());
if (dni) {
device.updateSetting("macAddress", [type: "string", value: dni.canonical]);
updated();
}
}
catch (Exception e) {
logError("Exception in installed(): ${e}");
}
}
// ------------------------------------------------------------
void updated() {
//
// Called everytime the user saves the driver preferences
//
try {
logDebug("updated()");
// Clear previous states
state.clear();
// Unschedule possible previous runIn() calls
unschedule();
// lgk if ddns name resolve this first and do ip check before dniupdatE.. ALSO schedule the re-check.
def String ddnsname = settings.DDNSName
def Number ddnsupdatetime = settings.DDNSRefreshTime
logDebug("DDNS Name = $ddnsname")
logDebug("DDNS Refresh Time = $ddnsupdatetime")
if ((ddnsname != null) && (ddnsname != "")) {
logDebug("Got ddns name $ddnsname")
// now resolve
Map params = [
uri: "https://8.8.8.8/resolve?name=$ddnsname&type=A",
contentType: "text/plain",
timeout: 20
]
logDebug("calling dns Update url = $params")
asynchttpGet("nsCallback", params)
}
// now schedule next run of update
if ((ddnsupdatetime != null) && (ddnsupdatetime != 00)) {
def thesecs = ddnsupdatetime * 3600
logInfo("Rescheduling IP Address Check to run again in $thesecs seconds.")
runIn(thesecs, "DNSCheckCallback");
}
// Update Device Network ID
String error = dniUpdate();
if (error == null) {
// The gateway dni hasn't changed: we set OK only if a resync sensors is not pending
if (device.getDataValue("sensorResync")) devStatus("Sensor sync pending", "blue");
else devStatus();
}
else if (error != "") devStatus(error, "red");
else resyncSensors();
// Update driver version now and every Sunday @ 2am, if we are monitoring Git
versionUpdate();
if(monitorGitVersion()) {
schedule("0 0 2 ? * 1 *", versionUpdate);
}
// Turn off debug log in 30 minutes
if (logGetLevel() > 2) runIn(1800, logDebugOff);
// lgk get rid of now unused time attribute
device.deleteCurrentState("time")
}
catch (Exception e) {
logError("Exception in updated(): ${e}");
}
}
// ------------------------------------------------------------
void uninstalled() {
//
// Called once when the driver is deleted
//
try {
// Delete all children
List list = getChildDevices();
if (list) list.each { deleteChildDevice(it.getDeviceNetworkId()); }
logDebug("uninstalled()");
}
catch (Exception e) {
logError("Exception in uninstalled(): ${e}");
}
}
void uninstalledChildDevice(String dni) {
//
// Called by the children to notify the parent they are being uninstalled
//
}
// ------------------------------------------------------------
def forwardData(String msg) {
if(forwardAddress != null && forwardAddress != "") {
logDebug("forwardData() - forwarding to IP ${forwardAddress}, Port ${forwardPort} and Path ${forwardPath}");
logDebug("forwardData() - data = ${msg}");
def bodyForm = msg;
def postParams = [:];
def headers = [:];
headers.put("accept", "application/x-www-form-urlencoded");
postParams = [
uri: "http://${forwardAddress}:${forwardPort}",
path: forwardPath,
headers: headers,
contentType: "application/x-www-form-urlencoded",
body : bodyForm,
ignoreSSLIssues: true
];
try {
asynchttpPost(postParams);
}
catch(Exception e)
{
logError("forwardData: Exception ${e}")
}
}
}
// ------------------------------------------------------------
void parse(String msg) {
//
// Called everytime a POST message is received from the WiFi Gateway
//
try {
logDebug("parse()");
// Parse POST message
Map data = parseLanMessage(msg);
// Save only the body and discard the header
String body = data["body"];
// Build a map with one key/value pair for each field we receive
data = [:];
body.split("&").each {
String[] keyValue = it.split("=");
data[keyValue[0]] = (keyValue.size() > 1)? keyValue[1]: "";
}
// "dewPoint" and "heatIndex" are based on "tempf" and "humidity"
// for them to be calculated properly, in "data", "humidity", if present, must come after "tempf"
// "windchill" is based on "tempf" and "windspeedmph"
// for it to be calculated properly, in "data", "windspeedmph", if present, must come after "tempf"
// "aqi" is based on "pm25_24h_co2" and "pm10_24h_co2"
// for it to be calculated properly, in "data", "pm10_24h_co2", if present, must come after "pm25_24h_co2"
// Inject a special key (at the end of the data map) to notify all the driver of end-of-data status. Value is local time
def now = new Date().format('yyyy-MM-dd h:mm a',location.timeZone)
data["endofdata"] = now
logData(data);
if (device.getDataValue("sensorResync")) {
// We execute this block only the first time we receive data from the wifi gateway
// or when the user presses the "Resynchronize Sensors" command
device.removeDataValue("sensorResync");
// (Re)create sensor map
device.removeDataValue("sensorBundled");
device.removeDataValue("sensorMap");
sensorMapping(data);
// (Re)create sensor list
device.removeDataValue("sensorList");
attributeUpdate(data, this.&sensorEnumerate);
// Match the new (soon to be created) sensor list with the existing one
// and delete sensors in the existing list that are not in the new one
sensorGarbageCollect();
// Clear pending status and start processing data
devStatus();
}
attributeUpdate(data, this.&sensorUpdate);
//Driver Version Updates
// If the driver has been updated on the HE hub, check that this is reflected in the driver attribute
// If the current driver value is empty or different, run the version update to record the correct details
if(curVer == null || curVer == "" || !(curVer.startsWith(versionExtract(version()).desc))) {
logDebug("Driver on HE Hub updated, running versionUpdate() to update the driver attribute");
versionUpdate();
}
// Forward the data on, if configured for the Gateway
forwardData(body);
}
catch (Exception e) {
logError("Exception in parse(): ${e}");
}
}
// Recycle bin ----------------------------------------------------------------------------------------------------------------
/*
synchronized(this) {
}
@Field static java.util.concurrent.Semaphore mutex = new java.util.concurrent.Semaphore(1)
if (!mutex.tryAcquire())
mutex.release()
*/
// EOF ------------------------------------------------------------------------------------------------------------------------