/** * OpenWeatherMap Multi-API Weather Driver * Platform: Hubitat Elevation * Compatible with One Call API 2.5, 3.0, and 4.0 Keys * Capabilities: Temperature, Illuminance, Barometric Pressure, Relative Humidity, Ultraviolet Index 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 is a complete rewrite of the original driver done by James Shimota. This driver is designed to fit a number of needs and does both Tile generation for dashboards AND attributes useful for functions within Hubitat Elevation Rules and other apps by the community. It was original written with the intent of staying a private code work, but I felt that there may be value to others in the community. The original was title Weather-Alerts and was very Alert tile centric I felt. It also relied on multiple sources such as OpenWeatherMaps (OWM) and Natiion Weather Service (NWS). The data returned on NWS was frequently bad and not accurate for my location - I'm in the farm country of Oregon and that created a problem since the City would often say 'local area' instead of my actual city! Icons and Images - there are multiple sources of images used in this driver. The original base path is the OWM API, but the base path can also use the Alternate Path which defaults to the Hubitat Public space. In addition, there are images from the an app written previously for the Moon Phase, and that also includes a dynamic 'shading' svg if desired. Wind Direction images are available from my repo - and if there are no wind direction images, icons are used. VERSIONS: v2.5.10 07/31/26 jshimota changed scheduling to be aware of day/night change so it updates 5 mins after to keep tiles aware v2.5.9 07/28/26 jshimota Added moon alt and az text to moonPhaseTile v2.5.8 07/28/26 jshimota Bug check: Cleaned up calcMoonPosition variables, aligned versions, and added text updates for moon angles in calcTextValue v2.5.7 07/28/26 jshimota Added independent calcMoonPosition routine dispatches for currentMoon attributes v2.5.6 07/28/26 jshimota Fixed location.timeZone null safety on tile date formats and corrected fallback attribute target in calcMoonPhaseSvgImage v2.5.5 07/28/26 jshimota Bug check cleanup: fixed null safe checks on generateTiles and convertWindDirectionState v2.5.4 07/28/26 jshimota Renamed ConditionImageText attributes to ConditionImageName and added .png extension to attribute values v2.5.3 07/28/26 jshimota Renamed ConditionIcon attributes to ConditionImageText across metadata, logic, and tiles v2.5.2 07/28/26 jshimota Renamed ConditionAltIcon attributes to ConditionAltImage across metadata, logic, and tiles v2.5.1 07/28/26 jshimota Renamed generic altitude references across routines/variables to sunAltitude v2.5.0 07/28/26 jshimota Renamed currentRain/currentSnow attributes to currentRainfall/currentSnowfall (and text variants) v2.4.9 07/28/26 jshimota Strictly enforce precisionPrecip scaling on zero and default values (0, 0.0, 0.00) v2.4.8 07/28/26 jshimota Separated hourly vs daily rain/snow mapping structure (daily returns direct number, current uses 1h key) v2.4.7 07/28/26 jshimota Fixed rain and snow extraction logic for current and forecast data sets v2.4.6 07/28/26 jshimota Renamed Wind Direction Icon attributes to WindDirectionImage v2.4.5 07/28/26 jshimota Fix init race condition by evaluating sun position/isDay prior to parsing forecast data v2.4.4 07/28/26 jshimota Fix race condition on forecast tile. fixed state storage of sun altitude to correct name v2.4.3 07/28/26 jshimota changed altitude and azimuth to currentSun prefixed, added currentMoon values, v2.4.2 07/28/26 jshimota changed api log switch to logging section. renamed precisionSunAngles to precisionSunMoonAngles v2.4.1 07/25/26 jshimota Added button to allow a user to clear all schedules to help people converting v2.4.0 07/25/26 jshimota Initial public release v2.3.0 07/20/26 jshimota Integrated Moon Phase v2.2.0 07/17/26 jshimota basepath modifications v2.1.0 07/15/26 jshimota start point **/ static String version() { return '2.5.10' } metadata { definition( name: "OpenWeatherMap Multi-API Weather Driver", namespace: "jshimota", author: "James Shimota", importUrl: "https://raw.githubusercontent.com/jshimota01/hubitat/main/Drivers/owm_multi-API_weather/owm_multi-API_weather.groovy" ) { capability "Sensor" capability "Refresh" capability "Initialize" // - Core Capabilities capability "TemperatureMeasurement" capability "IlluminanceMeasurement" capability "PressureMeasurement" capability "RelativeHumidityMeasurement" capability "UltravioletIndex" // - Custom Driver Attributes attribute "betwixt", "string" attribute "overrideCity", "string" attribute "overrideLongitude", "number" attribute "overrideLatitude", "number" attribute "pressureUnit", "string" attribute "temperatureUnit", "string" attribute "windSpeedUnit", "string" attribute "illuminanceUnit", "string" attribute "humidityUnit", "string" // - Api specific response attributes attribute "apiTimezone", "string" attribute "apiTimezoneOffset", "number" attribute "apiLatitude", "number" attribute "apiLongitude", "number" // - Alert attributes attribute "currentAlert", "string" attribute "currentAlertDesc", "string" attribute "currentAlertSender", "string" attribute "currentAlertDescFull", "string" // - Calculated Solar Angle attributes attribute "currentSunAltitude", "number" attribute "currentSunAzimuth", "number" attribute "currentSunAltitudeText", "string" attribute "currentSunAzimuthText", "string" // - Calculated Moon Angle attributes attribute "currentMoonAltitude", "number" attribute "currentMoonAzimuth", "number" attribute "currentMoonAltitudeText", "string" attribute "currentMoonAzimuthText", "string" // - Current unique attributes attribute "currentSnowfall", "number" attribute "currentRainfall", "number" attribute "currentFeelsLike", "number" attribute "currentTemperature", "number" // - Current unique derived attributes attribute "currentHumidityText", "string" attribute "currentIlluminanceText", "string" attribute "currentPressureText", "string" attribute "currentTemperatureText", "string" attribute "currentWindSpeedDescText", "string" attribute "currentSnowfallText", "string" attribute "currentRainfallText", "string" attribute "currentVisibility", "number" attribute "currentTwilightBeginTime", "number" attribute "currentSolarNoonTime", "number" attribute "currentTwilightEndTime", "number" attribute "currentIsDay", "enum", ["true","false"] attribute "currentWindDirCardinal", "string" attribute "currentWindDirFull", "string" attribute "currentWindDirImageUrl", "string" attribute "currentWindDirectionImage", "string" attribute "currentTwilightBeginTimeText", "string" attribute "currentSolarNoonTimeText", "string" attribute "currentTwilightEndTimeText", "string" attribute "currentVisibilityText", "string" attribute "currentWeatherSummaryText", "string" attribute "currentWindSummaryText", "string" // - Current Condition attributes attribute "currentConditionCode", "number" attribute "currentConditionType", "string" attribute "currentConditionTypeDesc", "string" attribute "currentConditionImageName", "string" // - Current Condition derived attribute attribute "currentConditionAltImage", "string" attribute "currentConditionIconImg", "string" attribute "currentConditionTypeAltDesc", "string" // - Current attributes (commonly found in both current and forecast) attribute "currentSunriseTime", "number" attribute "currentSunsetTime", "number" attribute "currentIlluminance", "number" attribute "currentPressure", "number" attribute "currentHumidity", "number" attribute "currentDewPoint", "number" attribute "currentUVI", "number" attribute "currentCloudPCT", "number" attribute "currentWindGust", "number" attribute "currentWindDeg", "number" attribute "currentWindSpeed", "number" attribute "currentWindSpeedText", "string" attribute "currentSunriseTimeText", "string" attribute "currentSunsetTimeText", "string" // - Forecast today unique attributes attribute "todaySunriseTime", "number" attribute "todaySunsetTime", "number" attribute "todayMoonriseTime", "number" attribute "todayMoonsetTime", "number" attribute "todayMoonPhase", "number" attribute "todaySummary", "string" attribute "todayTempMin", "number" attribute "todayTempNight", "number" attribute "todayTempMax", "number" attribute "todayTempEve", "number" attribute "todayTempMorn", "number" attribute "todayTempDay", "number" attribute "todayFeelsLikeDay", "number" attribute "todayFeelsLikeNight", "number" attribute "todayFeelsLikeEve", "number" attribute "todayFeelsLikeMorn", "number" attribute "todayPressure", "number" attribute "todayHumidity", "number" attribute "todayDewPoint", "number" attribute "todayWindGust", "number" attribute "todayWindDeg", "number" attribute "todayWindSpeed", "number" attribute "todayWindSpeedText", "string" attribute "todayWindSpeedDescText", "string" attribute "todayCloudPCT", "number" attribute "todayPOP", "number" attribute "todayRain", "number" attribute "todaySnow", "number" attribute "todayUVI", "number" attribute "todaySunriseTimeText", "string" attribute "todaySunsetTimeText", "string" attribute "todayMoonriseTimeText", "string" attribute "todayMoonsetTimeText", "string" // - Forecast tomorrow unique attributes attribute "tomSunriseTime", "number" attribute "tomSunsetTime", "number" attribute "tomMoonriseTime", "number" attribute "tomMoonsetTime", "number" attribute "tomMoonPhase", "number" attribute "tomSummary", "string" attribute "tomTempMin", "number" attribute "tomTempNight", "number" attribute "tomTempMax", "number" attribute "tomTempEve", "number" attribute "tomTempMorn", "number" attribute "tomTempDay", "number" attribute "tomFeelsLikeDay", "number" attribute "tomFeelsLikeNight", "number" attribute "tomFeelsLikeEve", "number" attribute "tomFeelsLikeMorn", "number" attribute "tomPressure", "number" attribute "tomHumidity", "number" attribute "tomDewPoint", "number" attribute "tomWindGust", "number" attribute "tomWindDeg", "number" attribute "tomWindSpeed", "number" attribute "tomWindSpeedText", "string" attribute "tomWindSpeedDescText", "string" attribute "tomCloudPCT", "number" attribute "tomPOP", "number" attribute "tomRain", "number" attribute "tomSnow", "number" attribute "tomUVI", "number" attribute "tomSunriseTimeText", "string" attribute "tomSunsetTimeText", "string" attribute "tomMoonriseTimeText", "string" attribute "tomMoonsetTimeText", "string" // - Forecast tomorrow dayafter unique attributes attribute "tdaSunriseTime", "number" attribute "tdaSunsetTime", "number" attribute "tdaMoonriseTime", "number" attribute "tdaMoonsetTime", "number" attribute "tdaMoonPhase", "number" attribute "tdaSummary", "string" attribute "tdaTempMin", "number" attribute "tdaTempNight", "number" attribute "tdaTempMax", "number" attribute "tdaTempEve", "number" attribute "tdaTempMorn", "number" attribute "tdaTempDay", "number" attribute "tdaFeelsLikeDay", "number" attribute "tdaFeelsLikeNight", "number" attribute "tdaFeelsLikeEve", "number" attribute "tdaFeelsLikeMorn", "number" attribute "tdaPressure", "number" attribute "tdaHumidity", "number" attribute "tdaDewPoint", "number" attribute "tdaWindGust", "number" attribute "tdaWindDeg", "number" attribute "tdaWindSpeed", "number" attribute "tdaWindSpeedText", "string" attribute "tdaWindSpeedDescText", "string" attribute "tdaCloudPCT", "number" attribute "tdaPOP", "number" attribute "tdaRain", "number" attribute "tdaSnow", "number" attribute "tdaUVI", "number" attribute "tdaSunriseTimeText", "string" attribute "tdaSunsetTimeText", "string" attribute "tdaMoonriseTimeText", "string" attribute "tdaMoonsetTimeText", "string" // - Forecast unique derived attributes attribute "todayWindDirCardinal", "string" attribute "tomWindDirCardinal", "string" attribute "tdaWindDirCardinal", "string" attribute "todayWindDirFull", "string" attribute "tomWindDirFull", "string" attribute "tdaWindDirFull", "string" attribute "todayWindDirImageUrl", "string" attribute "tomWindDirImageUrl", "string" attribute "tdaWindDirImageUrl", "string" attribute "todayWindDirectionImage", "string" attribute "tomWindDirectionImage", "string" attribute "tdaWindDirectionImage", "string" attribute "todayDate", "number" attribute "tomDate", "number" attribute "tdaDate", "number" attribute "todayDateText", "string" attribute "tomDateText", "string" attribute "tdaDateText", "string" attribute "todayMoonPhaseText", "string" attribute "tomMoonPhaseText", "string" attribute "tdaMoonPhaseText", "string" // - Moon Phase attributes attribute "todayMoonPhaseSvgImage", "string" attribute "tomMoonPhaseSvgImage", "string" attribute "tdaMoonPhaseSvgImage", "string" attribute "todayMoonPhasePngImageUrl", "string" attribute "tomMoonPhasePngImageUrl", "string" attribute "tdaMoonPhasePngImageUrl", "string" attribute "todayMoonPhaseEmojiIcon", "string" attribute "tomMoonPhaseEmojiIcon", "string" attribute "tdaMoonPhaseEmojiIcon", "string" // - Wind Direction Emoji Attributes attribute "currentWindDirectionEmojiIcon", "string" attribute "todayWindDirectionEmojiIcon", "string" attribute "tomWindDirectionEmojiIcon", "string" attribute "tdaWindDirectionEmojiIcon", "string" // - Forecast Weather Condition Attributes attribute "todayConditionCode", "number" attribute "todayConditionType", "string" attribute "todayConditionTypeDesc", "string" attribute "todayConditionTypeAltDesc", "string" attribute "todayConditionImageName", "string" attribute "todayConditionAltImage", "string" attribute "tomConditionCode", "number" attribute "tomConditionType", "string" attribute "tomConditionTypeDesc", "string" attribute "tomConditionTypeAltDesc", "string" attribute "tomConditionImageName", "string" attribute "tomConditionAltImage", "string" attribute "tdaConditionCode", "number" attribute "tdaConditionType", "string" attribute "tdaConditionTypeDesc", "string" attribute "tdaConditionTypeAltDesc", "string" attribute "tdaConditionImageName", "string" attribute "tdaConditionAltImage", "string" // - Tiles attribute "currentAlertTile", "string" attribute "3DayForecastTile", "string" attribute "currentTile", "string" attribute "currentMoonPhaseTile", "string" command "clearAllDriverStates" command "clearAllAttributes" command "clearAllSchedules" command "pollOWM" } preferences { input name: "apiKey", type: "text", title: "API Key", description: "Enter your OpenWeatherMap API Key here
Required by OpenWeatherMaps", required: true input name: "apiSelection", type: "enum", title: "API Version", options: ["2.5": "One Call 2.5 (Obsolete!)","3.0": "One Call 3.0", "4.0": "One Call 4.0"], defaultValue: "3.0", description: "Select your API Key version here
Required by OpenWeatherMaps
*Note: 2.5 API is now obsolete as of June 2024
*Note: 4.0 API key uses 3.0 API poll method", required: true // Optional City field that dynamically overrides latitude/longitude if populated input name: "overrideCity", type: "text", title: "Base Override - City", description: "Optional - Will attempt to geo lookup and override ALL latitude/longitude values
Default:(empty)
EG: Portland, OR or London, UK.
*Note: Overrides Latitude/Longitude parameters of Hub AND values configured below
", required: false // Optional Image location overrides input name: "altIconLoc", type: "text", title: "Base Override - Icon Location", description: "Optional - Icon Source Location:
blank for default OWM location
https://tinyurl.com/icnqz/ (Which redirects directly to the
Hubitat Community Weather Icons repository)
", required: false input name: "altMoonPhaseImagePath", type: "text", title: "Base Override - Moon Phase Image Location", description: "Optional - Moon Phase Image Source Location:
blank for default Moon Phase Image location
https://raw.githubusercontent.com/thebearmay/hubitat/main/moonPhaseRes/
(Supplied by @thebearmay of the Hubitat community who wrote & maintains the original MoonPhase Tile driver)
", required: false input name: "altWindDirectionImageLoc", type: "text", title: "Base Override - Wind Direction Image Location", description: "Optional - Wind Direction Image Source Location:
If not used, Wind Direction icons are used", required: false input name: "overrideLatitude", type: "decimal", title: "Base Override - Latitude", description: "Optional - Leave blank to use Hub location", required: false input name: "overrideLongitude", type: "decimal", title: "Base Override - Longitude", description: "Optional - Leave blank to use Hub location", required: false input name: "altIconsEnable", type: "bool", title: "Base Override - Use Alternative Icons?", description: "Turn ON to use alternate icons (found in csv map within the driver), or OFF to use the standard OpenWeatherMap icons
Base Override - Icon Location MUST be filled!", defaultValue: false, required: true // Display Selector Options input name: "precisionHumid", type: "enum", title: "Display Decimal Precision - Humidity", options: ["0": "0 Places", "1": "1 Place", "2": "2 Places"], description: "Choice of decimal precision for humidity readings in logging and tiles
Default: 0
EG: 1, 1.5, 1.55", defaultValue: "0", required: true input name: "precisionPrecip", type: "enum", title: "Display Decimal Precision - Precipitation", options: ["0": "0 Places", "1": "1 Place", "2": "2 Places"], description: "Choice of decimal precision for rainfall readings in logging and tiles
Default: 2
EG: 1, 1.5, 1.55", defaultValue: "2", required: true input name: "precisionPress", type: "enum", title: "Display Decimal Precision - Pressure", options: ["0": "0 Places", "1": "1 Place", "2": "2 Places"], description: "Choice of decimal precision for barometer readings in logging and tiles
Default: 2
EG: 30mb,30.5mb, 30.55mb", defaultValue: "2", required: true input name: "precisionSunMoonAngles", type: "enum", title: "Display Decimal Precision - Sun/Moon Angles", options: ["0": "0 Places", "1": "1 Place", "2": "2 Places"], description: "Choice of decimal precision for sun and moon angle (altitude and azimuth) readings in logging and tiles
Default: 0
EG(with Unit): 149°, 149.5°, 149.55°", defaultValue: "0", required: true input name: "precisionTemp", type: "enum", title: "Display Decimal Precision - Temperature", options: ["0": "0 Places", "1": "1 Place", "2": "2 Places"], description: "Choice of decimal precision for temperature readings in logging and tiles
Default: 2
EG(with Unit): 70°F, 70.3°F, 70.55°F", defaultValue: "2", required: true input name: "precisionWind", type: "enum", title: "Display Decimal Precision - Wind Speed", options: ["0": "0 Places", "1": "1 Place", "2": "2 Places"], description: "Choice of decimal precision for wind speed readings in logging and tiles
Default: 2
EG (with Unit): 12 mph, 12.7 mph, 12.77 mph", defaultValue: "2", required: true // Display Format Selectors input name: "DateTimeForm", type: "enum", title: "Display Format - Date & Time Attributes", options: ["1": "M/d/yyyy h:mm a", "2": "M/d/yyyy HH:mm", "3": "MM/dd/yyyy h:mm a", "4": "MM/dd/yyyy HH:mm", "5": "d/M/yyyy h:mm a", "6": "d/M/yyyy HH:mm", "7": "dd/MM/yyyy h:mm a", "8": "dd/MM/yyyy HH:mm", "9": "yyyy/MM/dd HH:mm", "10": "Unix, UTC (native)"], description: "Choice of date & time format used in tiles and logging for attributes containing both date and time
Default: M/d/yyyy h:mm a
EG: 7/12/2026 9:00 AM, 12/7/2026 09:01, 2026/07/12 09:02, 1783859756", defaultValue: "1", required: true input name: "DateForm", type: "enum", title: "Display Format - Date Only Attributes", options: ["1": "M/d/yyyy", "3": "MM/dd/yyyy", "5": "d/M/yyyy", "7": "dd/MM/yyyy", "9": "yyyy/MM/dd", "10": "Unix, UTC (native)"], description: "Choice of date format used in tiles and logging for attribute names containing ONLY date
Default: M/d/yyyy
EG: 7/12/2026, 12/7/2026, 2026/07/12, 1783859756", defaultValue: "1", required: true input name: "TimeForm", type: "enum", title: "Display Format - Time Only Attributes", options: ["1": "h:mm a", "2": "HH:mm", "10": "Unix, UTC (native)"], description: "Choice of time format used in tiles and logging for attribute names containing ONLY time
Default: h:mm a
EG: 9:00 AM, 13:00, 1783859756", defaultValue: "1", required: true // Display Unit Selectors input name: "pressureUnit", type: "enum", title: "Display Unit - Barometric Pressure", options: ["hPa": "Hectopascals (hPa)", "inHg": "Inches of Mercury (inHg)", "kPa": "Kilopascals (kPa)", "mb": "Millibar (mb)", "mmHg": "Millimeters of Mercury (mmHg)", "none": "None (No Unit Suffix)"], description: "Choice of barometer unit used in tiles and logging
Default: Inches of Mercury (inHg)", defaultValue: "inHg", required: true input name: "humidityUnit", type: "enum", title: "Display Unit - Humidity", options: ["%": "Percent (%)", "%RH": "Percent RH (%RH)", "g/m³": "Absolute Humidity (g/m³)", "g/kg³": "Mixing Ratio (g/kg³)", "none": "None (No Unit Suffix)"], description: "Choice of humidity unit formatting used in tiles and logging
Default: Humidity (%)", defaultValue: "%", required: true input name: "illuminanceUnit", type: "enum", title: "Display Unit - Illuminance", options: ["lx": "Lux (lx)", "fc": "Foot-candle (fc)", "ph": "Phot (ph)", "none": "None (No Unit Suffix)"], description: "Choice of illuminance unit used in tiles and logging
Default: Lux (lx)", defaultValue: "lx", required: true input name: "precipUnit", type: "enum", title: "Display Unit - Precipitation (Rain/Snow)", options: ["mmHr": "Millimeters per Hour (mmHr)", "inHr": "Inches per Hour (inHr)", "\"": "Inches (\")", "none": "None (No Unit Suffix)"], description: "Choice of precipitation (both rain and snow) unit formatting used in tiles and logging
Default: Inches per Hour (inHr)", defaultValue: "inHr", required: true input name: "temperatureUnit", type: "enum", title: "Display Unit - Temperature", options: ["°F": "Fahrenheit (°F)", "°C": "Celsius (°C)", "K": "Kelvin (K)", "none": "None (No Unit Suffix)"], description: "Choice of temperature unit formatting used in tiles and logging
Default: Fahrenheit (°F)", defaultValue: "°F", required: true input name: "visibilityUnit", type: "enum", title: "Display Unit - Visibility Distance", options: ["m": "Meters (m)", "miles": "Miles (miles)", "ft": "Feet (ft)", "km": "Kilometers (km)", "none": "None (No Unit Suffix)"], description: "Choice of visibility distance unit used in tiles and logging
Default: Miles (miles)", defaultValue: "miles", required: true input name: "windSpeedUnit", type: "enum", title: "Display Unit - Wind Speed", options: ["mph": "Miles per Hour (mph)", "kmh": "Kilometers per Hour (km/h)", "kt": "Knots (kt)", "ms": "Meters per Second (m/s)", "none": "None (No Unit Suffix)"], description: "Choice of wind speed unit used in tiles and logging
Default: Miles per Hour (mph)", defaultValue: "mph", required: true // Polling Option Dropdown Menu input name: "dayInterval", type: "enum", title: "Polling - Daytime Interval", options: ["manual": "Manual Only (via pollOWM command)", "15": "15 Minutes", "30": "30 Minutes", "60": "1 Hour", "180": "3 Hours"], description: "Polling frequency to OWM during daytime (between sunrise and sunset)
Default: 30 Minutes", defaultValue: "30", required: true input name: "nightInterval", type: "enum", title: "Polling - Nighttime Interval", options: ["manual": "Manual Only (via pollOWM command)", "15": "15 Minutes", "30": "30 Minutes", "60": "1 Hour", "180": "3 Hours"], description: "Polling frequency of OWM during nighttime (between sunset and sunrise)
Default: 1 Hour", defaultValue: "60", required: true // Tile Options input name: "displayTileMoonPhaseSVGEnable", type: "bool", title: "Tile - SVG or PNG image use in Moon Phase Tile", description: "Choice of Moon Phase Tile image type. Enabled uses SVG, disabled uses PNG
Default: ON", defaultValue: true, required: true input name: "debugTileEnable", type: "bool", title: "Tile - Enable Tile Debug Info", description: "Enable to embed character count string on tiles
Default: Off", defaultValue: false, required: true // Independent Logging Switches input name: "logInfoEnable", type: "bool", title: "Logging - Enable Info Logging", description: "Enable to output normal activity to log
Default: On", defaultValue: true, required: true input name: "logErrorEnable", type: "bool", title: "Logging - Enable Error Logging", description: "Enable to output error activity to log
Default: On", defaultValue: true, required: true input name: "logWarnEnable", type: "bool", title: "Logging - Enable Warning Logging", description: "Enable to output warning activity to log
Default: On", defaultValue: true, required: true input name: "logDebugEnable", type: "bool", title: "Logging - Enable Debug Logging", description: "Enable to output debugging activity to log
Default: Off
(Is turned on for 30 minutes after Initialized or first installed)", defaultValue: false, required: true input name: "logTraceEnable", type: "bool", title: "Logging - Enable Trace Logging", description: "Enable to output tracing activity to log
Default: Off", defaultValue: false, required: true input name: "aPIKeyExposedEnable", type: "bool", title: "Logging - Expose API Key In Logging?", description: "Enable to show API Key value in log outputs
Default: Off", defaultValue: false, required: true } } def installed() { logInfo "Driver Installed. Turning on initial debug logging for 30 minutes." device.updateSetting("logDebugEnable", [type: "bool", value: true]) if (settings.altIconLoc == null || settings.altIconLoc.trim() == "") { device.updateSetting("altIconLoc", [type: "text", value: "https://tinyurl.com/icnqz/"]) } ["humidityUnit": "%RH", "pressureUnit": "inHg", "illuminanceUnit": "lx", "temperatureUnit": "°F", "windSpeedUnit": "mph", "precipUnit": "inHr", "visibilityUnit": "miles"].each { k, v -> if (settings[k] == null) device.updateSetting(k, [type: "enum", value: v]) sendIfChanged(name: k, value: v) } initialize() } def updated() { logInfo "Preferences updated. Running initialization ..." if (state.sunAltitude != null) { state.currentSunAltitude = state.sunAltitude state.remove("sunAltitude") } if (settings.altIconLoc == null || settings.altIconLoc.trim() == "") { device.updateSetting("altIconLoc", [type: "text", value: "https://tinyurl.com/icnqz/"]) } state.iconBasePath = calcIconBasePath(settings.altIconLoc) state.moonPhaseImagePath = calcMoonPhaseImagePath(settings.altMoonPhaseImagePath) state.windDirectionImagePath = calcWinDirImagePath(settings.altWindDirectionImageLoc) sendEvent(name: "temperature", value: 0, unit: settings.temperatureUnit ?: "°F") sendEvent(name: "pressure", value: 0, unit: settings.pressureUnit ?: "inHg") sendEvent(name: "illuminance", value: 0, unit: settings.illuminanceUnit ?: "lx") sendEvent(name: "humidity", value: 0, unit: settings.humidityUnit ?: "%") sendEvent(name: "ultravioletIndex", value: 0) ["todayMoonPhaseText", "tomMoonPhaseText", "tdaMoonPhaseText"].each { sendIfChanged(name: it, value: "--") } state.lastOverrideCity = null state.lastOverrideLatitude = null state.lastOverrideLongitude = null state.usedCity = null initialize() } def initialize() { logDebug "Clearing all scheduled jobs ..." unschedule() logInfo "Initializing driver ..." if (logDebugEnable == true) { runIn(1800, "disableDebugLogging") } state.moonPhaseImagePath = calcMoonPhaseImagePath(settings.altMoonPhaseImagePath) state.windDirectionImagePath = calcWinDirImagePath(settings.altWindDirectionImageLoc) state.isInitializing = true runIn(1, "scheduledPoll") } void clearAllDriverStates() { logInfo "Clearing all driver states..." state.clear() logInfo "All states have been cleared." } void clearAllAttributes() { logInfo "Clearing all attributes..." device.properties.supportedAttributes.each { device.deleteCurrentState("$it") } logInfo "All attributes have been cleared." } void clearAllSchedules() { logInfo "Clearing all scheduled jobs (including orphaned schedules)..." unschedule() logInfo "All scheduled jobs have been successfully cleared." } def scheduledPoll() { logDebug "Scheduled background poll sequence initiated." pollOWM("schedule") } def refresh() { logDebug "Refresh triggered via schedule or button press." if (!apiKey) { logWarn "Execution halted: API Key entry is missing!" return } pollOWM("refresh") } void scheduledTextValue(List dataList) { logDebug "Unpacking scheduled calcTextValue arguments safely..." if (dataList && dataList.size() >= 9) { Map currentData = dataList[5] instanceof Map ? dataList[5] : [:] Map todayData = dataList[6] instanceof Map ? dataList[6] : [:] Map tomData = dataList[7] instanceof Map ? dataList[7] : [:] Map tdaData = dataList[8] instanceof Map ? dataList[8] : [:] calcTextValue( dataList[0] != null ? dataList[0].toBigDecimal() : null, dataList[1] != null ? dataList[1].toBigDecimal() : null, dataList[2] != null ? dataList[2].toBigDecimal() : null, dataList[3] != null ? dataList[3].toBigDecimal() : null, dataList[4] != null ? dataList[4].toBigDecimal() : null, currentData, todayData, tomData, tdaData ) calcMoonPhaseValue(todayData, tomData, tdaData) calcMoonPhaseSvgImage(todayData, tomData, tdaData) generateTiles(currentData, todayData, tomData, tdaData) } else { logWarn "Scheduled calcTextValue skipped: Argument list was incomplete." } } def pollOWM(String type = "manual") { switch(type) { case "refresh": logInfo "pollOWM run on manual Refresh"; break case "schedule": logInfo "polling OpenWeatherMaps API on schedule"; break case "manual": default: logInfo "PollOWM run manually"; break } logDebug "pollOWM triggered. Evaluating location coordinates..." calcLonLatCityState() if(state.usedLatitude == null || state.usedLongitude == null) { logWarn "pollOWM aborted: Valid coordinates are missing (Lat: ${state.usedLatitude}, Lon: ${state.usedLongitude})" return } BigDecimal currentSunAlt = calcSunPosition() calcMoonPosition() calcBetwixtState(currentSunAlt) calcIsDayState(currentSunAlt) state.iconBasePath = calcIconBasePath(settings.altIconLoc) state.moonPhaseImagePath = calcMoonPhaseImagePath(settings.altMoonPhaseImagePath) state.windDirectionImagePath = calcWinDirImagePath(settings.altWindDirectionImageLoc) pollOWMAPI() } private void updateDynamicSchedules(long sunriseEpoch, long sunsetEpoch) { unschedule("scheduledPoll") if (dayInterval == "manual" && nightInterval == "manual") { logInfo "Both daytime and nighttime polling are set to MANUAL. Dynamic scheduling skipped." return } long nowTime = now() / 1000L boolean isDay = (nowTime >= sunriseEpoch && nowTime < sunsetEpoch) String currentInterval = isDay ? dayInterval : nightInterval int delaySeconds = 0 if (currentInterval == "manual") { if (isDay) { delaySeconds = (int)(sunsetEpoch - nowTime) + 300 logDebug "Daytime polling is MANUAL. Scheduling next poll at sunset (+5 mins) in ${delaySeconds} seconds." } else { long nextSunrise = (nowTime >= sunriseEpoch) ? (sunriseEpoch + 86400) : sunriseEpoch delaySeconds = (int)(nextSunrise - nowTime) + 300 logDebug "Nighttime polling is MANUAL. Scheduling next poll at sunrise (+5 mins) in ${delaySeconds} seconds." } } else { int intervalMinutes = (currentInterval && currentInterval.isInteger()) ? currentInterval.toInteger() : 30 delaySeconds = intervalMinutes * 60 // Short-circuit timer if Daybreak or Sunset boundary happens BEFORE the standard interval if (isDay) { long secondsToSunset = (sunsetEpoch - nowTime) + 300 if (secondsToSunset > 0 && secondsToSunset < delaySeconds) { delaySeconds = (int)secondsToSunset logDebug "Night transition occurs before next interval. Scheduling poll 5 minutes after sunset in ${delaySeconds} seconds." } } else { long nextSunrise = (nowTime >= sunriseEpoch) ? (sunriseEpoch + 86400) : sunriseEpoch long secondsToSunrise = (nextSunrise - nowTime) + 300 if (secondsToSunrise > 0 && secondsToSunrise < delaySeconds) { delaySeconds = (int)secondsToSunrise logDebug "Daybreak transition occurs before next interval. Scheduling poll 5 minutes after sunrise in ${delaySeconds} seconds." } } } if (delaySeconds <= 0) delaySeconds = 1800 runIn(delaySeconds, "scheduledPoll", [overwrite: true]) } void generateTiles(Map currentData = [:], Map todayData = [:], Map tomData = [:], Map tdaData = [:]) { String tUnit = settings.temperatureUnit ?: "°F" String wUnit = settings.windSpeedUnit ?: "mph" String cityName = state.usedCity ?: "Local Area" TimeZone tz = location?.timeZone ?: TimeZone.getDefault() def appendTileDebug = { String bodyHtml -> if (settings.debugTileEnable != true) return bodyHtml + "" int actualContentLen = bodyHtml.length() + 6 String footerStart = "
Len:" return bodyHtml + footerStart + "${actualContentLen}
" } // SECTION 1: currentTile def temp, cond, hi, lo, hum, wind, currentIconUrl, feelsLike if (currentData && todayData) { temp = currentData.temp != null ? convertKelvin(currentData.temp) : "--" feelsLike = currentData.feels_like != null ? convertKelvin(currentData.feels_like) : "--" cond = (currentData.weather && currentData.weather[0]) ? currentData.weather[0].description : "--" hi = todayData.temp?.max != null ? convertKelvin(todayData.temp.max) : "--" lo = todayData.temp?.min != null ? convertKelvin(todayData.temp.min) : "--" hum = currentData.humidity != null ? convertHumidity(currentData.humidity) : "--" wind = currentData.wind_speed != null ? convertWindSpeed(currentData.wind_speed) : "--" } else { temp = device.currentValue("currentTemperature") ?: "--" feelsLike = device.currentValue("currentFeelsLike") ?: "--" cond = device.currentValue("currentConditionTypeDesc") ?: "--" hi = device.currentValue("todayTempMax") ?: "--" lo = device.currentValue("todayTempMin") ?: "--" hum = device.currentValue("currentHumidity") ?: "--" wind = device.currentValue("currentWindSpeed") ?: "--" } currentIconUrl = device.currentValue("currentConditionAltImage") if (!currentIconUrl && currentData && currentData.weather && currentData.weather[0]) { Integer code = currentData.weather[0].id != null ? currentData.weather[0].id.toInteger() : 0 String omiIcon = currentData.weather[0].icon ?: "01d" String basePath = state.iconBasePath ?: calcIconBasePath(settings.altIconLoc) if (settings.altIconsEnable == true) { Map condDetails = lookupConditionDetails(code) BigDecimal currentSunAlt = state.currentSunAltitude != null ? state.currentSunAltitude.toBigDecimal() : (device.currentValue("currentSunAltitude")?.toBigDecimal() ?: 0.0) boolean isDay = (currentSunAlt >= -0.833) String iconFilename = isDay ? condDetails.altDay : condDetails.altNight currentIconUrl = "${basePath}${iconFilename}" } else { currentIconUrl = "${basePath}${omiIcon}.png" } } else if (!currentIconUrl) { String imgName = device.currentValue("currentConditionImageName")?.toString() ?: "01d.png" String basePath = state.iconBasePath ?: calcIconBasePath(settings.altIconLoc) currentIconUrl = "${basePath}${imgName}" } String windIconDisplay = "💨" if (settings.altIconsEnable == true) { String windIconUrl = device.currentValue("currentWindDirImageUrl") ?: "" if (windIconUrl != "") { windIconDisplay = "" } } String sT = (temp instanceof BigDecimal) ? temp.setScale(0, java.math.RoundingMode.HALF_UP) : temp String sFL = (feelsLike instanceof BigDecimal) ? feelsLike.setScale(0, java.math.RoundingMode.HALF_UP) : feelsLike String sH = (hi instanceof BigDecimal) ? hi.setScale(0, java.math.RoundingMode.HALF_UP) : hi String sL = (lo instanceof BigDecimal) ? lo.setScale(0, java.math.RoundingMode.HALF_UP) : lo String sW = (wind instanceof BigDecimal) ? wind.setScale(0, java.math.RoundingMode.HALF_UP) : wind String currentBodyHtml = "
" + "
Currently in ${cityName}
" + "
${sT}${tUnit}
" + "
(feels like ${sFL}${tUnit})
" + "
${cond}
" + "
High: ${sH}° Low: ${sL}°
" + "
💦${hum}%    ${windIconDisplay}${sW} ${wUnit}
" sendIfChanged(name: "currentTile", value: appendTileDebug(currentBodyHtml)) // SECTION 2: 3DayForecastTile String day1Name = (tomData && tomData.dt) ? new Date(tomData.dt * 1000L).format("EEE", tz) : "Tom" String day2Name = (tdaData && tdaData.dt) ? new Date(tdaData.dt * 1000L).format("EEE", tz) : "TDA" String pUnit = (settings.precipUnit == "none" || settings.precipUnit == null) ? "" : settings.precipUnit String basePath = state.iconBasePath ?: calcIconBasePath(settings.altIconLoc) String cIcon = device.currentValue("currentConditionAltImage") ?: "" if (!cIcon && currentData && currentData.weather && currentData.weather[0]) { Integer code = currentData.weather[0].id != null ? currentData.weather[0].id.toInteger() : 0 String omiIcon = currentData.weather[0].icon ?: "01d" if (settings.altIconsEnable == true) { Map condDetails = lookupConditionDetails(code) BigDecimal currentSunAlt = state.currentSunAltitude != null ? state.currentSunAltitude.toBigDecimal() : (device.currentValue("currentSunAltitude")?.toBigDecimal() ?: 0.0) boolean isDay = (currentSunAlt >= -0.833) String iconFilename = isDay ? condDetails.altDay : condDetails.altNight cIcon = "${basePath}${iconFilename}" } else { cIcon = "${basePath}${omiIcon}.png" } } def extractPrecipNum = { Map src, String key -> if (!src || src[key] == null) return 0.0 if (src[key] instanceof Map) return src[key]?.getAt("1h")?.toDouble() ?: 0.0 if (src[key].toString().isNumber()) return src[key].toDouble() return 0.0 } def getFcstData = { prefix, Map sourceMap -> String iconUrl = device.currentValue("${prefix}ConditionAltImage") ?: "" if (!iconUrl && sourceMap && sourceMap.weather && sourceMap.weather[0]) { Integer code = sourceMap.weather[0].id != null ? sourceMap.weather[0].id.toInteger() : 0 String omiIcon = sourceMap.weather[0].icon ?: "01d" if (settings.altIconsEnable == true) { Map condDetails = lookupConditionDetails(code) BigDecimal currentSunAlt = state.currentSunAltitude != null ? state.currentSunAltitude.toBigDecimal() : (device.currentValue("currentSunAltitude")?.toBigDecimal() ?: 0.0) boolean isDay = (prefix == "today") ? (currentSunAlt >= -0.833) : true String iconFilename = isDay ? condDetails.altDay : condDetails.altNight iconUrl = "${basePath}${iconFilename}" } else { iconUrl = "${basePath}${omiIcon}.png" } } String desc = sourceMap.weather ? sourceMap.weather[0].description : (device.currentValue("${prefix}ConditionTypeDesc") ?: "--") if (desc && desc != "--") { desc = desc.replace(" ", "
") } def fHi = sourceMap.temp?.max != null ? convertKelvin(sourceMap.temp.max) : (device.currentValue("${prefix}TempMax") ?: "--") def fLo = sourceMap.temp?.min != null ? convertKelvin(sourceMap.temp.min) : (device.currentValue("${prefix}TempMin") ?: "--") def pop = sourceMap.pop != null ? sourceMap.pop : (device.currentValue("${prefix}POP") ?: 0) def rain = extractPrecipNum(sourceMap, "rain") def snow = extractPrecipNum(sourceMap, "snow") def precip = (rain > 0) ? convertPrecip(rain) : convertPrecip(snow) String sHi = (fHi instanceof BigDecimal) ? fHi.setScale(0, java.math.RoundingMode.HALF_UP) : fHi String sLo = (fLo instanceof BigDecimal) ? fLo.setScale(0, java.math.RoundingMode.HALF_UP) : fLo int popPct = (pop.toBigDecimal() * 100).intValue() String precipStr = (precip != null && precip.toBigDecimal() > 0) ? "${precip}${pUnit}" : "" return [icon: iconUrl, desc: desc, hi: sHi, lo: sLo, pop: popPct, precip: precipStr] } def d0 = getFcstData("today", todayData) def d1 = getFcstData("tom", tomData) def d2 = getFcstData("tda", tdaData) String forecastBodyHtml = "" + "" + "" + "" + "" + "" + "" + "" + "
${cityName}
NowToday${day1Name}${day2Name}
Currently
${sT}°
${d0.desc}${d1.desc}${d2.desc}
Hi/Lo${d0.hi}°/${d0.lo}°${d1.hi}°/${d1.lo}°${d2.hi}°/${d2.lo}°
Chance of Rain💧${d0.pop}%${d0.precip ? ' ' + d0.precip : ''}💧${d1.pop}%${d1.precip ? ' ' + d1.precip : ''}💧${d2.pop}%${d2.precip ? ' ' + d2.precip : ''}
" sendIfChanged(name: "3DayForecastTile", value: appendTileDebug(forecastBodyHtml)) // SECTION 3: currentAlertTile String alertActive = device.currentValue("currentAlert") ?: "No active alerts" String alertSender = device.currentValue("currentAlertSender") ?: "N/A" String alertDescFull = device.currentValue("currentAlertDescFull") ?: "No active alerts" String lastPollTime = new Date().format("HH:mm", tz) String containerStart = "
" String alertBodyHtml = "" if (alertActive != "No active alerts" && alertActive != "N/A" && alertActive != "") { String headerHtml = "
⚠️ ${alertActive}
" String footerHtml = "
👤 ${alertSender.take(12)}⏰ ${lastPollTime}
" int debugBadgeLen = (settings.debugTileEnable == true) ? 143 : 0 int overheadLen = containerStart.length() + headerHtml.length() + footerHtml.length() + debugBadgeLen int maxDescLen = 1023 - overheadLen String truncatedDesc = alertDescFull if (maxDescLen > 3 && alertDescFull.length() > maxDescLen) { truncatedDesc = alertDescFull.take(maxDescLen - 3) + "..." } else if (maxDescLen <= 3) { truncatedDesc = alertDescFull.take(Math.max(0, maxDescLen)) } alertBodyHtml = "${containerStart}${headerHtml}${truncatedDesc}${footerHtml}" } else { alertBodyHtml = "${containerStart}
✅ ${cityName}
No Alerts
No active weather alerts from OpenWeatherMap or their sources
Updated ${lastPollTime}
" } sendIfChanged(name: "currentAlertTile", value: appendTileDebug(alertBodyHtml)) // SECTION 4: currentMoonPhaseTile Map moonVals = calcMoonPhaseValue(todayData, tomData, tdaData) String liveSvg = calcMoonPhaseSvgImage(todayData, tomData, tdaData) String moonPhaseText = moonVals.text ?: (device.currentValue("todayMoonPhaseText") ?: "Unknown") String moonSvg = liveSvg ?: (device.currentValue("todayMoonPhaseSvgImage") ?: "") String moonPngUrl = moonVals.png ?: (device.currentValue("todayMoonPhasePngImageUrl") ?: "") def moonAltText = device.currentValue("currentMoonAltitudeText") ?: "--°" def moonAzText = device.currentValue("currentMoonAzimuthText") ?: "--°" String graphicDisplay = "" if (settings.displayTileMoonPhaseSVGEnable == true && moonSvg != "") { graphicDisplay = moonSvg } else if (moonPngUrl != "") { graphicDisplay = "" } else { String moonEmoji = moonVals.emoji ?: (device.currentValue("todayMoonPhaseEmojiIcon") ?: "🌑") graphicDisplay = "
${moonEmoji}
" } String moonBodyHtml = "
" + graphicDisplay + "
" + "
${moonPhaseText}
" + "
Alt: ${moonAltText}  |  Az: ${moonAzText}
" + "
" + "
" sendIfChanged(name: "currentMoonPhaseTile", value: appendTileDebug(moonBodyHtml)) } private void pollOWMAPI() { logDebug "Building OpenWeatherMap API HTTP Request..." def lat = state.usedLatitude def lon = state.usedLongitude def version = settings.apiSelection ?: "3.0" if (!apiKey) { logError "API request aborted: Missing API Key." return } String apiUrl = "" switch(version) { case "2.5": apiUrl = "https://api.openweathermap.org/data/2.5/onecall?lat=${lat}&lon=${lon}&exclude=minutely,hourly&appid=${apiKey}" break case "3.0": case "4.0": apiUrl = "https://api.openweathermap.org/data/3.0/onecall?lat=${lat}&lon=${lon}&exclude=minutely,hourly&appid=${apiKey}" break default: logError "Unknown API Version Selection: ${version}" return } def params = [uri: apiUrl, contentType: "application/json", timeout: 15] if (!settings.aPIKeyExposedEnable) { logDebug "Polling OpenWeatherMap via URL: ${apiUrl.replaceAll(/appid=[^&]+/, 'appid=***')}" } else { logDebug "Polling OpenWeatherMap via URL: ${apiUrl}" } try { httpGet(params) { response -> if (response.status == 200 && response.data) { state.lastResponseCode = response.status.toString() long currentUnixEpochSeconds = now() / 1000L state.lastUpdatedDateTime = currentUnixEpochSeconds String chosenFormat = settings.DateTimeForm ?: "1" if (chosenFormat == "10") { state.lastUpdatedDateTimeText = "${currentUnixEpochSeconds}" } else { Map formattedMap = convertDateTimeFormat(currentUnixEpochSeconds, chosenFormat) state.lastUpdatedDateTimeText = formattedMap.dateTime } parseOWMData(response.data) } else { logError "OWM API call failed with status code: ${response.status}" state.lastResponseCode = response.status.toString() } } } catch (Exception e) { logError "OWM API request failed: ${e.message}" if (e.hasProperty("statusCode")) { state.lastResponseCode = "${e.statusCode}" } } } private void parseOWMData(Map json) { if (!json) { logWarn "parseOWMData received an empty payload map." return } logDebug "Parsing newly received OpenWeatherMap response data structure..." String calculatedCityAttr = state.usedCity ?: "Local Area" String iconBasePath = state.iconBasePath ?: "https://tinyurl.com/icnqz/" if (json.lat != null) sendIfChanged(name: "apiLatitude", value: json.lat) if (json.lon != null) sendIfChanged(name: "apiLongitude", value: json.lon) if (json.timezone != null) sendIfChanged(name: "apiTimezone", value: json.timezone) if (json.timezone_offset != null) sendIfChanged(name: "apiTimezoneOffset", value: json.timezone_offset) long liveSunrise = 0 long liveSunset = 0 def currentData = json.current ?: [:] if (currentData) { logTrace "Current weather data payload extracted successfully." if (currentData.sunrise) { liveSunrise = currentData.sunrise.toLong() state.todaySunriseEpoch = liveSunrise } if (currentData.sunset) { liveSunset = currentData.sunset.toLong() state.todaySunsetEpoch = liveSunset } // Current weather uses '1h' map key def cRain = (currentData.rain instanceof Map) ? (currentData.rain?.getAt("1h") ?: 0.0) : (currentData.rain ?: 0.0) def cSnow = (currentData.snow instanceof Map) ? (currentData.snow?.getAt("1h") ?: 0.0) : (currentData.snow ?: 0.0) currentData["calculatedRain"] = cRain currentData["calculatedSnow"] = cSnow } BigDecimal currentSunAlt = calcSunPosition() calcMoonPosition() calcBetwixtState(currentSunAlt, liveSunrise, liveSunset) calcIsDayState(currentSunAlt) calcCurrentTwilight() def dailyList = json.daily ?: [] def data0 = dailyList.size() > 0 ? dailyList[0] : [:] def data1 = dailyList.size() > 1 ? dailyList[1] : [:] def data2 = dailyList.size() > 2 ? dailyList[2] : [:] if (data0 && data0.dt != null) sendIfChanged(name: "todayDate", value: data0.dt) if (data1 && data1.dt != null) sendIfChanged(name: "tomDate", value: data1.dt) if (data2 && data2.dt != null) sendIfChanged(name: "tdaDate", value: data2.dt) calcAlertsState(json, calculatedCityAttr, iconBasePath) sendOWMData(currentData, data0, data1, data2) if (liveSunrise > 0 && liveSunset > 0) { updateDynamicSchedules(liveSunrise, liveSunset) } calcMoonPhaseValue(data0, data1, data2) calcMoonPhaseSvgImage(data0, data1, data2) if (state.isInitializing == true) { state.isInitializing = false logDebug "Driver is initializing. Scheduling secondary out-of-band text refresh to prevent DB race conditions." def liveClouds = currentData?.clouds != null ? currentData.clouds : null BigDecimal freshLux = calcCurrentIlluminance(currentSunAlt, liveClouds) BigDecimal freshTemp = currentData?.temp != null ? convertKelvin(currentData.temp) : null BigDecimal freshPress = currentData?.pressure != null ? convertPressure(currentData.pressure) : null BigDecimal freshWind = currentData?.wind_speed != null ? convertWindSpeed(currentData.wind_speed) : null BigDecimal freshHum = currentData?.humidity != null ? convertHumidity(currentData.humidity) : null runIn(2, "scheduledTextValue", [data: [freshLux, freshTemp, freshPress, freshWind, freshHum, currentData, data0, data1, data2, json]]) } else { generateTiles(currentData, data0, data1, data2) } } private void sendOWMData(Map current, Map today, Map tom, Map tda) { logDebug "sendOWMData initiated. Dispatching events to device attributes..." logTrace "sendOWMData Current section starting" // 1. CURRENT DATA DISPATCHES if (current) { def rawRain = current.calculatedRain ?: 0.00 def rawSnow = current.calculatedSnow ?: 0.00 def rainVal = convertPrecip(rawRain) def snowVal = convertPrecip(rawSnow) String preUnit = (settings.precipUnit == "none" || settings.precipUnit == null) ? "" : "${settings.precipUnit}" String spaceStr = (preUnit == '"') ? "" : " " sendIfChanged(name: "currentRainfall", value: rainVal) sendIfChanged(name: "currentSnowfall", value: snowVal) sendIfChanged(name: "currentRainfallText", value: "${rainVal}${spaceStr}${preUnit}") sendIfChanged(name: "currentSnowfallText", value: "${snowVal}${spaceStr}${preUnit}") if (current.sunrise != null) sendIfChanged(name: "currentSunriseTime", value: current.sunrise) if (current.sunset != null) sendIfChanged(name: "currentSunsetTime", value: current.sunset) if (current.temp != null) { BigDecimal calcTemp = convertKelvin(current.temp) sendIfChanged(name: "currentTemperature", value: calcTemp) sendIfChanged(name: "temperature", value: calcTemp) } if (current.feels_like != null) sendIfChanged(name: "currentFeelsLike", value: convertKelvin(current.feels_like)) if (current.dew_point != null) sendIfChanged(name: "currentDewPoint", value: convertKelvin(current.dew_point)) if (current.humidity != null) { BigDecimal calcHumidity = convertHumidity(current.humidity) sendIfChanged(name: "currentHumidity", value: calcHumidity) sendIfChanged(name: "humidity", value: calcHumidity) } if (current.pressure != null) { BigDecimal calcPressure = convertPressure(current.pressure) sendIfChanged(name: "currentPressure", value: calcPressure) sendIfChanged(name: "pressure", value: calcPressure) } if (current.uvi != null) { sendIfChanged(name: "currentUVI", value: current.uvi) sendIfChanged(name: "ultravioletIndex", value: current.uvi) } if (current.visibility != null) { BigDecimal visDist = convertVisibilityDistance(current.visibility) sendIfChanged(name: "currentVisibility", value: visDist) sendIfChanged(name: "currentVisibilityText", value: "${visDist} ${visibilityUnit}") } if (current.clouds != null) sendIfChanged(name: "currentCloudPCT", value: current.clouds) if (current.wind_speed != null) sendIfChanged(name: "currentWindSpeed", value: convertWindSpeed(current.wind_speed)) if (current.wind_deg != null) sendIfChanged(name: "currentWindDeg", value: current.wind_deg) if (current.wind_gust != null) sendIfChanged(name: "currentWindGust", value: convertWindSpeed(current.wind_gust)) if (current.wind_deg != null) { Map wDir = convertWindDirectionState(current.wind_deg) sendIfChanged(name: "currentWindDirCardinal", value: wDir.cardinal) sendIfChanged(name: "currentWindDirFull", value: wDir.full) sendIfChanged(name: "currentWindDirImageUrl", value: wDir.iconUrl) sendIfChanged(name: "currentWindDirectionImage", value: wDir.icon) sendIfChanged(name: "currentWindDirectionEmojiIcon", value: wDir.emoji) } if (current.weather && current.weather[0]) { Integer code = current.weather[0].id != null ? current.weather[0].id.toInteger() : 0 String omiIcon = current.weather[0].icon ?: "01d" sendIfChanged(name: "currentConditionCode", value: code) sendIfChanged(name: "currentConditionType", value: current.weather[0].main) sendIfChanged(name: "currentConditionTypeDesc", value: current.weather[0].description) sendIfChanged(name: "currentConditionImageName", value: "${omiIcon}.png") Map condDetails = lookupConditionDetails(code) sendIfChanged(name: "currentConditionTypeAltDesc", value: condDetails.desc) String basePath = state.iconBasePath ?: calcIconBasePath(settings.altIconLoc) String finalIconUrl = "" if (settings.altIconsEnable == true) { BigDecimal currentSunAlt = state.currentSunAltitude != null ? state.currentSunAltitude.toBigDecimal() : (device.currentValue("currentSunAltitude")?.toBigDecimal() ?: 0.0) boolean isDay = (currentSunAlt >= -0.833) String iconFilename = isDay ? condDetails.altDay : condDetails.altNight finalIconUrl = "${basePath}${iconFilename}" logDebug "Alternate Icons Enabled. Day: ${isDay} | File: ${iconFilename} | URL: ${finalIconUrl}" } else { finalIconUrl = "${basePath}${omiIcon}.png" logDebug "Standard OpenWeatherMap Icon Used | URL: ${finalIconUrl}" } sendIfChanged(name: "currentConditionAltImage", value: finalIconUrl) } logTrace "sendOWMData Current section ended" } // 2. FORECAST DISPATCHES def forecastDays = [ [prefix: "today", map: today], [prefix: "tom", map: tom], [prefix: "tda", map: tda] ] forecastDays.each { day -> String prefix = day.prefix Map data = day.map if (data) { logTrace "sendOWMData ${prefix} section started" if (data.sunrise != null) sendIfChanged(name: "${prefix}SunriseTime", value: data.sunrise) if (data.sunset != null) sendIfChanged(name: "${prefix}SunsetTime", value: data.sunset) if (data.pop != null) sendIfChanged(name: "${prefix}POP", value: data.pop) if (data.summary != null) sendIfChanged(name: "${prefix}Summary", value: data.summary) if (data.moonrise != null) sendIfChanged(name: "${prefix}MoonriseTime", value: data.moonrise) if (data.moonset != null) sendIfChanged(name: "${prefix}MoonsetTime", value: data.moonset) if (data.moon_phase != null) sendIfChanged(name: "${prefix}MoonPhase", value: data.moon_phase) // Daily forecast rain/snow are direct doubles, not maps def fRain = (data.rain instanceof Number) ? data.rain : 0.0 def fSnow = (data.snow instanceof Number) ? data.snow : 0.0 sendIfChanged(name: "${prefix}Rain", value: convertPrecip(fRain)) sendIfChanged(name: "${prefix}Snow", value: convertPrecip(fSnow)) if (data.weather && data.weather[0]) { Integer code = data.weather[0].id != null ? data.weather[0].id.toInteger() : 0 String omiIcon = data.weather[0].icon ?: "01d" sendIfChanged(name: "${prefix}ConditionCode", value: code) sendIfChanged(name: "${prefix}ConditionType", value: data.weather[0].main) sendIfChanged(name: "${prefix}ConditionTypeDesc", value: data.weather[0].description) sendIfChanged(name: "${prefix}ConditionImageName", value: "${omiIcon}.png") Map condDetails = lookupConditionDetails(code) sendIfChanged(name: "${prefix}ConditionTypeAltDesc", value: condDetails.desc) String basePath = state.iconBasePath ?: calcIconBasePath(settings.altIconLoc) String finalIconUrl = "" if (settings.altIconsEnable == true) { finalIconUrl = "${basePath}${condDetails.altDay}" } else { finalIconUrl = "${basePath}${omiIcon}.png" } sendIfChanged(name: "${prefix}ConditionAltImage", value: finalIconUrl) } if (data.temp) { ["min", "max", "day", "night", "eve", "morn"].each { tKey -> if (data.temp[tKey] != null) { sendIfChanged(name: "${prefix}Temp${tKey.capitalize()}", value: convertKelvin(data.temp[tKey])) } } } if (data.feels_like) { ["day", "night", "eve", "morn"].each { fKey -> if (data.feels_like[fKey] != null) { sendIfChanged(name: "${prefix}FeelsLike${fKey.capitalize()}", value: convertKelvin(data.feels_like[fKey])) } } } if (data.pressure != null) sendIfChanged(name: "${prefix}Pressure", value: convertPressure(data.pressure)) if (data.humidity != null) sendIfChanged(name: "${prefix}Humidity", value: convertHumidity(data.humidity)) if (data.dew_point != null) sendIfChanged(name: "${prefix}DewPoint", value: convertKelvin(data.dew_point)) if (data.uvi != null) sendIfChanged(name: "${prefix}UVI", value: data.uvi) if (data.clouds != null) sendIfChanged(name: "${prefix}CloudPCT", value: data.clouds) if (data.wind_gust != null) sendIfChanged(name: "${prefix}WindGust", value: convertWindSpeed(data.wind_gust)) if (data.wind_deg != null) sendIfChanged(name: "${prefix}WindDeg", value: data.wind_deg) if (data.wind_speed != null) sendIfChanged(name: "${prefix}WindSpeed", value: convertWindSpeed(data.wind_speed)) if (data.wind_deg != null) { Map wDir = convertWindDirectionState(data.wind_deg) sendIfChanged(name: "${prefix}WindDirCardinal", value: wDir.cardinal) sendIfChanged(name: "${prefix}WindDirFull", value: wDir.full) sendIfChanged(name: "${prefix}WindDirImageUrl", value: wDir.iconUrl) sendIfChanged(name: "${prefix}WindDirectionImage", value: wDir.icon) sendIfChanged(name: "${prefix}WindDirectionEmojiIcon", value: wDir.emoji) } logTrace "sendOWMData ${prefix} section ended" } } // 3. ILLUMINANCE & OUT-OF-BAND MATH EXECUTION BigDecimal currentSunAlt = state.currentSunAltitude != null ? state.currentSunAltitude.toBigDecimal() : (device.currentValue("currentSunAltitude")?.toBigDecimal() ?: 0.0) def liveClouds = current?.clouds != null ? current.clouds : null BigDecimal freshLux = calcCurrentIlluminance(currentSunAlt, liveClouds) BigDecimal freshTemp = current?.temp != null ? convertKelvin(current.temp) : null BigDecimal freshPress = current?.pressure != null ? convertPressure(current.pressure) : null BigDecimal freshWind = current?.wind_speed != null ? convertWindSpeed(current.wind_speed) : null BigDecimal freshHum = current?.humidity != null ? convertHumidity(current.humidity) : null calcTextValue(freshLux, freshTemp, freshPress, freshWind, freshHum, current, today, tom, tda) calcMoonPhaseValue(today, tom, tda) calcMoonPhaseSvgImage(today, tom, tda) logDebug "sendOWMData event parsing complete." } // -----------------CONVERTERS private Map convertDateTimeFormat(def epochSeconds, String formatOption) { if (epochSeconds == null) { logDebug "convertDateTimeFormat received a null epoch timestamp value." return [dateTime: "--", date: "--", time: "--"] } long msecs = epochSeconds.toLong() * 1000L Date dateObject = new Date(msecs) TimeZone tz = location?.timeZone ?: TimeZone.getDefault() if (device.currentValue("apiTimezone") != null) { tz = TimeZone.getTimeZone(device.currentValue("apiTimezone").toString()) } String DTFormat = "" String dateFormat = "" String timeFormat = "" switch(formatOption?.toString()) { case '1': DTFormat = 'M/d/yyyy h:mm a'; dateFormat = 'M/d/yyyy'; timeFormat = 'h:mm a'; break case '2': DTFormat = 'M/d/yyyy HH:mm'; dateFormat = 'M/d/yyyy'; timeFormat = 'HH:mm'; break case '3': DTFormat = 'MM/dd/yyyy h:mm a'; dateFormat = 'MM/dd/yyyy'; timeFormat = 'h:mm a'; break case '4': DTFormat = 'MM/dd/yyyy HH:mm'; dateFormat = 'MM/dd/yyyy'; timeFormat = 'HH:mm'; break case '5': DTFormat = 'd/M/yyyy h:mm a'; dateFormat = 'd/M/yyyy'; timeFormat = 'h:mm a'; break case '6': DTFormat = 'd/M/yyyy HH:mm'; dateFormat = 'd/M/yyyy'; timeFormat = 'HH:mm'; break case '7': DTFormat = 'dd/MM/yyyy h:mm a'; dateFormat = 'dd/MM/yyyy'; timeFormat = 'h:mm a'; break case '8': DTFormat = 'dd/MM/yyyy HH:mm'; dateFormat = 'dd/MM/yyyy'; timeFormat = 'HH:mm'; break case '9': DTFormat = 'yyyy/MM/dd HH:mm'; dateFormat = 'yyyy/MM/dd'; timeFormat = 'HH:mm'; break default: DTFormat = 'M/d/yyyy h:mm a'; dateFormat = 'M/d/yyyy'; timeFormat = 'h:mm a'; break } try { return [ dateTime: dateObject.format(DTFormat, tz), date: dateObject.format(dateFormat, tz), time: dateObject.format(timeFormat, tz) ] } catch (Exception e) { logError "Exception occurred during convertDateTimeFormat conversion execution: ${e.message}" return [dateTime: "--", date: "--", time: "--"] } } private BigDecimal convertVisibilityDistance(def rawVisibility) { BigDecimal meters = rawVisibility?.toBigDecimal() if (meters == null) return 0.0 BigDecimal converted = meters String targetUnit = settings.visibilityUnit ?: "miles" int precision = 1 switch (targetUnit) { case "km": converted = meters / 1000.0 precision = 1 break case "miles": converted = meters / 1609.344 precision = 2 break case "ft": converted = meters * 3.28084 precision = 0 break default: precision = 0 break } return converted.setScale(precision, java.math.RoundingMode.HALF_UP) } private BigDecimal convertIlluminance(BigDecimal rawLux) { if (rawLux == null) return 0.0 String targetUnit = settings.illuminanceUnit ?: "lx" BigDecimal convertedValue = rawLux int precision = 0 switch (targetUnit) { case "fc": convertedValue = rawLux * 0.092903 precision = 1 break case "ph": convertedValue = rawLux * 0.0001 precision = 4 break case "lx": case "none": default: convertedValue = rawLux precision = 0 break } BigDecimal finalValue = convertedValue.setScale(precision, java.math.RoundingMode.HALF_UP) return finalValue < 0 ? 0.0 : finalValue } private BigDecimal convertHumidity(BigDecimal rawHumidity) { if (rawHumidity == null || rawHumidity == "") { int precision = settings.precisionHumid != null ? settings.precisionHumid.toInteger() : 0 return new BigDecimal("0.0").setScale(precision, java.math.RoundingMode.HALF_UP) } String hUnit = settings.humidityUnit ?: "%" Double calculatedValue = rawHumidity.toDouble() switch (hUnit) { case "%": case "%RH": break case "g/m³": def tempVal = device.currentValue("currentTemperature") if (tempVal != null && tempVal.toString().isNumber()) { Double tempC = tempVal.toDouble() String tUnit = settings.temperatureUnit ?: "°F" if (tUnit == "°F") { tempC = (tempC - 32.0) * 5.0 / 9.0 } else if (tUnit == "K") { tempC = tempC - 273.15 } Double es = 6.112 * Math.exp((17.67 * tempC) / (tempC + 243.5)) Double e = (calculatedValue / 100.0) * es calculatedValue = (e * 216.7) / (tempC + 273.15) } break case "g/kg³": def tempVal = device.currentValue("currentTemperature") def pressVal = device.currentValue("currentPressure") if (tempVal != null) { Double tempC = tempVal.toDouble() String tUnit = settings.temperatureUnit ?: "°F" if (tUnit == "°F") { tempC = (tempC - 32.0) * 5.0 / 9.0 } else if (tUnit == "K") { tempC = tempC - 273.15 } Double es = 6.112 * Math.exp((17.67 * tempC) / (tempC + 243.5)) Double e = (calculatedValue / 100.0) * es Double pressureHpa = (pressVal != null) ? pressVal.toDouble() : 1013.25 String pUnit = settings.pressureUnit ?: "inHg" if (pUnit == "inHg") { pressureHpa = pressureHpa * 33.8639 } else if (pUnit == "kPa") { pressureHpa = pressureHpa * 10.0 } else if (pUnit == "mmHg") { pressureHpa = pressureHpa * 1.33322 } calculatedValue = 621.99 * e / (pressureHpa - e) } break case "none": break default: break } int precision = (settings.precisionHumid ?: "0").toInteger() return BigDecimal.valueOf(calculatedValue).setScale(precision, java.math.RoundingMode.HALF_UP) } private BigDecimal convertKelvin(def kelvinVal) { BigDecimal K = kelvinVal?.toBigDecimal() if (K == null) return 0.0 BigDecimal converted = K String targetUnit = settings.temperatureUnit ?: "°F" switch (targetUnit) { case "°F": converted = (K - 273.15) * 1.8 + 32 break case "°C": converted = K - 273.15 break case "K": case "none": default: break } int precision = (settings.precisionTemp ?: "2").toInteger() return converted.setScale(precision, java.math.RoundingMode.HALF_UP) } private BigDecimal convertPrecip(precipVal) { int precision = (settings.precisionPrecip != null && settings.precisionPrecip != "") ? settings.precisionPrecip.toInteger() : 2 if (precipVal == null || precipVal == "" || precipVal.toString() == "0") { return BigDecimal.ZERO.setScale(precision, java.math.RoundingMode.HALF_UP) } BigDecimal precip = (precipVal instanceof BigDecimal) ? precipVal : new BigDecimal(precipVal.toString()) String unit = settings.precipUnit ?: "inHr" if (unit in ["inHr", "in", "\""]) { precip = precip * 0.0393701 } return precip.setScale(precision, java.math.RoundingMode.HALF_UP) } private BigDecimal convertPressure(def hpaVal) { BigDecimal hpa = hpaVal?.toBigDecimal() if (hpa == null) return 0.0 BigDecimal converted = hpa String targetUnit = settings.pressureUnit ?: "inHg" switch (targetUnit) { case "inHg": converted = hpa * 0.029530 break case "mmHg": converted = hpa * 0.750062 break case "kPa": converted = hpa * 0.1 break case "hPa": case "mb": case "none": default: break } int precision = (settings.precisionPress ?: "2").toInteger() return converted.setScale(precision, java.math.RoundingMode.HALF_UP) } private BigDecimal convertWindSpeed(def msVal) { BigDecimal ms = msVal?.toBigDecimal() if (ms == null) return 0.0 BigDecimal converted = ms String targetUnit = settings.windSpeedUnit ?: "mph" switch (targetUnit) { case "mph": converted = ms * 2.23694 break case "kmh": converted = ms * 3.6 break case "kt": converted = ms * 1.94384 break case "ms": case "none": default: break } int precision = (settings.precisionWind ?: "2").toInteger() return converted.setScale(precision, java.math.RoundingMode.HALF_UP) } private Map convertWindDirectionState(degrees) { if (degrees == null) { logDebug "convertWindDirectionState received null value." return [cardinal: "Unknown", full: "Unknown", iconUrl: "", icon: "💨", emoji: "💨"] } double deg = 0.0 try { deg = degrees.toDouble() } catch (Exception e) { logError "Failed to parse wind degrees (${degrees}): ${e.message}" return [cardinal: "Unknown", full: "Unknown", iconUrl: "", icon: "💨", emoji: "💨"] } deg = (deg % 360 + 360) % 360 def cardinals = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] def fullWords = ["North", "North-Northeast", "Northeast", "East-Northeast", "East", "East-Southeast", "Southeast", "South-Southeast", "South", "South-Southwest", "Southwest", "West-Southwest", "West", "West-Northwest", "Northwest", "North-Northwest"] def directionEmojis = ["⬇️", "↙️", "↙️", "↙️", "⬅️", "↖️", "↖️", "↖️", "⬆️", "↗️", "↗️", "↗️", "➡️", "↘️", "↘️", "↘️"] int index = (int) Math.round(deg / 22.5) % 16 String token = cardinals[index] String word = fullWords[index] String chosenEmoji = directionEmojis[index] String finalIconUrl = "" String finalIconDisplay = chosenEmoji String basePath = state.windDirectionImagePath ?: (calcWinDirImagePath(settings.altWindDirectionImageLoc) ?: "") if (basePath != null && basePath.trim() != "") { String filename = "wind-${token.toLowerCase()}.png" finalIconUrl = "${basePath}${filename}" finalIconDisplay = "" } return [ cardinal: token, full: word, iconUrl: finalIconUrl, icon: finalIconDisplay, emoji: chosenEmoji ] } // ----------------------- CALCS private String calcIconBasePath(String altIconLoc) { String base = "" if (settings.altIconsEnable == true) { base = (altIconLoc && altIconLoc.trim() != "") ? altIconLoc.trim() : "https://tinyurl.com/icnqz/" } else { base = "https://openweathermap.org/img/wn/" } if (!base.endsWith("/")) base += "/" logDebug "Calculated Icon Base Path resolved to: ${base}" return base } private String calcMoonPhaseImagePath(String altMPLoc) { String base = altMPLoc ? altMPLoc.trim() : "https://raw.githubusercontent.com/thebearmay/hubitat/main/moonPhaseRes/" if (!base.endsWith("/")) base += "/" logDebug "Calculated Moon Phase Image base path resolved to: ${base}" return base } private String calcWinDirImagePath(String altWDLoc) { String base = (altWDLoc && altWDLoc.trim() != "") ? altWDLoc.trim() : "" if (base != "" && !base.endsWith("/")) { base += "/" } logDebug "Calculated Wind Direction Image base path resolved to: '${base}'" return base } Map calcMoonPhaseValue(Map todayData = [:], Map tomData = [:], Map tdaData = [:]) { logDebug "Calculating moon phase icons, text names, and emojis from API payload maps..." List emojis = ["🌑", "🌒", "🌓", "🌔", "🌕", "🌖", "🌗", "🌘"] def phases = [ [sourceMap: todayData, apiKey: "moon_phase", valAttr: "todayMoonPhase", pngAttr: "todayMoonPhasePngImageUrl", textAttr: "todayMoonPhaseText", emojiAttr: "todayMoonPhaseEmojiIcon"], [sourceMap: tomData, apiKey: "moon_phase", valAttr: "tomMoonPhase", pngAttr: "tomMoonPhasePngImageUrl", textAttr: "tomMoonPhaseText", emojiAttr: "tomMoonPhaseEmojiIcon"], [sourceMap: tdaData, apiKey: "moon_phase", valAttr: "tdaMoonPhase", pngAttr: "tdaMoonPhasePngImageUrl", textAttr: "tdaMoonPhaseText", emojiAttr: "tdaMoonPhaseEmojiIcon"] ] def todayVal = null boolean isPathOverridden = (settings.altMoonPhaseImagePath != null && settings.altMoonPhaseImagePath.trim() != "") Map resultMap = [:] phases.each { phase -> def rawVal = (phase.sourceMap && phase.sourceMap[phase.apiKey] != null) ? phase.sourceMap[phase.apiKey] : device.currentValue(phase.valAttr) if (rawVal != null && rawVal.toString().isNumber()) { double val = (rawVal.toDouble() % 1.0 + 1.0) % 1.0 if (phase.valAttr == "todayMoonPhase") { todayVal = val } int index = (int) Math.floor((val * 8) + 0.5) % 8 String filename = isPathOverridden ? "mp${index}.png" : "moon-phase-icon-${index}.png" String basePath = state.moonPhaseImagePath ?: "https://raw.githubusercontent.com/thebearmay/hubitat/main/moonPhaseRes/" String fullPath = "${basePath}${filename}" sendIfChanged(name: phase.pngAttr, value: fullPath) int emojiIndex = (int) Math.floor((val * 8) + 0.5) % 8 String chosenEmoji = emojis[emojiIndex] sendIfChanged(name: phase.emojiAttr, value: chosenEmoji) String phaseName = "Unknown" double tolerance = 0.02 if (val <= tolerance || val >= (1.0 - tolerance)) phaseName = "New Moon" else if (val > tolerance && val < (0.25 - tolerance)) phaseName = "Waxing Crescent" else if (val >= (0.25 - tolerance) && val <= (0.25 + tolerance)) phaseName = "First Quarter" else if (val > (0.25 + tolerance) && val < (0.5 - tolerance)) phaseName = "Waxing Gibbous" else if (val >= (0.5 - tolerance) && val <= (0.5 + tolerance)) phaseName = "Full Moon" else if (val > (0.5 + tolerance) && val < (0.75 - tolerance)) phaseName = "Waning Gibbous" else if (val >= (0.75 - tolerance) && val <= (0.75 + tolerance)) phaseName = "Last Quarter" else if (val > (0.75 + tolerance) && val < (1.0 - tolerance)) phaseName = "Waning Crescent" sendIfChanged(name: phase.textAttr, value: phaseName) if (phase.valAttr == "todayMoonPhase") { resultMap.text = phaseName resultMap.png = fullPath resultMap.emoji = chosenEmoji } } } return resultMap } String calcMoonPhaseSvgMask(double phase, String path) { if (phase <= 0.02 || phase >= 0.98) { return """""" } double rx1 = 127.0, rx2 = 127.0 int sf1 = 1, sf2 = 1 if (phase <= 0.25) { rx1 *= (1 - 4 * phase) } else if (phase <= 0.50) { rx1 *= (4 * phase - 1); sf1 = 0 } else if (phase <= 0.75) { rx2 *= (3 - 4 * phase); sf2 = 0 } else { rx2 *= (4 * phase - 3) } return """""" } String calcMoonPhaseSvgImage(Map todayData = [:], Map tomData = [:], Map tdaData = [:]) { logDebug "Evaluating moon phase calculations for SVG output variables..." String path = state.moonPhaseImagePath ?: "https://raw.githubusercontent.com/thebearmay/hubitat/main/moonPhaseRes/" String todaySvg = "" def targets = [ [sourceMap: todayData, apiKey: "moon_phase", valAttr: "todayMoonPhase", targetAttr: "todayMoonPhaseSvgImage"], [sourceMap: tomData, apiKey: "moon_phase", valAttr: "tomMoonPhase", targetAttr: "tomMoonPhaseSvgImage"], [sourceMap: tdaData, apiKey: "moon_phase", valAttr: "tdaMoonPhase", targetAttr: "tdaMoonPhaseSvgImage"] ] targets.each { entry -> def rawVal = (entry.sourceMap && entry.sourceMap[entry.apiKey] != null) ? entry.sourceMap[entry.apiKey] : device.currentValue(entry.valAttr) if (rawVal != null && rawVal.toString().isNumber()) { double val = (rawVal.toDouble() % 1.0 + 1.0) % 1.0 String svgContent = calcMoonPhaseSvgMask(val, path) sendIfChanged(name: entry.targetAttr, value: svgContent) if (entry.targetAttr == "todayMoonPhaseSvgImage") { todaySvg = svgContent } } } return todaySvg } private void calcAlertsState(Map json, String calculatedCityAttr, String iconBasePath) { def alerts = json?.alerts ?: [] String alertActive = "No active alerts" String currentAlertSender = "N/A" String currentAlertDesc = "No active alerts" TimeZone tz = location?.timeZone ?: TimeZone.getDefault() String lastPollTime = new Date().format("HH:mm", tz) String currentAlertDescFull = "No active alerts for ${calculatedCityAttr} at last poll as of ${lastPollTime}" if (alerts.size() > 0) { def a = alerts[0] alertActive = a.event ?: "Active Alert" currentAlertSender = a.sender_name ?: "Unknown" currentAlertDesc = a.description ? (a.description.length() > 100 ? a.description.take(100) + "..." : a.description) : "Alert details unavailable" currentAlertDescFull = (a.description ?: "N/A") + " as of ${lastPollTime}" } sendIfChanged(name: "currentAlert", value: alertActive) sendIfChanged(name: "currentAlertSender", value: currentAlertSender) sendIfChanged(name: "currentAlertDesc", value: currentAlertDesc) sendIfChanged(name: "currentAlertDescFull", value: currentAlertDescFull) } private BigDecimal calcSunPosition() { int precision = (settings.precisionSunMoonAngles ?: "0").toInteger() BigDecimal locLat = state.usedLatitude != null ? state.usedLatitude.toBigDecimal() : location?.latitude BigDecimal locLon = state.usedLongitude != null ? state.usedLongitude.toBigDecimal() : location?.longitude if (locLat == null || locLon == null) { logWarn "calcSunPosition: Latitude or Longitude coordinates are unavailable." return 0.0 } Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) double hour = cal.get(Calendar.HOUR_OF_DAY) + (cal.get(Calendar.MINUTE) / 60.0) + (cal.get(Calendar.SECOND) / 3600.0) int day = cal.get(Calendar.DAY_OF_MONTH) int month = cal.get(Calendar.MONTH) + 1 int year = cal.get(Calendar.YEAR) if (month <= 2) { year -= 1 month += 12 } int A = (int)(year / 100) int B = 2 - A + (int)(A / 4) double jd = (int)(365.25 * (year + 4716)) + (int)(30.6001 * (month + 1)) + day + (hour / 24.0) + B - 1524.5 double d = jd - 2451545.0 double g = 357.529 + 0.98560028 * d double q = 280.459 + 0.98564736 * d double L = q + 1.915 * Math.sin(Math.toRadians(g)) + 0.020 * Math.sin(Math.toRadians(2 * g)) double e = 23.439 - 0.00000036 * d double sin_delta = Math.sin(Math.toRadians(e)) * Math.sin(Math.toRadians(L)) double delta = Math.toDegrees(Math.asin(sin_delta)) double ra = Math.toDegrees(Math.atan2(Math.cos(Math.toRadians(e)) * Math.sin(Math.toRadians(L)), Math.cos(Math.toRadians(L)))) double gst = 280.46061837 + 360.98564736629 * d double lst = gst + locLon double H = lst - ra double latRad = Math.toRadians(locLat) double deltaRad = Math.toRadians(delta) double hRad = Math.toRadians(H) double sin_alt = Math.sin(latRad) * Math.sin(deltaRad) + Math.cos(latRad) * Math.cos(deltaRad) * Math.cos(hRad) sin_alt = Math.max(-1.0, Math.min(1.0, sin_alt)) double sAlt = Math.toDegrees(Math.asin(sin_alt)) double cos_alt = Math.cos(Math.toRadians(sAlt)) double az = 0.0 if (Math.abs(cos_alt) > 0.0001) { double cos_az = (Math.sin(deltaRad) - Math.sin(latRad) * sin_alt) / (Math.cos(latRad) * cos_alt) cos_az = Math.max(-1.0, Math.min(1.0, cos_az)) az = Math.toDegrees(Math.acos(cos_az)) if (Math.sin(hRad) > 0) az = 360.0 - az } else { az = (locLat > 0) ? 180.0 : 0.0 } az = (az % 360.0 + 360.0) % 360.0 BigDecimal finalSunAltitude = BigDecimal.valueOf(sAlt).setScale(precision, java.math.RoundingMode.HALF_UP) BigDecimal finalSunAzimuth = BigDecimal.valueOf(az).setScale(precision, java.math.RoundingMode.HALF_UP) logTrace "calcSunPosition: Solar Altitude computed as ${finalSunAltitude}°, Azimuth as ${finalSunAzimuth}°" sendIfChanged(name: "currentSunAltitude", value: finalSunAltitude) sendIfChanged(name: "currentSunAzimuth", value: finalSunAzimuth) sendIfChanged(name: "currentSunAltitudeText", value: "${finalSunAltitude}°") sendIfChanged(name: "currentSunAzimuthText", value: "${finalSunAzimuth}°") state.currentSunAltitude = finalSunAltitude return finalSunAltitude } private BigDecimal calcMoonPosition() { int precision = (settings.precisionSunMoonAngles ?: "0").toInteger() BigDecimal locLat = state.usedLatitude != null ? state.usedLatitude.toBigDecimal() : location?.latitude BigDecimal locLon = state.usedLongitude != null ? state.usedLongitude.toBigDecimal() : location?.longitude if (locLat == null || locLon == null) { logWarn "calcMoonPosition: Latitude or Longitude coordinates are unavailable." return 0.0 } Calendar cal = Calendar.getInstance(TimeZone.getTimeZone("UTC")) double hour = cal.get(Calendar.HOUR_OF_DAY) + (cal.get(Calendar.MINUTE) / 60.0) + (cal.get(Calendar.SECOND) / 3600.0) int day = cal.get(Calendar.DAY_OF_MONTH) int month = cal.get(Calendar.MONTH) + 1 int year = cal.get(Calendar.YEAR) if (month <= 2) { year -= 1 month += 12 } int A = (int)(year / 100) int B = 2 - A + (int)(A / 4) double jd = (int)(365.25 * (year + 4716)) + (int)(30.6001 * (month + 1)) + day + (hour / 24.0) + B - 1524.5 double d = jd - 2451545.0 double L = 218.316 + 13.176396 * d double M = 134.963 + 13.064993 * d double F = 93.272 + 13.229350 * d double lRad = Math.toRadians(L + 6.289 * Math.sin(Math.toRadians(M))) double bRad = Math.toRadians(5.128 * Math.sin(Math.toRadians(F))) double e = Math.toRadians(23.439 - 0.00000036 * d) double sin_dec = Math.sin(bRad) * Math.cos(e) + Math.cos(bRad) * Math.sin(e) * Math.sin(lRad) sin_dec = Math.max(-1.0, Math.min(1.0, sin_dec)) double decRad = Math.asin(sin_dec) double y = Math.sin(lRad) * Math.cos(e) - Math.tan(bRad) * Math.sin(e) double x = Math.cos(lRad) double raDeg = Math.toDegrees(Math.atan2(y, x)) double gst = 280.46061837 + 360.98564736629 * d double lst = gst + locLon double H = lst - raDeg double latRad = Math.toRadians(locLat) double hRad = Math.toRadians(H) double sin_alt = Math.sin(latRad) * Math.sin(decRad) + Math.cos(latRad) * Math.cos(decRad) * Math.cos(hRad) sin_alt = Math.max(-1.0, Math.min(1.0, sin_alt)) double mAlt = Math.toDegrees(Math.asin(sin_alt)) double cos_alt = Math.cos(Math.toRadians(mAlt)) double az = 0.0 if (Math.abs(cos_alt) > 0.0001) { double cos_az = (Math.sin(decRad) - Math.sin(latRad) * sin_alt) / (Math.cos(latRad) * cos_alt) cos_az = Math.max(-1.0, Math.min(1.0, cos_az)) az = Math.toDegrees(Math.acos(cos_az)) if (Math.sin(hRad) > 0) az = 360.0 - az } else { az = (locLat > 0) ? 180.0 : 0.0 } az = (az % 360.0 + 360.0) % 360.0 BigDecimal finalMoonAltitude = BigDecimal.valueOf(mAlt).setScale(precision, java.math.RoundingMode.HALF_UP) BigDecimal finalMoonAzimuth = BigDecimal.valueOf(az).setScale(precision, java.math.RoundingMode.HALF_UP) logTrace "calcMoonPosition: Lunar Altitude computed as ${finalMoonAltitude}°, Azimuth as ${finalMoonAzimuth}°" sendIfChanged(name: "currentMoonAltitude", value: finalMoonAltitude) sendIfChanged(name: "currentMoonAzimuth", value: finalMoonAzimuth) sendIfChanged(name: "currentMoonAltitudeText", value: "${finalMoonAltitude}°") sendIfChanged(name: "currentMoonAzimuthText", value: "${finalMoonAzimuth}°") state.currentMoonAltitude = finalMoonAltitude return finalMoonAltitude } private void calcCurrentTwilight() { if (state.todaySunriseEpoch && state.todaySunsetEpoch) { long twilightBeginEpoch = (state.todaySunriseEpoch as Long) - 1440L long twilightEndEpoch = (state.todaySunsetEpoch as Long) + 1440L logDebug "Calculated Twilight Epochs -> Begin: ${twilightBeginEpoch}, End: ${twilightEndEpoch}" sendIfChanged(name: "currentTwilightBeginTime", value: twilightBeginEpoch) sendIfChanged(name: "currentTwilightEndTime", value: twilightEndEpoch) state.twilightBeginEpoch = twilightBeginEpoch state.twilightEndEpoch = twilightEndEpoch } else { logWarn "calcCurrentTwilight skipped: Missing todaySunriseEpoch or todaySunsetEpoch in state." } } private void calcBetwixtState(BigDecimal sunAltitude, long liveSunrise = 0, long liveSunset = 0) { String sliceText = "fully night time" long currentEpoch = (now() / 1000L) long sunriseEpoch = (liveSunrise > 0) ? liveSunrise : (state.todaySunriseEpoch ?: 0) long sunsetEpoch = (liveSunset > 0) ? liveSunset : (state.todaySunsetEpoch ?: 0) boolean isTwilightAngle = (sunAltitude >= -6.0 && sunAltitude < -0.833) boolean isSunUp = (sunAltitude >= -0.833) if (sunriseEpoch > 0 && sunsetEpoch > 0) { long midDayEpoch = sunriseEpoch + ((sunsetEpoch - sunriseEpoch) / 2) try { sendIfChanged(name: "currentSolarNoonTime", value: midDayEpoch) state.solarNoonEpoch = midDayEpoch } catch (Exception e) { logError "Exception occurred while calculating currentSolarNoonTime: ${e.message}" } if (currentEpoch < midDayEpoch) { sliceText = isTwilightAngle ? "between twilight and sunrise" : (isSunUp ? "between sunrise and noon" : sliceText) } else { sliceText = isSunUp ? "between noon and sunset" : (isTwilightAngle ? "between sunset and twilight" : sliceText) } } else { sliceText = isTwilightAngle ? "between twilight and sunrise" : (isSunUp ? "between sunrise and noon" : sliceText) } sendIfChanged(name: "betwixt", value: sliceText) logDebug "Calculated betwixt slice: ${sliceText} (Current Sun Altitude: ${sunAltitude}°)" } private void calcIsDayState(BigDecimal sunAltitude) { String isDayText = (sunAltitude != null && sunAltitude >= -0.833) ? "true" : "false" sendIfChanged(name: "currentIsDay", value: isDayText) logTrace "Calculated currentIsDay: ${isDayText}" } private BigDecimal calcCurrentIlluminance(BigDecimal sunAltitude, def liveClouds = null) { logDebug "Calculating dynamic current illuminance adjusted for chosen unit..." def cloudPctVal = (liveClouds != null) ? liveClouds : device.currentValue("currentCloudPCT") if (cloudPctVal == null || sunAltitude == null) { logDebug "calcCurrentIlluminance postponed: Waiting for cloud percentage or sun altitude data." return 0.0 } if (sunAltitude <= 0) { sendIfChanged(name: "currentIlluminance", value: 0) sendIfChanged(name: "illuminance", value: 0) return 0.0 } BigDecimal clouds = cloudPctVal.toBigDecimal() double radians = Math.toRadians(sunAltitude.doubleValue()) BigDecimal clearSkyLux = 100000 * Math.sin(radians) BigDecimal attenuatedLux = (clearSkyLux * ((100 - clouds) / 100)) * 0.75 String targetUnit = settings.illuminanceUnit ?: "lx" BigDecimal finalValue = convertIlluminance(attenuatedLux) if (targetUnit == "lx" && finalValue > 100000) finalValue = 100000 logDebug "Illuminance Parsed: ${finalValue} ${targetUnit} (Base Lux: ${attenuatedLux.setScale(0, 4)} lx)" sendIfChanged(name: "currentIlluminance", value: finalValue) sendIfChanged(name: "illuminance", value: finalValue) return finalValue } private void calcTextValue(BigDecimal freshLux = null, BigDecimal freshTemp = null, BigDecimal freshPress = null, BigDecimal freshWind = null, BigDecimal freshHum = null, Map currentMap = [:], Map todayMap = [:], Map tomMap = [:], Map tdaMap = [:]) { logDebug "Generating formatted text attributes with unit suffixes..." String tUnit = settings.temperatureUnit ?: "°F" String pUnit = settings.pressureUnit ?: "inHg" String wUnit = (settings.windSpeedUnit == "none") ? "" : " ${settings.windSpeedUnit ?: 'mph'}" String iUnit = (settings.illuminanceUnit == "none") ? "lx" : (settings.illuminanceUnit ?: "lx") String hUnit = settings.humidityUnit ?: "%" if (hUnit == "none") hUnit = "" else if (hUnit in ["g/m³", "g/kg³"]) hUnit = " ${hUnit}" // 1. Temperature Text Formatting def tempVal = (freshTemp != null) ? freshTemp : device.currentValue("currentTemperature") if (tempVal != null) sendIfChanged(name: "currentTemperatureText", value: "${tempVal}${tUnit}") // 2. Pressure Text Formatting def pressVal = (freshPress != null) ? freshPress : device.currentValue("currentPressure") if (pressVal != null) sendIfChanged(name: "currentPressureText", value: "${pressVal} ${pUnit}") // 3. Wind Speed Text Formatting def windTargets = [ [prefix: "current", val: freshWind, map: currentMap], [prefix: "today", val: null, map: todayMap], [prefix: "tom", val: null, map: tomMap], [prefix: "tda", val: null, map: tdaMap] ] windTargets.each { target -> def speedVal = target.val if (speedVal == null) { speedVal = (target.map && target.map.wind_speed != null) ? convertWindSpeed(target.map.wind_speed) : device.currentValue("${target.prefix}WindSpeed") } if (speedVal != null) sendIfChanged(name: "${target.prefix}WindSpeedText", value: "${speedVal}${wUnit}") def rawSpeed = (target.map && target.map.wind_speed != null) ? target.map.wind_speed : device.currentValue("${target.prefix}WindSpeed") sendIfChanged(name: "${target.prefix}WindSpeedDescText", value: getBeaufortText(rawSpeed)) } // 4. Illuminance Text Formatting def luxVal = (freshLux != null) ? freshLux : device.currentValue("currentIlluminance") if (luxVal != null) sendIfChanged(name: "currentIlluminanceText", value: "${luxVal} ${iUnit}") // 5. Sun and Moon Angles Formatting int sunMoonPrecision = (settings.precisionSunMoonAngles ?: "0").toInteger() def sunAltVal = device.currentValue("currentSunAltitude") if (sunAltVal != null && sunAltVal.toString().isNumber()) { BigDecimal formattedAlt = sunAltVal.toBigDecimal().setScale(sunMoonPrecision, java.math.RoundingMode.HALF_UP) String altStr = (sunMoonPrecision == 0) ? "${formattedAlt.toBigInteger()}" : "${formattedAlt}" sendIfChanged(name: "currentSunAltitudeText", value: "${altStr}°") } def sunAzVal = device.currentValue("currentSunAzimuth") if (sunAzVal != null && sunAzVal.toString().isNumber()) { BigDecimal formattedAz = sunAzVal.toBigDecimal().setScale(sunMoonPrecision, java.math.RoundingMode.HALF_UP) String azStr = (sunMoonPrecision == 0) ? "${formattedAz.toBigInteger()}" : "${formattedAz}" sendIfChanged(name: "currentSunAzimuthText", value: "${azStr}°") } def moonAltVal = device.currentValue("currentMoonAltitude") if (moonAltVal != null && moonAltVal.toString().isNumber()) { BigDecimal formattedAlt = moonAltVal.toBigDecimal().setScale(sunMoonPrecision, java.math.RoundingMode.HALF_UP) String altStr = (sunMoonPrecision == 0) ? "${formattedAlt.toBigInteger()}" : "${formattedAlt}" sendIfChanged(name: "currentMoonAltitudeText", value: "${altStr}°") } def moonAzVal = device.currentValue("currentMoonAzimuth") if (moonAzVal != null && moonAzVal.toString().isNumber()) { BigDecimal formattedAz = moonAzVal.toBigDecimal().setScale(sunMoonPrecision, java.math.RoundingMode.HALF_UP) String azStr = (sunMoonPrecision == 0) ? "${formattedAz.toBigInteger()}" : "${formattedAz}" sendIfChanged(name: "currentMoonAzimuthText", value: "${azStr}°") } // 6. Humidity Formatting def humVal = (freshHum != null) ? freshHum : device.currentValue("currentHumidity") if (humVal != null) sendIfChanged(name: "currentHumidityText", value: "${humVal}${hUnit}") // DATE/TIME PROPERTIES CONSOLIDATED LOOP logDebug "Consolidated loop executing for date/time properties..." String chosenDTForm = settings.DateTimeForm ?: "1" String chosenDateForm = settings.DateForm ?: "1" String chosenTimeForm = settings.TimeForm ?: "1" device.properties.supportedAttributes.each { attr -> String attrName = attr.name def epochValue = null String formatSelection = "1" String textTargetSuffix = "" if (attrName.endsWith("DateTime")) { epochValue = device.currentValue(attrName) formatSelection = chosenDTForm textTargetSuffix = "Text" } else if (attrName.endsWith("Date")) { if (attrName == "todayDate" && todayMap?.dt != null) epochValue = todayMap.dt else if (attrName == "tomDate" && tomMap?.dt != null) epochValue = tomMap.dt else if (attrName == "tdaDate" && tdaMap?.dt != null) epochValue = tdaMap.dt else epochValue = device.currentValue(attrName) formatSelection = chosenDateForm textTargetSuffix = "Text" } else if (attrName.endsWith("Time") && !attrName.contains("DateTime")) { if (attrName == "currentSunriseTime" && currentMap?.sunrise != null) epochValue = currentMap.sunrise else if (attrName == "currentSunsetTime" && currentMap?.sunset != null) epochValue = currentMap.sunset else if (attrName == "todaySunriseTime" && todayMap?.sunrise != null) epochValue = todayMap.sunrise else if (attrName == "todaySunsetTime" && todayMap?.sunset != null) epochValue = todayMap.sunset else if (attrName == "todayMoonriseTime" && todayMap?.moonrise != null) epochValue = todayMap.moonrise else if (attrName == "todayMoonsetTime" && todayMap?.moonset != null) epochValue = todayMap.moonset else if (attrName == "tomSunriseTime" && tomMap?.sunrise != null) epochValue = tomMap.sunrise else if (attrName == "tomSunsetTime" && tomMap?.sunset != null) epochValue = tomMap.sunset else if (attrName == "tomMoonriseTime" && tomMap?.moonrise != null) epochValue = tomMap.moonrise else if (attrName == "tomMoonsetTime" && tomMap?.moonset != null) epochValue = tomMap.moonset else if (attrName == "tdaSunriseTime" && tdaMap?.sunrise != null) epochValue = tdaMap.sunrise else if (attrName == "tdaSunsetTime" && tdaMap?.sunset != null) epochValue = tdaMap.sunset else if (attrName == "tdaMoonriseTime" && tdaMap?.moonrise != null) epochValue = tdaMap.moonrise else if (attrName == "tdaMoonsetTime" && tdaMap?.moonset != null) epochValue = tdaMap.moonset else if (attrName == "currentTwilightBeginTime") epochValue = state.twilightBeginEpoch ?: device.currentValue(attrName) else if (attrName == "currentTwilightEndTime") epochValue = state.twilightEndEpoch ?: device.currentValue(attrName) else if (attrName == "currentSolarNoonTime") epochValue = state.solarNoonEpoch ?: device.currentValue(attrName) else epochValue = device.currentValue(attrName) formatSelection = chosenTimeForm textTargetSuffix = "Text" } if (epochValue != null && textTargetSuffix != "") { String targetAttr = "${attrName}${textTargetSuffix}" if (formatSelection == "10") { sendIfChanged(name: targetAttr, value: "${epochValue}") } else { Map formattedMap = convertDateTimeFormat(epochValue, formatSelection) String finalVal = (attrName.endsWith("Date")) ? formattedMap.date : ((attrName.endsWith("Time") && !attrName.contains("DateTime")) ? formattedMap.time : formattedMap.dateTime) sendIfChanged(name: targetAttr, value: finalVal) } } } // Current Wind Summary logDebug "Section 11b: Formatting Current Wind Summary Text ..." try { def windValLocal = (freshWind != null) ? freshWind : device.currentValue("currentWindSpeed") String windSpeedText = (windValLocal != null) ? "${windValLocal}${wUnit}" : (device.currentValue("currentWindSpeedText") ?: "--") String currentWindSpeedDescText = device.currentValue("currentWindSpeedDescText") ?: "Calm" String windDirFull = device.currentValue("currentWindDirFull") ?: "Unknown" sendIfChanged(name: "currentWindSummaryText", value: "${currentWindSpeedDescText} from the ${windDirFull} at ${windSpeedText}") } catch (Exception e) { logError "Exception occurred during currentWindSummaryText generation: ${e.message}" } // Current Weather Summary logDebug "Section 12: Formatting Current Weather Summary Text ..." try { def city = state.usedCity ?: "Local Area" def lastUpdatedEpoch = state.lastUpdatedDateTime String lastUpdatedDate = "--" String lastUpdatedTime = "--" if (lastUpdatedEpoch != null) { if (chosenDateForm == "10") { lastUpdatedDate = "${lastUpdatedEpoch}" } else { lastUpdatedDate = convertDateTimeFormat(lastUpdatedEpoch, chosenDateForm).date } if (chosenTimeForm == "10") { lastUpdatedTime = "${lastUpdatedEpoch}" } else { lastUpdatedTime = convertDateTimeFormat(lastUpdatedEpoch, chosenTimeForm).time } } def cond = device.currentValue("currentConditionTypeDesc") ?: "--" def temp = (freshTemp != null) ? freshTemp : device.currentValue("currentTemperature") def hi = (todayMap && todayMap.temp?.max != null) ? convertKelvin(todayMap.temp.max) : (device.currentValue("todayTempMax") ?: "--") def lo = (todayMap && todayMap.temp?.min != null) ? convertKelvin(todayMap.temp.min) : (device.currentValue("todayTempMin") ?: "--") def feels = device.currentValue("currentFeelsLike") ?: temp String tempText = (temp != null) ? "${temp}${tUnit}" : (device.currentValue("currentTemperatureText") ?: "--") humVal = (freshHum != null) ? freshHum : device.currentValue("currentHumidity") String humText = (humVal != null) ? "${humVal}%" : "--" def windRaw = (currentMap && currentMap.wind_speed != null) ? currentMap.wind_speed : device.currentValue("currentWindSpeed") String windStrengthText = getBeaufortText(windRaw) String windDirFull = device.currentValue("currentWindDirFull") ?: "Unknown" def windValLocal = (freshWind != null) ? freshWind : device.currentValue("currentWindSpeed") String windSpeedText = (windValLocal != null) ? "${windValLocal}${wUnit}" : (device.currentValue("currentWindSpeedText") ?: "--") def popRaw = (todayMap && todayMap.pop != null) ? todayMap.pop : (device.currentValue("todayPOP") ?: 0) int popPct = (popRaw.toBigDecimal() * 100).intValue() String visibilityText = device.currentValue("currentVisibilityText") ?: "--" String alertVal = device.currentValue("currentAlert") ?: "No active alerts" String alertSentence = (alertVal != "No active alerts" && alertVal != "N/A" && alertVal != "") ? "Active alerts for ${alertVal} exist for this area." : "No active alerts exist for this area." String summary = "Weather summary for ${city} updated at ${lastUpdatedTime} on ${lastUpdatedDate}. " + "Expect ${cond} with a high of ${hi}${tUnit} and a low of ${lo}${tUnit}. " + "Humidity is ${humText} and the current temperature is ${tempText}. " + "The temperature feels like it is ${feels}${tUnit}. " + "Wind: ${windStrengthText} from the ${windDirFull} at ${windSpeedText}. " + "There is a ${popPct}% chance of precipitation. " + "Visibility is around ${visibilityText}. " + "${alertSentence}" sendIfChanged(name: "currentWeatherSummaryText", value: summary) } catch (Exception e) { logError "Exception occurred during currentWeatherSummaryText generation: ${e.message}" } } private String getBeaufortText(def rawMsVal) { double ms = rawMsVal?.toDouble() ?: 0.0 if (ms < 0.3) return "Calm" if (ms <= 1.5) return "Light Air" if (ms <= 3.3) return "Light Breeze" if (ms <= 5.4) return "Gentle Breeze" if (ms <= 7.9) return "Moderate Breeze" if (ms <= 10.7) return "Fresh Breeze" if (ms <= 13.8) return "Strong Breeze" if (ms <= 17.1) return "Near Gale" if (ms <= 20.7) return "Gale" if (ms <= 24.4) return "Severe Gale" if (ms <= 28.4) return "Storm" if (ms <= 32.6) return "Violent Storm" return "Hurricane" } private void calcLonLatCityState() { String currentCity = settings.overrideCity ?: "" BigDecimal currentLat = settings.overrideLatitude ? settings.overrideLatitude.toBigDecimal() : null BigDecimal currentLon = settings.overrideLongitude ? settings.overrideLongitude.toBigDecimal() : null Boolean settingsChanged = (currentCity != state.lastOverrideCity || currentLat != state.lastOverrideLatitude || currentLon != state.lastOverrideLongitude) Boolean hasCachedData = (state.usedCity && state.usedLatitude != null && state.usedLongitude != null) if (!settingsChanged && hasCachedData) { logDebug "Coordinates and city are unchanged and cached. Skipping geo lookup." return } String usedCity = "" BigDecimal usedLatitude = 0.0 BigDecimal usedLongitude = 0.0 if (currentCity && currentCity.trim() != "") { logDebug "Scenario 1: overrideCity provided ('${currentCity}'). Running Direct Geo-Lookup." String encodedCity = URLEncoder.encode(currentCity.trim(), "UTF-8") String geoUrl = "https://api.openweathermap.org/geo/1.0/direct?q=${encodedCity}&limit=1&appid=${apiKey}" if (!settings.aPIKeyExposedEnable) { logDebug "Polling OpenWeatherMap GEO via URL: ${geoUrl.replaceAll(/appid=[^&]+/, 'appid=***')}" } else { logDebug "Polling OpenWeatherMap GEO via URL: ${geoUrl}" } def params = [uri: geoUrl, contentType: "application/json", timeout: 10] try { httpGet(params) { response -> if (response.status == 200 && response.data && response.data.size() > 0) { def locationData = response.data[0] usedCity = locationData.name ?: currentCity usedLatitude = locationData.lat ? locationData.lat.toBigDecimal() : 0.0 usedLongitude = locationData.lon ? locationData.lon.toBigDecimal() : 0.0 logDebug "Direct Geo-Lookup Success -> City: ${usedCity}, Lat: ${usedLatitude}, Lon: ${usedLongitude}" } else { logWarn "Direct Geo-Lookup returned no results. Falling back to configurations." } } } catch (Exception e) { logError "Exception occurred during Direct Geo-Lookup execution: ${e.message}" } } else { logDebug "Scenario 2: No overrideCity provided. Evaluating coordinate inputs or Hub configuration." usedLatitude = currentLat ?: location?.latitude?.toBigDecimal() usedLongitude = currentLon ?: location?.longitude?.toBigDecimal() if (usedLatitude && usedLongitude && apiKey) { logDebug "Attempting Reverse Geo-Lookup using Lat: ${usedLatitude}, Lon: ${usedLongitude}" String reverseGeoUrl = "https://api.openweathermap.org/geo/1.0/reverse?lat=${usedLatitude}&lon=${usedLongitude}&limit=1&appid=${apiKey}" def params = [uri: reverseGeoUrl, contentType: "application/json", timeout: 10] try { httpGet(params) { response -> if (response.status == 200 && response.data && response.data.size() > 0) { usedCity = response.data[0].name ?: "Local Area" logDebug "Reverse Geo-Lookup Success -> City resolved: ${usedCity}" } else { logWarn "Reverse Geo-Lookup returned empty results. Defaulting city name." usedCity = "Local Area" } } } catch (Exception e) { logError "Exception occurred during Reverse Geo-Lookup execution: ${e.message}" usedCity = "Local Area" } } else { usedCity = "Local Area" } } if (!usedLatitude || usedLatitude == 0.0) { usedLatitude = currentLat ?: location?.latitude?.toBigDecimal() ?: 0.0 } if (!usedLongitude || usedLongitude == 0.0) { usedLongitude = currentLon ?: location?.longitude?.toBigDecimal() ?: 0.0 } if (!usedCity || usedCity.trim() == "") { usedCity = "Local Area" } state.usedCity = usedCity state.usedLatitude = usedLatitude state.usedLongitude = usedLongitude state.lastOverrideCity = currentCity state.lastOverrideLatitude = currentLat state.lastOverrideLongitude = currentLon } private void sendIfChanged(Map args) { if (!args || !args.name) return String oldVal = device.currentValue(args.name as String)?.toString() String newVal = args.value != null ? args.value.toString() : "" if (oldVal != newVal) { Map eventMap = [name: args.name, value: args.value, descriptionText: "Attribute ${args.name} changed to ${args.value}"] if (args.unit) eventMap.unit = args.unit sendEvent(eventMap) logDebug "Event triggered: ${args.name} -> ${args.value}" } } private Map lookupConditionDetails(Integer code) { def conditions = [ // Thunderstorms 200: [type: "Thunderstorm", desc: "Thunderstorm with Light Rain", altDay: "38.png", altNight: "47.png"], 201: [type: "Thunderstorm", desc: "Thunderstorm with Rain", altDay: "38.png", altNight: "47.png"], 202: [type: "Thunderstorm", desc: "Thunderstorm with Heavy Rain", altDay: "38.png", altNight: "47.png"], 210: [type: "Thunderstorm", desc: "Light Thunderstorm", altDay: "38.png", altNight: "47.png"], 211: [type: "Thunderstorm", desc: "Thunderstorm", altDay: "38.png", altNight: "47.png"], 212: [type: "Thunderstorm", desc: "Heavy Thunderstorm", altDay: "38.png", altNight: "47.png"], 221: [type: "Thunderstorm", desc: "Ragged Thunderstorm", altDay: "38.png", altNight: "47.png"], 230: [type: "Thunderstorm", desc: "Thunderstorm with Light Drizzle", altDay: "38.png", altNight: "47.png"], 231: [type: "Thunderstorm", desc: "Thunderstorm with Drizzle", altDay: "38.png", altNight: "47.png"], 232: [type: "Thunderstorm", desc: "Thunderstorm with Heavy Drizzle", altDay: "38.png", altNight: "47.png"], // Drizzle 300: [type: "Drizzle", desc: "Light Intensity Drizzle", altDay: "9.png", altNight: "9.png"], 301: [type: "Drizzle", desc: "Drizzle", altDay: "9.png", altNight: "9.png"], 302: [type: "Drizzle", desc: "Heavy Intensity Drizzle", altDay: "9.png", altNight: "9.png"], 310: [type: "Drizzle", desc: "Light Intensity Drizzle Rain", altDay: "9.png", altNight: "9.png"], 311: [type: "Drizzle", desc: "Drizzle Rain", altDay: "9.png", altNight: "9.png"], 312: [type: "Drizzle", desc: "Heavy Intensity Drizzle Rain", altDay: "9.png", altNight: "9.png"], 313: [type: "Drizzle", desc: "Shower Rain and Drizzle", altDay: "9.png", altNight: "9.png"], 314: [type: "Drizzle", desc: "Heavy Shower Rain and Drizzle", altDay: "9.png", altNight: "9.png"], 321: [type: "Drizzle", desc: "Shower Drizzle", altDay: "9.png", altNight: "9.png"], // Rain 500: [type: "Rain", desc: "Light Rain", altDay: "39.png", altNight: "9.png"], 501: [type: "Rain", desc: "Moderate Rain", altDay: "39.png", altNight: "11.png"], 502: [type: "Rain", desc: "Heavy Intensity Rain", altDay: "39.png", altNight: "11.png"], 503: [type: "Rain", desc: "Very Heavy Rain", altDay: "39.png", altNight: "11.png"], 504: [type: "Rain", desc: "Extreme Rain", altDay: "39.png", altNight: "11.png"], 511: [type: "Rain", desc: "Freezing Rain", altDay: "39.png", altNight: "11.png"], 520: [type: "Rain", desc: "Light Intensity Shower Rain", altDay: "39.png", altNight: "9.png"], 521: [type: "Rain", desc: "Shower Rain", altDay: "39.png", altNight: "11.png"], 522: [type: "Rain", desc: "Heavy Intensity Shower Rain", altDay: "39.png", altNight: "11.png"], 531: [type: "Rain", desc: "Ragged Shower Rain", altDay: "39.png", altNight: "9.png"], // Snow 600: [type: "Snow", desc: "Light Snow", altDay: "13.png", altNight: "13.png"], 601: [type: "Snow", desc: "Snow", altDay: "14.png", altNight: "14.png"], 602: [type: "Snow", desc: "Heavy Snow", altDay: "16.png", altNight: "16.png"], 611: [type: "Snow", desc: "Sleet", altDay: "18.png", altNight: "18.png"], 612: [type: "Snow", desc: "Light Shower Sleet", altDay: "18.png", altNight: "18.png"], 613: [type: "Snow", desc: "Shower Sleet", altDay: "18.png", altNight: "18.png"], 615: [type: "Snow", desc: "Light Rain and Snow", altDay: "5.png", altNight: "5.png"], 616: [type: "Snow", desc: "Rain and Snow", altDay: "5.png", altNight: "5.png"], 620: [type: "Snow", desc: "Light Shower Snow", altDay: "13.png", altNight: "13.png"], 621: [type: "Snow", desc: "Shower Snow", altDay: "14.png", altNight: "14.png"], 622: [type: "Snow", desc: "Heavy Shower Snow", altDay: "16.png", altNight: "16.png"], // Atmosphere 701: [type: "Atmosphere", desc: "Mist", altDay: "20.png", altNight: "20.png"], 711: [type: "Atmosphere", desc: "Smoke", altDay: "20.png", altNight: "20.png"], 721: [type: "Atmosphere", desc: "Haze", altDay: "21.png", altNight: "21.png"], 731: [type: "Atmosphere", desc: "Sand/Dust Whirls", altDay: "19.png", altNight: "19.png"], 741: [type: "Atmosphere", desc: "Fog", altDay: "20.png", altNight: "20.png"], 751: [type: "Atmosphere", desc: "Sand", altDay: "19.png", altNight: "19.png"], 761: [type: "Atmosphere", desc: "Dust", altDay: "19.png", altNight: "19.png"], 762: [type: "Atmosphere", desc: "Volcanic Ash", altDay: "19.png", altNight: "19.png"], 771: [type: "Atmosphere", desc: "Squalls", altDay: "24.png", altNight: "24.png"], 781: [type: "Atmosphere", desc: "Tornado", altDay: "24.png", altNight: "24.png"], // Clear & Clouds 800: [type: "Clear", desc: "Clear Sky", altDay: "32.png", altNight: "31.png"], 801: [type: "Clouds", desc: "Few Clouds: 11-25%", altDay: "34.png", altNight: "29.png"], 802: [type: "Clouds", desc: "Scattered Clouds: 25-50%", altDay: "30.png", altNight: "30.png"], 803: [type: "Clouds", desc: "Broken Clouds: 51-84%", altDay: "28.png", altNight: "27.png"], 804: [type: "Clouds", desc: "Overcast Clouds: 85-100%", altDay: "26.png", altNight: "26.png"] ] return conditions[code] ?: [type: "Unknown", desc: "Unknown Condition", altDay: "unknown.png", altNight: "unknown.png"] } void disableDebugLogging() { logInfo "30 minutes have elapsed. Automatically disabling debug logging." device.updateSetting("logDebugEnable", [type: "bool", value: false]) } private void logMessage(String level, String msg) { if (settings["log${level.capitalize()}Enable"] == true) { log."${level}" "OpenWeatherMap Driver${level == 'warn' ? ' WARNING' : level == 'error' ? ' ERROR' : ''}: ${msg}" } } private void logInfo(String msg) { logMessage("info", msg) } private void logDebug(String msg) { logMessage("debug", msg) } private void logTrace(String msg) { logMessage("trace", msg) } private void logWarn(String msg) { logMessage("warn", msg) } private void logError(String msg) { logMessage("error", msg) }