diff --git a/CODEOWNERS b/CODEOWNERS index 97bd8089e8d23..bb8a1f509f380 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -243,6 +243,7 @@ /bundles/org.openhab.binding.monopriceaudio/ @mlobstein /bundles/org.openhab.binding.mpd/ @stefanroellin /bundles/org.openhab.binding.mqtt/ @ccutrer +/bundles/org.openhab.binding.mqtt.awtrixlight/ @DrRSatzteil /bundles/org.openhab.binding.mqtt.espmilighthub/ @Skinah /bundles/org.openhab.binding.mqtt.fpp/ @computergeek1507 /bundles/org.openhab.binding.mqtt.generic/ @ccutrer diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index ea798c3d2c6f5..bb0b318825c52 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -1201,6 +1201,11 @@ org.openhab.binding.mqtt ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.mqtt.awtrixlight + ${project.version} + org.openhab.addons.bundles org.openhab.binding.mqtt.espmilighthub diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/NOTICE b/bundles/org.openhab.binding.mqtt.awtrixlight/NOTICE new file mode 100644 index 0000000000000..38d625e349232 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/README.md b/bundles/org.openhab.binding.mqtt.awtrixlight/README.md new file mode 100644 index 0000000000000..bab113bfa9ae7 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/README.md @@ -0,0 +1,566 @@ +# MQTT Awtrix 3 Binding + +This binding allows you to control Awtrix 3 (formerly Awtrix Light) LED matrix displays via MQTT. +Awtrix 3 is a firmware for a 32x8 LED matrix display that can show various information like time, weather, notifications and custom text/graphics. +The most popular choice for a device that supports the Awtrix 3 firmware is the Ulanzi tc0001 clock. + +## Supported Things + +This binding supports two types of things: + +| Thing Type | Description | +|-------------------------|-------------------------------------------------------------------------------------------------| +| `awtrix-clock` (Bridge) | Represents an Awtrix 3 display device. Acts as a bridge for apps. | +| `awtrix-app` | Represents an app running on the Awtrix display. Apps can show text, icons, notifications, etc. | + +The binding was tested with the Ulanzi tc0001 clock. + +## Discovery + +The binding can automatically discover Awtrix 3 devices that publish their status to the configured MQTT broker. +Make sure to use a mqtt prefix that starts with `awtrix` for discovery to work. +It is however recommended to use a prefix with two topic levels, for example `awtrix/clock1` so that you can discover and control multiple devices. +Once a device is discovered, it will appear in the inbox. +There is no need to trigger a discovery scan manually. +Default Awtrix apps can also be discovered if `discoverDefaultApps` is enabled on the bridge. +This is however not recommended as the default apps cannot be controlled with this binding. + +## Binding Configuration + +The Awtrix 3 binding does not offer any binding configuration parameters. + +## Thing Configuration + +### Bridge Configuration (`awtrix-clock`) + +| Parameter | Description | Default | Required | +|-----------------------|--------------------------------------------------------------------------------------------------------------------------------------------|----------|----------| +| `basetopic` | The MQTT base topic for the Awtrix device | "awtrix" | Yes | +| `appLockTimeout` | Timeout in seconds before releasing the lock to a selected app and returning to normal app cycle (see App Configuration for more details). | 10 | Yes | +| `discoverDefaultApps` | Enable discovery of default apps. Since default apps cannot be controlled by this binding this should usually be disabled. | false | Yes | +| `lowBatteryThreshold` | Battery level threshold for low battery warning. | 25 | Yes | + +### App Configuration (`awtrix-app`) + +| Parameter | Description | Default | Required | +|--------------|------------------------------------|---------|----------| +| `appname` | Name of the app | - | Yes | +| `useButtons` | Enable button control for this app | false | No | + +When you enable the button control for an app, you can lock the app to the display by pushing the select button on the clock device. +A red indicator will be shown while the app is locked and will start to blink shortly before the lock ends. +The lock will last for the appLockTimeout set for the bridge. +As long as the app is locked the normal app cycle is disabled and you can control the app by pressing the left and right buttons or the select button on the clock device. +Pressing a button while the app is locked will reset the lock timeout to the value set for appLockTimeout. Left and right button presses will emit button events on the clock itself and the selected app. +The button events can be used by rules to change the displayed app or perform any other actions (for example change the text color of the app or skip the current song playing on your audio device). + +## Channels + +### Bridge Channels (`awtrix-clock`) + +| Channel | Action parameter (see Actions) | Read/Write | Description | +|-----------------------|------------------------------------------|------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `active` | - | RW | Enable/disable the app: Switches the app on or off. Note that channels of inactive apps will be reset to their default values during a restart of openHAB. | +| `autoscale` | `autoscale` | RW | Enable/disable autoscaling for bar and linechart. | +| `background` | `background` | RW | Sets a background color. | +| `bar` | `bar` | RW | Shows a bar chart: Send a string with values separated by commas (e.g. "value1,value2,value3"). Only the last 16 values will be displayed. | +| `blink` | `blinkText` | RW | Blink text: Blink the text in the specified interval. Ignored if gradientColor or rainbow are set. | +| `center` | `center` | RW | Center short text horizontally and disable scrolling. | +| `color` | `color` | RW | Text, bar or line chart color. | +| `duration` | `duration` | RW | Display duration in seconds. | +| `effect` | `effectSettings` | RW | Display effect (see https://blueforcer.github.io/awtrix3/#/effects for possible values). | +| `effect-blend` | `blend` as key in `effectSettings` | RW | Enable smoother effect transitions. Only to be used with effect. | +| `effect-palette` | `palette` as key in `effectSettings` Map | RW | Color palette for effects (see https://blueforcer.github.io/awtrix3/#/effects for possible values and how to create custom palettes). Only to be used with effect. | +| `effect-speed` | `speed` as key in `effectSettings` Map | RW | Effect animation speed: Higher means faster (see https://blueforcer.github.io/awtrix3/#/effects). Only to be used with effect. | +| `fade` | `fadeText` | RW | Fade text: Fades the text in and out in the specified interval. Ignored if gradientColor or rainbow are set. | +| `gradient-color` | `gradient` | RW | Secondary color for gradient effects. Use color for setting the primary color. | +| `icon` | `icon` | RW | Icon name to display: Install icons on the clock device first. | +| `lifetime` | `lifetime` | RW | App lifetime: Define how long the app will remain active on the clock. | +| `lifetime-mode` | `lifetimeMode` | RW | Lifetime mode: Define if the app should be deleted (Command DELETE) or marked as stale (Command STALE) after lifetime. | +| `line` | `line` | RW | Shows a line chart: Send a string with values separated by commas (e.g. "value1,value2,value3"). Only the last 16 values will be displayed. | +| `overlay` | `overlay` | RW | Enable overlay mode: Shows a weather overlay effect (can be any of clear, snow, rain, drizzle, storm, thunder, frost). | +| `progress` | `progress` | RW | Progress value: Shows a progress bar at the bottom of the app with the specified percentage value. | +| `progress-background` | `progressBC` | RW | Progress bar background color: Background color for the progress bar. | +| `progress-color` | `progressC` | RW | Progress bar color: Color for the progress bar. | +| `push-icon` | `pushIcon` | RW | Push icon animation (STATIC=Icon doesn't move, PUSHOUT=Icon moves with text and will not appear again, PUSHOUTRETURN=Icon moves with text but appears again when the text starts to scroll again). | +| `rainbow` | `rainbow` | RW | Enable rainbow effect: Uses a rainbow effect for the displayed text. | +| `reset` | - | RW | Reset app to default state: All channels will be reset to their default values. | +| `scroll` | `scrollText` | RW | Scroll text: Scroll the text in the specified interval. Ignored if gradientColor or rainbow are set. | +| `scroll-speed` | `scrollSpeed` | RW | Text scrolling speed: Provide as percentage value. The original speed is 100%. Values above 100% will increase the scrolling speed, values below 100% will decrease it. Setting this value to 0 will disable scrolling completely. | +| `text` | `text` | RW | Text to display. | +| `text-case` | `textCase` | RW | Set text case (0=normal, 1=uppercase, 2=lowercase). | +| `text-offset` | `textOffset` | RW | Text offset position: Horizontal offset of the text in pixels. | +| `top-text` | `topText` | RW | Draws the text on the top of the display. | + +### App Channels (`awtrix-app`) + +| Channel | Type | Read/Write | Description | | +|-----------------------|-----------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---| +| `active` | - | Switch | W | Enable/disable the app: Switches the app on or off. Note that channels of inactive apps will be reset to their default values during a restart of openHAB. | | | +| `autoscale` | `autoscale` | boolean | Switch | RW | Enable/disable autoscaling for bar and linechart. | | +| `background` | `background` | int[] (rgb-Array) | Color | RW | Sets a background color. | | +| `bar` | `bar` | String | String | RW | Shows a bar chart: Send a string with values separated by commas (e.g. "value1,value2,value3"). Only the last 16 values will be displayed. | | +| `blink` | `blinkText` | BigDecimal (in milliseconds) | Number:Time | RW | Blink text: Blink the text in the specified interval. Ignored if gradientColor or rainbow are set. | | +| `center` | `center` | boolean | Switch | RW | Center short text horizontally and disable scrolling. | | +| `color` | `color` | BigDecimal[] (rgb-Array) | Color | RW | Text, bar or line chart color. | | +| `duration` | `duration` | BigDecimal | Number:Time | RW | Display duration in seconds. | | +| `effect` | `effectSettings` | Map<String, Object> | String | RW | Display effect (see https://blueforcer.github.io/awtrix3/#/effects for possible values). | | +| `effect-blend` | `blend` as key in `effectSettings` | boolean | Switch | RW | Enable smoother effect transitions. Only to be used with effect. | | +| `effect-palette` | `palette` as key in `effectSettings` Map | String ("None" for default) | String | RW | Color palette for effects (see https://blueforcer.github.io/awtrix3/#/effects for possible values and how to create custom palettes). Only to be used with effect. | | +| `effect-speed` | `speed` as key in `effectSettings` Map | BigDecimal | Number:Dimensionless | RW | Effect animation speed: Higher means faster (see https://blueforcer.github.io/awtrix3/#/effects). Only to be used with effect. | | +| `fade` | `fadeText` | BigDecimal (in milliseconds) | Number:Time | RW | Fade text: Fades the text in and out in the specified interval. Ignored if gradientColor or rainbow are set. | | +| `gradient-color` | `gradient` | BigDecimal[] (rgb-Array) | Color | RW | Secondary color for gradient effects. Use color for setting the primary color. | | +| `icon` | `icon` | String | String | RW | Icon name to display: Install icons on the clock device first. | | +| `lifetime` | `lifetime` | BigDecimal | Number:Time | RW | App lifetime: Define how long the app will remain active on the clock. | | +| `lifetime-mode` | `lifetimeMode` | BigDecimal (0=DELETE, 1=STALE) | String | RW | Lifetime mode: Define if the app should be deleted (Command DELETE) or marked as stale (Command STALE) after lifetime. | | +| `line` | `line` | String | String | RW | Shows a line chart: Send a string with values separated by commas (e.g. "value1,value2,value3"). Only the last 16 values will be displayed. | | +| `overlay` | `overlay` | String | String | RW | Enable overlay mode: Shows a weather overlay effect (can be any of clear, snow, rain, drizzle, storm, thunder, frost). | | +| `progress` | `progress` | String | Number:Dimensionless | RW | Progress value: Shows a progress bar at the bottom of the app with the specified percentage value. | | +| `progress-background` | `progressBC` | BigDecimal[] (rgb-Array) | Color | RW | Progress bar background color: Background color for the progress bar. | | +| `progress-color` | `progressC` | BigDecimal[] (rgb-Array) | Color | RW | Progress bar color: Color for the progress bar. | | +| `push-icon` | `pushIcon` | BigDecimal (0=STATIC, 1=PUSHOUT, 2=PUSHRETURN) | String | RW | Push icon animation (STATIC=Icon doesn't move, PUSHOUT=Icon moves with text and will not appear again, PUSHOUTRETURN=Icon moves with text but appears again when the text starts to scroll again). | | +| `rainbow` | `rainbow` | boolean | Switch | RW | Enable rainbow effect: Uses a rainbow effect for the displayed text. | | +| `reset` | - | | Switch | RW | Reset app to default state: All channels will be reset to their default values. | | +| `scroll-speed` | `scrollSpeed` | BigDecimal | Number:Dimensionless | RW | Text scrolling speed: Provide as percentage value. The original speed is 100%. Values above 100% will increase the scrolling speed, values below 100% will decrease it. Setting this value to 0 will disable scrolling completely. | | +| `text` | `text` | String | String | RW | Text to display. | | +| `text-case` | `textCase` | BigDecimal | Number:Dimensionless | RW | Set text case (0=normal, 1=uppercase, 2=lowercase). | | +| `text-offset` | `textOffset` | BigDecimal | Number:Dimensionless | RW | Text offset position: Horizontal offset of the text in pixels. | | +| `top-text` | `topText` | boolean | Switch | RW | Draws the text on the top of the display. | | + +## Actions + +The binding supports various actions that can be used in rules to control the Awtrix display. To use these actions, you need to import them in your rules (see examples below). + +The following actions are supported: + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Action NameDescriptionParameter TypeParameter NameParameter Description
blinkIndicatorBlink an indicator LED.intindicatorId1 for top indicator, 2 for center indicator or 3 for bottom indicator.
int[]rgbProvide an array with exactly 3 values for red, green and blue (in the range 0-255).
intblinkTimeInMsProvide the blink time in milliseconds.
fadeIndicatorFade an indicator LED in and out.intindicatorId1 for top indicator, 2 for center indicator or 3 for bottom indicator.
int[]rgbProvide an array with exactly 3 values for red, green and blue (in the range 0-255).
intfadeTimeInMsProvide the fade time in milliseconds.
activateIndicatorActivate an indicator LED.intindicatorId1 for top indicator, 2 for center indicator or 3 for bottom indicator.
int[]rgbProvide an array with exactly 3 values for red, green and blue (in the range 0-255).
deactivateIndicatorDeactivate an indicator LED.intindicatorId1 for top indicator, 2 for center indicator or 3 for bottom indicator.
rebootReboot the device
sleepMake the device sleep for a number of seconds.intsecondsDevice will wake up after the specified number of seconds. Sleep can only be interrupted by a press of the select button.
upgradeUpgrade the device if a firmware update is available.
playSoundPlay a sound file saved on the device.StringmelodyThe sound file name saved in the clocks MELODIES folder (without the file extension).
playRtttlPlay a rtttl sound.StringrtttlThe rtttl string to play.
showNotificationShow a notification.StringmessageThe message to show.
StringiconThe name of the icon saved on the device that is shown with the message.
showCustomNotificationShow a notification with maximal customization options.Map<String, Object>appParamsMap that holds any parameter that is available for an Awtrix App as shown in the App Channels section of the documentation. Use the channel ids as keys.
booleanholdWhether the notification should stay on the screen until the user presses the select button.
booleanwakeUpWhether the notification should wake up the device if the display is currently switched off.
booleanstackWhether the notification should be stacked on top of the previous notification or replace the currently active notification.
StringrtttlPlay the specified rtttl sound when displaying the notification.
StringrtttlPlay the specified rtttl ringtone when displaying the notification. Set to null for no sound or when the sound parameter is set.
StringsoundPlay the specified sound file when displaying the notification. Set to null for no sound or when the rtttl parameter is set.
booleanloopSoundWhether the sound should be played in a loop until the notification is dismissed.
+ +## Full Example + +### Things + +```java +Bridge mqtt:broker:myBroker [ host="localhost", port=1883 ] +Bridge mqtt:awtrix-clock:myBroker:myAwtrix "Living Room Display" (mqtt:broker:myBroker) [ basetopic="awtrix", appLockTimeout=10, lowBatteryThreshold=25 ] { + Thing awtrix-app clock "Clock App" [ appname="clock", useButtons=true ] + Thing awtrix-app weather "Weather App" [ appname="weather" ] + Thing awtrix-app calendar "Calendar App" [ appname="calendar" ] + Thing awtrix-app custom "Custom App" [ appname="custom" ] +} +``` + +### Items + +```java +// Bridge items (Living Room Display) +Group gAwtrix "Living Room Awtrix Display" +Dimmer Display_Brightness "Brightness [%d %%]" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:brightness" } +Switch Display_Power "Power" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:power" } +Switch Display_Screen "Screen" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:display" } +Switch Display_Sound "Sound" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:sound" } +Switch Display_AutoBrightness "Auto Brightness" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:autoBrightness" } +Number:Temperature Display_Temperature "Temperature [%.1f °C]" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:temperature" } +Number:Dimensionless Display_Humidity "Humidity [%d %%]" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:humidity" } +Number Display_Battery "Battery Level [%d %%]" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:batteryLevel" } +Switch Display_LowBattery "Low Battery" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:lowBattery" } +Number:Dimensionless Display_WiFi "WiFi Signal [%d %%]" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:rssi" } +String Display_CurrentApp "Active App [%s]" (gAwtrix) { channel="mqtt:awtrix-clock:myBroker:myAwtrix:app" } + +// Clock App items +Group gAwtrixClock "Clock App" +Switch Clock_Active "Clock Active" (gAwtrixClock) { channel="mqtt:awtrix-app:myBroker:myAwtrix:clock:active" } +String Clock_Text "Clock Text" (gAwtrixClock) { channel="mqtt:awtrix-app:myBroker:myAwtrix:clock:text" } +Color Clock_Color "Clock Color" (gAwtrixClock) { channel="mqtt:awtrix-app:myBroker:myAwtrix:clock:color" } +Number Clock_Duration "Clock Duration" (gAwtrixClock) { channel="mqtt:awtrix-app:myBroker:myAwtrix:clock:duration" } + +// Weather App items +Group gAwtrixWeather "Weather App" +Switch Weather_Active "Weather Active" (gAwtrixWeather) { channel="mqtt:awtrix-app:myBroker:myAwtrix:weather:active" } +String Weather_Text "Weather Text" (gAwtrixWeather) { channel="mqtt:awtrix-app:myBroker:myAwtrix:weather:text" } +String Weather_Icon "Weather Icon" (gAwtrixWeather) { channel="mqtt:awtrix-app:myBroker:myAwtrix:weather:icon" } +Color Weather_Color "Weather Color" (gAwtrixWeather) { channel="mqtt:awtrix-app:myBroker:myAwtrix:weather:color" } +Switch Weather_Rainbow "Weather Rainbow Effect" (gAwtrixWeather) { channel="mqtt:awtrix-app:myBroker:myAwtrix:weather:rainbow" } + +// Custom App items with advanced features +Switch Custom_Active "Custom App Active" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:active" } +String Custom_Text "Custom Text" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:text" } +String Custom_Icon "Custom Icon" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:icon" } +Color Custom_Color "Text Color" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:color" } +Color Custom_Background "Background Color" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:background" } +Number:Dimensionless Custom_ScrollSpeed "Scroll Speed" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:scrollSpeed" } +Switch Custom_Center "Center Text" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:center" } +Number:Dimensionless Custom_Progress "Progress [%d %%]" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:progress" } +Color Custom_ProgressColor "Progress Color" (gAwtrix) { channel="mqtt:awtrix-app:myBroker:myAwtrix:custom:progressColor" } +``` + +### Sitemap + +```perl + +sitemap awtrix label="Awtrix Display" { + Frame label="Display Control" { + Switch item=Display_Power + Slider item=Display_Brightness + Switch item=Display_Screen + Switch item=Display_Sound + Switch item=Display_AutoBrightness + Text item=Display_Temperature + Text item=Display_Humidity + Text item=Display_Battery visibility=[Display_LowBattery==ON] + Text item=Display_WiFi + Text item=Display_CurrentApp + } + + Frame label="Clock App" { + Switch item=Clock_Active + Text item=Clock_Text + Colorpicker item=Clock_Color + Slider item=Clock_Duration + } + + Frame label="Weather App" { + Switch item=Weather_Active + Text item=Weather_Text + Text item=Weather_Icon + Colorpicker item=Weather_Color + Switch item=Weather_Rainbow + } + + Frame label="Custom App" { + Switch item=Custom_Active + Text item=Custom_Text + Text item=Custom_Icon + Colorpicker item=Custom_Color + Colorpicker item=Custom_Background + Slider item=Custom_ScrollSpeed + Switch item=Custom_Center + Slider item=Custom_Progress + Colorpicker item=Custom_ProgressColor + } +} + +``` + +### Actions + +The binding provides various actions that can be used in rules to control the Awtrix display. To use these actions, you need to import them in your rules. + +Rules DSL: + +```java +val awtrixActions = getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix") +``` + +JS Scripting: + +```java +var awtrixActions = actions.thingActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix"); +``` + +### Indicator Control + +Control the three indicator LEDs on the Awtrix display (JS Scripting): + +```java +// Blink indicator 1 in red for 1 second +awtrixActions.blinkIndicator(1, [255,0,0], 1000) + +// Fade indicator 2 to blue over 2 seconds +awtrixActions.fadeIndicator(2, [0,0,255], 2000) + +// Turn on indicator 3 in green +awtrixActions.activateIndicator(3, [0,255,0]) + +// Turn off indicator 1 +awtrixActions.deactivateIndicator(1) +``` + +### Device Control + +Control basic device functions: + +```java +// Reboot the device +awtrixActions.reboot() + +// Put device to sleep for 60 seconds +awtrixActions.sleep(60) + +// Perform firmware upgrade +awtrixActions.upgrade() +``` + +### Sound Control + +Play sounds and melodies: + +```java +// Play a predefined sound file (without extension) +awtrixActions.playSound("notification") + +// Play an RTTTL melody +awtrixActions.playRtttl("Indiana:d=4,o=5,b=250:e,8p,8f,8g,8p,1c6,8p.,d,8p,8e,1f,p.,g,8p,8a,8b,8p,1f6,p,a,8p,8b,2c6,2d6,2e6,e,8p,8f,8g,8p,1c6,p,d6,8p,8e6,1f.6,g,8p,8g,e.6,8p,d6,8p,8g,e.6,8p,d6,8p,8g,f.6,8p,e6,8p,8d6,2c6") +``` + +### Notifications + +Display notifications on the screen: + +```java +// Show simple notification with icon +awtrixActions.showNotification("Hello World", "alert") + +// Show custom notification with advanced options +val params = newHashMap( + 'text' -> 'Custom Message', + 'icon' -> 'warning', + 'color' -> [255,165,0], // Orange color + 'rainbow' -> true, + 'duration' -> 10 +) +awtrixActions.showCustomNotification( + params, // Notification parameters + false, // hold: Keep notification until manually cleared + true, // wakeUp: Wake up from screen saver + true, // stack: Add to notification stack + "Indiana:d=4,o=5,b=250:e,8p,8f,8g,8p,1c6,8p.,d,8p,8e,1f,p.,g,8p,8a,8b,8p,1f6,p,a,8p,8b,2c6,2d6,2e6,e,8p,8f,8g,8p,1c6,p,d6,8p,8e6,1f.6,g,8p,8g,e.6,8p,d6,8p,8g,e.6,8p,d6,8p,8g,f.6,8p,e6,8p,8d6,2c6", // RTTTL sound to play (not both sound and rtttl) + "alert", // Sound file to play (not both sound and rtttl) + false // loopSound: Loop the sound until manually stopped +) +``` + +#### Custom Notification Parameters + +The action method parameters: + +| Parameter | Type | Description | +|-------------|---------|------------------------------------------| +| `hold` | Boolean | Keep notification until manually cleared | +| `wakeUp` | Boolean | Wake up from screen saver | +| `stack` | Boolean | Add to notification stack | +| `rtttl` | String | RTTTL melody to play | +| `sound` | String | Sound file to play (without extension) | +| `loopSound` | Boolean | Loop the sound | +| `params` | Map | Notification parameters | + +The `showCustomNotification` action accepts all app channels as shown above as parameters in the params map. + +### Rule Examples + +Here are some example rules demonstrating various features: + +```java + +rule "Battery Status Indicator Demo" +when + Item Display_Battery changed +then + if (Display_Battery.state <= 20) { + // Show low battery warning with blinking red indicator + getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix").blinkIndicator(1, [255,0,0], 500) + getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix").showNotification("Low Battery!", "battery-alert") + } else if (Display_Battery.state <= 50) { + // Show yellow indicator for medium battery + getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix").setIndicator(1, [255,255,0]) + } else { + // Show green indicator for good battery + getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix").setIndicator(1, [0,255,0]) + } +end + +rule "Door Bell Demo" +when + Item Doorbell_Button changed to ON +then + // Play sound and show notification + getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix").playRtttl("doorbell:d=4,o=6,b=100:8e,8g,8e,8c") + + var params = newHashMap( + 'text' -> "Doorbell", + 'icon' -> "bell-ring", + 'color' -> [0,255,255], // Cyan color + 'pushIcon' -> "PUSHOUT", + 'center' -> true + ) + getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix").showCustomNotification( + params, false, true, true, "", "", false + ) +end + +rule "Progress Bar Demo" +when + Item Washing_Machine_Progress changed +then + var progress = (Washing_Machine_Progress.state as Number).intValue + + // Update custom app with progress bar + Custom_Text.sendCommand("Washing") + Custom_Icon.sendCommand("washing-machine") + Custom_Progress.sendCommand(progress) + Custom_ProgressColor.sendCommand("0,255,0") // Green progress bar + + if (progress == 100) { + // Play sound when done + getActions("mqtt.awtrixlight", "mqtt:awtrix-clock:myBroker:myAwtrix").playSound("complete") + } +end +``` + +These rules demonstrate: + +- Using indicators to show battery status +- Creating custom notifications with icons and colors +- Playing RTTTL melodies and sound files +- Displaying progress bars diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/pom.xml b/bundles/org.openhab.binding.mqtt.awtrixlight/pom.xml new file mode 100644 index 0000000000000..d70b6a59a8d5b --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/pom.xml @@ -0,0 +1,25 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 5.0.0-SNAPSHOT + + + org.openhab.binding.mqtt.awtrixlight + openHAB Add-ons :: Bundles :: MQTT Awtrix 3 + + + + org.openhab.addons.bundles + org.openhab.binding.mqtt + ${project.version} + provided + + + + diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/feature/feature.xml b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/feature/feature.xml new file mode 100644 index 0000000000000..b31dddabc0832 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/feature/feature.xml @@ -0,0 +1,12 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + openhab-transport-mqtt + mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.awtrixlight/${project.version} + + + diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AppConfigOptions.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AppConfigOptions.java new file mode 100644 index 0000000000000..eafb302e34d2f --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AppConfigOptions.java @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.awtrixlight.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AppConfigOptions} Holds the config for the app settings. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class AppConfigOptions { + public String appname = ""; + public boolean useButtons = false; +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AwtrixLightBindingConstants.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AwtrixLightBindingConstants.java new file mode 100644 index 0000000000000..1e945ae492f1a --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AwtrixLightBindingConstants.java @@ -0,0 +1,173 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.openhab.binding.mqtt.awtrixlight.internal; + +import static org.openhab.binding.mqtt.MqttBindingConstants.BINDING_ID; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ThingTypeUID; + +/** + * The {@link AwtrixLightBindingConstants} class defines common constants, which are + * used across the whole binding. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class AwtrixLightBindingConstants { + + // List of all Thing Type UIDs + public static final ThingTypeUID THING_TYPE_APP = new ThingTypeUID(BINDING_ID, "awtrix-app"); + public static final ThingTypeUID THING_TYPE_BRIDGE = new ThingTypeUID(BINDING_ID, "awtrix-clock"); + public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_APP, THING_TYPE_BRIDGE); + + // Matrix Size + public static final int SCREEN_HEIGHT = 8; + public static final int SCREEN_WIDTH = 32; + + // Clock Properties + public static final String PROP_APPID = "appid"; + public static final String PROP_APPLOCKTIMEOUT = "appLockTimeout"; + public static final String PROP_APPNAME = "appname"; + public static final String PROP_APP_CONTROLLABLE = "useButtons"; + public static final String PROP_BASETOPIC = "basetopic"; + public static final String PROP_DISCOVERDEFAULT = "discoverDefaultApps"; + public static final String PROP_FIRMWARE = "firmware"; + public static final String PROP_UNIQUEID = "uniqueId"; + public static final String PROP_VENDOR = "vendor"; + + // Clock Topics + public static final String TOPIC_BASE = "awtrix"; + public static final String TOPIC_BUTLEFT = "/stats/buttonLeft"; + public static final String TOPIC_BUTRIGHT = "/stats/buttonRight"; + public static final String TOPIC_BUTSELECT = "/stats/buttonSelect"; + public static final String TOPIC_INDICATOR1 = "/indicator1"; + public static final String TOPIC_INDICATOR2 = "/indicator2"; + public static final String TOPIC_INDICATOR3 = "/indicator3"; + public static final String TOPIC_NOTIFY = "/notify"; + public static final String TOPIC_POWER = "/power"; + public static final String TOPIC_REBOOT = "/reboot"; + public static final String TOPIC_RTTTL = "/rtttl"; + public static final String TOPIC_SCREEN = "/screen"; + public static final String TOPIC_SEND_SCREEN = "/sendscreen"; + public static final String TOPIC_SETTINGS = "/settings"; + public static final String TOPIC_SLEEP = "/sleep"; + public static final String TOPIC_SOUND = "/sound"; + public static final String TOPIC_STATS = "/stats"; + public static final String TOPIC_STATS_CURRENT_APP = "/stats/currentApp"; + public static final String TOPIC_SWITCH = "/switch"; + public static final String TOPIC_UPGRADE = "/doupdate"; + + // Stats fields + public static final String FIELD_BRIDGE_APP = "app"; + public static final String FIELD_BRIDGE_BATTERY = "bat"; + public static final String FIELD_BRIDGE_BATTERY_RAW = "bat_raw"; + public static final String FIELD_BRIDGE_BRIGHTNESS = "bri"; + public static final String FIELD_BRIDGE_FIRMWARE = "version"; + public static final String FIELD_BRIDGE_HUMIDITY = "hum"; + public static final String FIELD_BRIDGE_INDICATOR1 = "indicator1"; + public static final String FIELD_BRIDGE_INDICATOR2 = "indicator2"; + public static final String FIELD_BRIDGE_INDICATOR3 = "indicator3"; + public static final String FIELD_BRIDGE_INDICATOR1_COLOR = "indicator1-color"; + public static final String FIELD_BRIDGE_INDICATOR2_COLOR = "indicator2-color"; + public static final String FIELD_BRIDGE_INDICATOR3_COLOR = "indicator3-color"; + public static final String FIELD_BRIDGE_LDR_RAW = "ldr_raw"; + public static final String FIELD_BRIDGE_LUX = "lux"; + public static final String FIELD_BRIDGE_MATRIX = "matrix"; + public static final String FIELD_BRIDGE_MESSAGES = "messages"; + public static final String FIELD_BRIDGE_RAM = "ram"; + public static final String FIELD_BRIDGE_TEMPERATURE = "temp"; + public static final String FIELD_BRIDGE_TYPE = "type"; + public static final String FIELD_BRIDGE_UID = "uid"; + public static final String FIELD_BRIDGE_UPTIME = "uptime"; + public static final String FIELD_BRIDGE_WIFI_SIGNAL = "wifi_signal"; + + // Settings fields + public static final String FIELD_BRIDGE_SET_APP_TIME = "ATIME"; + public static final String FIELD_BRIDGE_SET_AUTO_BRIGHTNESS = "ABRI"; + public static final String FIELD_BRIDGE_SET_AUTO_TRANSITION = "ATRANS"; + public static final String FIELD_BRIDGE_SET_BLOCK_KEYS = "BLOCKN"; + public static final String FIELD_BRIDGE_SET_BRIGHTNESS = "BRI"; + public static final String FIELD_BRIDGE_SET_DISPLAY = "MATP"; + public static final String FIELD_BRIDGE_SET_MUTE = "SOUND"; + public static final String FIELD_BRIDGE_SET_SCROLL_SPEED = "SSPEED"; + public static final String FIELD_BRIDGE_SET_TEXT_COLOR = "TCOL"; + public static final String FIELD_BRIDGE_SET_TRANS_EFFECT = "TEFF"; + public static final String FIELD_BRIDGE_SET_TRANS_SPEED = "TSPEED"; + + // Apps + public static final String BASE_APP_TOPIC = "/custom"; + public static final String[] DEFAULT_APPS = { "Time", "Date", "Temperature", "Humidity", "Battery" }; + + // Common Channels + public static final String CHANNEL_BUTLEFT = "button-left"; + public static final String CHANNEL_BUTRIGHT = "button-right"; + public static final String CHANNEL_BUTSELECT = "button-select"; + + // Clock Channels + public static final String CHANNEL_APP = "app"; + public static final String CHANNEL_AUTO_BRIGHTNESS = "auto-brightness"; + public static final String CHANNEL_BATTERY = "battery-level"; + public static final String CHANNEL_BRIGHTNESS = "brightness"; + public static final String CHANNEL_DISPLAY = "display"; + public static final String CHANNEL_HUMIDITY = "humidity"; + public static final String CHANNEL_INDICATOR1 = "indicator-1"; + public static final String CHANNEL_INDICATOR2 = "indicator-2"; + public static final String CHANNEL_INDICATOR3 = "indicator-3"; + public static final String CHANNEL_LOW_BATTERY = "low-battery"; + public static final String CHANNEL_LUX = "lux"; + public static final String CHANNEL_RSSI = "rssi"; + public static final String CHANNEL_RTTTL = "rtttl"; + public static final String CHANNEL_SCREEN = "screen"; + public static final String CHANNEL_SOUND = "sound"; + public static final String CHANNEL_TEMPERATURE = "temperature"; + + // App Channels + public static final String CHANNEL_ACTIVE = "active"; + public static final String CHANNEL_AUTOSCALE = "autoscale"; + public static final String CHANNEL_BACKGROUND = "background"; + public static final String CHANNEL_BAR = "bar"; + public static final String CHANNEL_BLINK_TEXT = "blink-text"; + public static final String CHANNEL_CENTER = "center"; + public static final String CHANNEL_COLOR = "color"; + public static final String CHANNEL_DURATION = "duration"; + public static final String CHANNEL_EFFECT = "effect"; + public static final String CHANNEL_EFFECT_BLEND = "effect-blend"; + public static final String CHANNEL_EFFECT_PALETTE = "effect-palette"; + public static final String CHANNEL_EFFECT_SPEED = "effect-speed"; + public static final String CHANNEL_FADE_TEXT = "fade-text"; + public static final String CHANNEL_GRADIENT_COLOR = "gradient-color"; + public static final String CHANNEL_ICON = "icon"; + public static final String CHANNEL_LIFETIME = "lifetime"; + public static final String CHANNEL_LIFETIME_MODE = "lifetime-mode"; + public static final String CHANNEL_LINE = "line"; + public static final String CHANNEL_OVERLAY = "overlay"; + public static final String CHANNEL_PROGRESS = "progress"; + public static final String CHANNEL_PROGRESSC = "progress-color"; + public static final String CHANNEL_PROGRESSBC = "progress-background"; + public static final String CHANNEL_PUSH_ICON = "push-icon"; + public static final String CHANNEL_RAINBOW = "rainbow"; + public static final String CHANNEL_RESET = "reset"; + public static final String CHANNEL_SCROLLSPEED = "scroll-speed"; + public static final String CHANNEL_TEXT = "text"; + public static final String CHANNEL_TEXTCASE = "text-case"; + public static final String CHANNEL_TEXT_OFFSET = "text-offset"; + public static final String CHANNEL_TOP_TEXT = "top-text"; + + public static final String PUSH_ICON_OPTION_0 = "STATIC"; + public static final String PUSH_ICON_OPTION_1 = "PUSHOUT"; + public static final String PUSH_ICON_OPTION_2 = "PUSHOUTRETURN"; +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AwtrixLightHandlerFactory.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AwtrixLightHandlerFactory.java new file mode 100644 index 0000000000000..84713a67ea1a6 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/AwtrixLightHandlerFactory.java @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.openhab.binding.mqtt.awtrixlight.internal; + +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.SUPPORTED_THING_TYPES; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.handler.AwtrixLightAppHandler; +import org.openhab.binding.mqtt.awtrixlight.internal.handler.AwtrixLightBridgeHandler; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingRegistry; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandlerFactory; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerFactory; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +/** + * The {@link AwtrixLightHandlerFactory} is responsible for creating things and thing + * handlers. + * + * @author Thomas Lauterbach - Initial contribution + */ +@Component(service = ThingHandlerFactory.class) +@NonNullByDefault +public class AwtrixLightHandlerFactory extends BaseThingHandlerFactory { + + @Activate + public AwtrixLightHandlerFactory(final @Reference ThingRegistry thingRegistry) { + } + + @Override + public boolean supportsThingType(ThingTypeUID thingTypeUID) { + return SUPPORTED_THING_TYPES.contains(thingTypeUID); + } + + @Override + protected @Nullable ThingHandler createHandler(Thing thing) { + ThingTypeUID thingTypeUID = thing.getThingTypeUID(); + if (AwtrixLightBridgeHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) { + return new AwtrixLightBridgeHandler((Bridge) thing); + } else if (AwtrixLightAppHandler.SUPPORTED_THING_TYPES.contains(thingTypeUID)) { + return new AwtrixLightAppHandler((Thing) thing); + } + + return null; + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/BridgeConfigOptions.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/BridgeConfigOptions.java new file mode 100644 index 0000000000000..7bb764d446bff --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/BridgeConfigOptions.java @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.awtrixlight.internal; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link BridgeConfigOptions} Holds the config for the bridge settings. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class BridgeConfigOptions { + public String basetopic = "awtrix"; + public int appLockTimeout = 10; + public boolean discoverDefaultApps = false; + public int lowBatteryThreshold = 25; +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/Helper.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/Helper.java new file mode 100644 index 0000000000000..31ded6a09f8fe --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/Helper.java @@ -0,0 +1,121 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.awtrixlight.internal; + +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.SCREEN_HEIGHT; +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.SCREEN_WIDTH; + +import java.awt.image.BufferedImage; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import javax.imageio.ImageIO; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.app.AwtrixApp; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; + +/** + * The {@link Helper} Various helper methods used througout the binding + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class Helper { + + private static final Logger LOGGER = LoggerFactory.getLogger(Helper.class); + + private static final Gson GSON = new GsonBuilder().create(); + + public static Map decodeStatsJson(String statsJson) { + Map stats = GSON.fromJson(statsJson, new TypeToken>() { + }.getType()); + return stats != null ? stats : new HashMap(); + } + + public static AwtrixApp decodeAppJson(String appJson) { + @Nullable + AwtrixApp app = GSON.fromJson(appJson, AwtrixApp.class); + return app != null ? app : new AwtrixApp(); + } + + public static String encodeJson(Map params) { + return GSON.toJson(params); + } + + public static byte[] decodeImage(String messageJSON) { + String cutBrackets = messageJSON.substring(1, messageJSON.length() - 1); + + String[] pixelStrings = cutBrackets.split(","); + int[] values = Arrays.stream(pixelStrings).mapToInt(Integer::parseInt).toArray(); + int[][] pixels = new int[SCREEN_HEIGHT][SCREEN_WIDTH]; + for (int i = 0; i < 256; i++) { + pixels[i / 32][i % 32] = values[i]; + } + + // Resize and add gaps between pixels + int factor = 10; + int resizedHeight = SCREEN_HEIGHT * factor + (SCREEN_HEIGHT - 1); + int resizedWidth = SCREEN_WIDTH * factor + (SCREEN_WIDTH - 1); + int[][] resizedPixels = new int[resizedHeight][resizedWidth]; + for (int y = 0; y < SCREEN_HEIGHT; y++) { + for (int x = 0; x < SCREEN_WIDTH; x++) { + int yOffset = y * factor + y; + int xOffset = x * factor + x; + for (int y2 = 0; y2 < factor; y2++) { + for (int x2 = 0; x2 < factor; x2++) { + resizedPixels[yOffset + y2][xOffset + x2] = pixels[y][x]; + } + } + } + } + + BufferedImage image = new BufferedImage(resizedWidth, resizedHeight, BufferedImage.TYPE_INT_RGB); + for (int y = 0; y < resizedHeight; y++) { + for (int x = 0; x < resizedWidth; x++) { + int rgb = resizedPixels[y][x]; + image.setRGB(x, y, rgb); + } + } + + byte[] bytes = new byte[256]; + try { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ImageIO.write(image, "png", baos); + bytes = baos.toByteArray(); + } catch (IOException e) { + LOGGER.warn("Failed to decode image", e); + } + return bytes == null ? new byte[0] : bytes; + } + + public static int[] leftTrim(int[] data, int length) { + if (length < data.length) { + int[] trimmed = new int[length]; + for (int i = data.length - length; i < data.length; i++) { + trimmed[i - (data.length - length)] = data[i]; + } + return trimmed; + } + return data; + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/action/AwtrixActions.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/action/AwtrixActions.java new file mode 100644 index 0000000000000..efba9b8717878 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/action/AwtrixActions.java @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.openhab.binding.mqtt.awtrixlight.internal.action; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.handler.AwtrixLightBridgeHandler; +import org.openhab.core.automation.annotation.RuleAction; +import org.openhab.core.thing.binding.ThingActions; +import org.openhab.core.thing.binding.ThingActionsScope; +import org.openhab.core.thing.binding.ThingHandler; + +/** + * Actions for the Awtrix clock. + * + * @author Thomas Lauterbach - Initial contribution + */ +@ThingActionsScope(name = "mqtt.awtrixlight") +@NonNullByDefault +public class AwtrixActions implements ThingActions { + + private @Nullable AwtrixLightBridgeHandler handler; + + @Override + public void setThingHandler(ThingHandler handler) { + this.handler = (AwtrixLightBridgeHandler) handler; + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return handler; + } + + @RuleAction(label = "Blink Indicator", description = "Blink indicator with indicatorId") + public void blinkIndicator(int indicatorId, int[] rgb, int blinkTimeInMs) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.blinkIndicator(indicatorId, rgb, blinkTimeInMs); + } + } + + public static void blinkIndicator(@Nullable ThingActions actions, int indicatorId, int[] rgb, int blinkTimeInMs) { + if (actions instanceof AwtrixActions awtrixActions) { + awtrixActions.blinkIndicator(indicatorId, rgb, blinkTimeInMs); + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Fade Indicator", description = "Fade indicator with indicatorId") + public void fadeIndicator(int indicatorId, int[] rgb, int fadeTimeInMs) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.fadeIndicator(indicatorId, rgb, fadeTimeInMs); + } + } + + public static void fadeIndicator(@Nullable ThingActions actions, int indicatorId, int[] rgb, int fadeTimeInMs) { + if (actions instanceof AwtrixActions awtrixActions) { + awtrixActions.fadeIndicator(indicatorId, rgb, fadeTimeInMs); + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Activate Indicator", description = "Turn on indicator with indicatorId") + public void activateIndicator(int indicatorId, int[] rgb) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.activateIndicator(indicatorId, rgb); + } + } + + public static void activateIndicator(@Nullable ThingActions actions, int indicatorId, int[] rgb) { + if (actions instanceof AwtrixActions awtrixActions) { + awtrixActions.activateIndicator(indicatorId, rgb); + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Deactivate Indicator", description = "Turn off indicator with indicatorId") + public void deactivateIndicator(int indicatorId) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.deactivateIndicator(indicatorId); + } + } + + public static void deactivateIndicator(@Nullable ThingActions actions, int indicatorId) { + if (actions instanceof AwtrixActions awtrixActions) { + awtrixActions.deactivateIndicator(indicatorId); + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Reboot", description = "Reboots the device") + public void reboot() { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.reboot(); + } + } + + public static void reboot(@Nullable ThingActions actions) { + if (actions instanceof AwtrixActions awtrixActions) { + awtrixActions.reboot(); + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Sleep", description = "Send device to deep sleep") + public void sleep(int seconds) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.sleep(seconds); + } + } + + public static void sleep(@Nullable ThingActions actions, int seconds) { + if (actions instanceof AwtrixActions awtrixActions) { + awtrixActions.sleep(seconds); + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Upgrade", description = "Performs firmware upgrade") + public void upgrade() { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.upgrade(); + } + } + + public static void upgrade(@Nullable ThingActions actions) { + if (actions instanceof AwtrixActions awtrixActions) { + awtrixActions.upgrade(); + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Play Sound", description = "Plays the sound file with given name (without extension) if it exists") + public void playSound(String melody) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.playSound(melody); + } + } + + public static void playSound(@Nullable ThingActions actions, @Nullable String melody) { + if (actions instanceof AwtrixActions awtrixActions) { + if (melody != null) { + awtrixActions.playSound(melody); + } + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Play RTTTL", description = "Plays the melody provided in RTTTL format") + public void playRtttl(String rtttl) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.playRtttl(rtttl); + } + } + + public static void playRtttl(@Nullable ThingActions actions, @Nullable String rtttl) { + if (actions instanceof AwtrixActions awtrixActions) { + if (rtttl != null) { + awtrixActions.playRtttl(rtttl); + } + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Show Notification", description = "Shows a default notification with an icon") + public void showNotification(String message, String icon) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + Map params = new HashMap(); + params.put("text", message); + params.put("icon", icon); + localHandler.showNotification(false, false, true, "", "", false, params); + } + } + + public static void showNotification(@Nullable ThingActions actions, @Nullable String message, + @Nullable String icon) { + if (actions instanceof AwtrixActions awtrixActions) { + if (message != null && icon != null) { + awtrixActions.showNotification(message, icon); + } + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } + + @RuleAction(label = "Show Custom Notification", description = "Shows a notification with specified options") + public void showCustomNotification(Map appParams, boolean hold, boolean wakeUp, boolean stack, + @Nullable String rtttl, @Nullable String sound, boolean loopSound) { + AwtrixLightBridgeHandler localHandler = this.handler; + if (localHandler != null) { + localHandler.showNotification(hold, wakeUp, stack, rtttl, sound, loopSound, appParams); + } + } + + public static void showCustomNotification(@Nullable ThingActions actions, @Nullable Map appParams, + boolean hold, boolean wakeUp, boolean stack, @Nullable String rtttl, @Nullable String sound, + boolean loopSound) { + if (actions instanceof AwtrixActions awtrixActions) { + if (appParams != null) { + awtrixActions.showCustomNotification(appParams, hold, wakeUp, stack, rtttl, sound, loopSound); + } + } else { + throw new IllegalArgumentException("Instance is not an AwtrixActions class."); + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/app/AwtrixApp.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/app/AwtrixApp.java new file mode 100644 index 0000000000000..59daff54fd660 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/app/AwtrixApp.java @@ -0,0 +1,601 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.awtrixlight.internal.app; + +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.*; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.Helper; + +/** + * The {@link AwtrixApp} is the representation of the current app configuration and provides a method to create a config + * string for the clock. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class AwtrixApp { + + public static final String DEFAULT_TEXT = "New Awtrix App"; + public static final int DEFAULT_TEXTCASE = 0; + public static final boolean DEFAULT_TOPTEXT = false; + public static final int DEFAULT_TEXTOFFSET = 0; + public static final boolean DEFAULT_CENTER = true; + public static final int[] DEFAULT_COLOR = { 255, 255, 255 }; + public static final int[][] DEFAULT_GRADIENT = {}; + public static final int DEFAULT_BLINKTEXT = 0; + public static final int DEFAULT_FADETEXT = 0; + public static final int[] DEFAULT_BACKGROUND = { 0, 0, 0 }; + public static final boolean DEFAULT_RAINBOW = false; + public static final String DEFAULT_ICON = "None"; + public static final int DEFAULT_PUSHICON = 0; + public static final int DEFAULT_DURATION = 7; + public static final int[] DEFAULT_LINE = {}; + public static final int DEFAULT_LIFETIME = 0; + public static final int DEFAULT_LIFETIME_MODE = 0; + public static final int[] DEFAULT_BAR = {}; + public static final boolean DEFAULT_AUTOSCALE = true; + public static final String DEFAULT_OVERLAY = "Clear"; + public static final int DEFAULT_PROGRESS = -1; + public static final int[] DEFAULT_PROGRESSC = { 0, 255, 0 }; + public static final int[] DEFAULT_PROGRESSBC = { 255, 255, 255 }; + public static final int DEFAULT_SCROLLSPEED = 100; + public static final String DEFAULT_EFFECT = "None"; + public static final int DEFAULT_EFFECTSPEED = 100; + public static final String DEFAULT_EFFECTPALETTE = "None"; + public static final boolean DEFAULT_EFFECTBLEND = true; + + private String text = DEFAULT_TEXT; + private int textCase = DEFAULT_TEXTCASE; + private boolean topText = DEFAULT_TOPTEXT; + private int textOffset = DEFAULT_TEXTOFFSET; + private boolean center = DEFAULT_CENTER; + private int[] color = DEFAULT_COLOR; + private int[][] gradient = DEFAULT_GRADIENT; + private int blinkText = DEFAULT_BLINKTEXT; + private int fadeText = DEFAULT_FADETEXT; + private int[] background = DEFAULT_BACKGROUND; + private boolean rainbow = DEFAULT_RAINBOW; + private String icon = DEFAULT_ICON; + private int pushIcon = DEFAULT_PUSHICON; + private int duration = DEFAULT_DURATION; + private int[] line = DEFAULT_LINE; + private int lifetime = DEFAULT_LIFETIME; + private int lifetimeMode = DEFAULT_LIFETIME_MODE; + private int[] bar = DEFAULT_BAR; + private boolean autoscale = DEFAULT_AUTOSCALE; + private String overlay = DEFAULT_OVERLAY; + private int progress = DEFAULT_PROGRESS; + private int[] progressC = DEFAULT_PROGRESSC; + private int[] progressBC = DEFAULT_PROGRESSBC; + private int scrollSpeed = DEFAULT_SCROLLSPEED; + private String effect = DEFAULT_EFFECT; + + // effectSettings properties + private Map effectSettings; + + public AwtrixApp() { + this.effectSettings = new HashMap(); + this.effectSettings.put("speed", DEFAULT_EFFECTSPEED); + this.effectSettings.put("palette", DEFAULT_EFFECTPALETTE); + this.effectSettings.put("blend", DEFAULT_EFFECTBLEND); + } + + public void updateFields(Map params) { + this.text = getStringValue(params, CHANNEL_TEXT, DEFAULT_TEXT); + this.textCase = getNumberValue(params, CHANNEL_TEXTCASE, DEFAULT_TEXTCASE); + this.topText = getBoolValue(params, CHANNEL_TOP_TEXT, DEFAULT_TOPTEXT); + this.textOffset = getNumberValue(params, CHANNEL_TEXT_OFFSET, DEFAULT_TEXTOFFSET); + this.center = getBoolValue(params, CHANNEL_CENTER, DEFAULT_CENTER); + this.color = getNumberArrayValue(params, CHANNEL_COLOR, DEFAULT_COLOR); + this.gradient = getGradientValue(params, DEFAULT_GRADIENT); + this.blinkText = getNumberValue(params, CHANNEL_BLINK_TEXT, DEFAULT_BLINKTEXT); + this.fadeText = getNumberValue(params, CHANNEL_FADE_TEXT, DEFAULT_FADETEXT); + this.background = getNumberArrayValue(params, CHANNEL_BACKGROUND, DEFAULT_BACKGROUND); + this.rainbow = getBoolValue(params, CHANNEL_RAINBOW, DEFAULT_RAINBOW); + this.icon = getStringValue(params, CHANNEL_ICON, DEFAULT_ICON); + this.pushIcon = getNumberValue(params, CHANNEL_PUSH_ICON, DEFAULT_PUSHICON); + this.duration = getNumberValue(params, CHANNEL_DURATION, DEFAULT_DURATION); + this.line = getNumberArrayValue(params, CHANNEL_LINE, DEFAULT_LINE); + this.lifetime = getNumberValue(params, CHANNEL_LIFETIME, DEFAULT_LIFETIME); + this.lifetimeMode = getNumberValue(params, CHANNEL_LIFETIME_MODE, DEFAULT_LIFETIME_MODE); + this.bar = getNumberArrayValue(params, CHANNEL_BAR, DEFAULT_BAR); + this.autoscale = getBoolValue(params, CHANNEL_AUTOSCALE, DEFAULT_AUTOSCALE); + this.overlay = getStringValue(params, CHANNEL_OVERLAY, DEFAULT_OVERLAY); + this.progress = getNumberValue(params, CHANNEL_PROGRESS, DEFAULT_PROGRESS); + this.progressC = getNumberArrayValue(params, CHANNEL_PROGRESSC, DEFAULT_PROGRESSC); + this.progressBC = getNumberArrayValue(params, CHANNEL_PROGRESSBC, DEFAULT_PROGRESSBC); + this.scrollSpeed = getNumberValue(params, CHANNEL_SCROLLSPEED, DEFAULT_SCROLLSPEED); + this.effect = getStringValue(params, CHANNEL_EFFECT, DEFAULT_EFFECT); + + Map effectSettings = new HashMap(); + effectSettings.put("speed", getNumberValue(params, CHANNEL_EFFECT_SPEED, DEFAULT_EFFECTSPEED)); + effectSettings.put("palette", getStringValue(params, CHANNEL_EFFECT_PALETTE, DEFAULT_EFFECTPALETTE)); + effectSettings.put("blend", getBoolValue(params, CHANNEL_EFFECT_BLEND, DEFAULT_EFFECTBLEND)); + this.effectSettings = effectSettings; + } + + public String getAppConfig() { + Map fields = getAppParams(); + return Helper.encodeJson(fields); + } + + public String getText() { + return this.text; + } + + public void setText(String text) { + this.text = text; + } + + public int getTextCase() { + return this.textCase; + } + + public void setTextCase(int textCase) { + this.textCase = textCase; + } + + public boolean getTopText() { + return this.topText; + } + + public void setTopText(boolean topText) { + this.topText = topText; + } + + public int getTextOffset() { + return this.textOffset; + } + + public void setTextOffset(int textOffset) { + this.textOffset = textOffset; + } + + public Boolean getCenter() { + return this.center; + } + + public void setCenter(Boolean center) { + this.center = center; + } + + public int[] getColor() { + return this.color; + } + + public void setColor(int[] color) { + this.color = color; + } + + public int[][] getGradient() { + return this.gradient; + } + + public void setGradient(int[][] gradient) { + this.gradient = gradient; + } + + public int getBlinkText() { + return this.blinkText; + } + + public void setBlinkText(int blinkText) { + this.blinkText = blinkText; + } + + public int getFadeText() { + return this.fadeText; + } + + public void setFadeText(int fadeText) { + this.fadeText = fadeText; + } + + public int[] getBackground() { + return this.background; + } + + public void setBackground(int[] background) { + this.background = background; + } + + public Boolean getRainbow() { + return this.rainbow; + } + + public void setRainbow(Boolean rainbow) { + this.rainbow = rainbow; + } + + public String getIcon() { + return this.icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public int getPushIcon() { + return this.pushIcon; + } + + public void setPushIcon(int pushIcon) { + this.pushIcon = pushIcon; + } + + public int getDuration() { + return this.duration; + } + + public void setDuration(int duration) { + this.duration = duration; + } + + public int[] getLine() { + return this.line; + } + + public void setLine(int[] line) { + this.line = line; + } + + public int getLifetime() { + return this.lifetime; + } + + public void setLifetime(int lifetime) { + this.lifetime = lifetime; + } + + public int getLifetimeMode() { + return this.lifetimeMode; + } + + public void setLifetimeMode(int lifetimeMode) { + this.lifetimeMode = lifetimeMode; + } + + public int[] getBar() { + return this.bar; + } + + public void setBar(int[] bar) { + this.bar = bar; + } + + public Boolean getAutoscale() { + return this.autoscale; + } + + public void setAutoscale(Boolean autoscale) { + this.autoscale = autoscale; + } + + public String getOverlay() { + return this.overlay; + } + + public void setOverlay(String overlay) { + this.overlay = overlay; + } + + public int getProgress() { + return this.progress; + } + + public void setProgress(int progress) { + this.progress = progress; + } + + public int[] getProgressC() { + return this.progressC; + } + + public void setProgressC(int[] progressC) { + this.progressC = progressC; + } + + public int[] getProgressBC() { + return this.progressBC; + } + + public void setProgressBC(int[] progressBC) { + this.progressBC = progressBC; + } + + public int getScrollSpeed() { + return this.scrollSpeed; + } + + public void setScrollSpeed(int scrollSpeed) { + this.scrollSpeed = scrollSpeed; + } + + public String getEffect() { + return this.effect; + } + + public void setEffect(String effect) { + this.effect = effect; + } + + public Map getEffectSettings() { + return this.effectSettings; + } + + public void setEffectSettings(Map effectSettings) { + this.effectSettings = effectSettings; + } + + protected String propertiesAsString() { + return "text=" + text + ", textCase=" + textCase + ", topText=" + topText + ", textOffset=" + textOffset + + ", center=" + center + ", color=" + Arrays.toString(color) + ", gradient=[" + + Arrays.stream(gradient).map(color -> Arrays.toString(color)).collect(Collectors.joining(", ")) + + "], blinkText=" + blinkText + ", fadeText=" + fadeText + ", background=" + Arrays.toString(background) + + ", rainbow=" + rainbow + ", icon=" + icon + ", pushIcon=" + pushIcon + ", duration=" + duration + + ", line=" + Arrays.toString(line) + ", lifetime=" + lifetime + ", lifetimeMode=" + lifetimeMode + + ", bar=" + Arrays.toString(bar) + ", autoscale=" + autoscale + ", overlay=" + overlay + ", progress=" + + progress + ", progressC=" + Arrays.toString(progressC) + ", progressBC=" + Arrays.toString(progressBC) + + ", scrollSpeed=" + scrollSpeed + ", effect=" + effect + ", effectSpeed=" + getEffectSpeed() + + ", effectPalette=" + effectSettings.get("palette") + ", effectBlend=" + effectSettings.get("blend"); + } + + @Override + public String toString() { + return "AwtrixApp [" + propertiesAsString() + "]"; + } + + public Map getAppParams() { + Map fields = new HashMap(); + fields.put("text", this.text); + fields.put("textCase", this.textCase); + fields.put("topText", this.topText); + fields.put("textOffset", this.textOffset); + fields.put("center", this.center); + fields.put("lifetime", this.lifetime); + fields.put("lifetimeMode", this.lifetimeMode); + fields.put("overlay", this.overlay); + fields.putAll(getColorConfig()); + fields.putAll(getTextEffectConfig()); + fields.putAll(getBackgroundConfig()); + fields.putAll(getIconConfig()); + fields.put("duration", this.duration); + fields.putAll(getGraphConfig()); + fields.putAll(getProgressConfig()); + if (this.scrollSpeed == 0) { + fields.put("noScroll", true); + } else { + fields.put("scrollSpeed", this.scrollSpeed); + } + fields.putAll(getEffectConfig()); + return fields; + } + + private boolean getBoolValue(Map params, String key, boolean defaultValue) { + if (params.containsKey(key)) { + @Nullable + Object value = params.get(key); + if (value instanceof Boolean boolValue) { + return boolValue; + } + } + return defaultValue; + } + + private int getNumberValue(Map params, String key, int defaultValue) { + if (params.containsKey(key)) { + @Nullable + Object value = params.get(key); + if (value instanceof Integer intValue) { + return intValue; + } + } + return defaultValue; + } + + private int[] getNumberArrayValue(Map params, String key, int[] defaultValue) { + if (params.containsKey(key)) { + @Nullable + Object value = params.get(key); + if (value instanceof int[] intArray) { + return intArray; + } + } + return defaultValue; + } + + private int[][] getGradientValue(Map params, int[][] defaultValue) { + if (params.containsKey(CHANNEL_GRADIENT_COLOR)) { + @Nullable + Object gradientParam = params.get(CHANNEL_GRADIENT_COLOR); + if (gradientParam instanceof int[] gradient) { + @Nullable + Object colorParam = params.get(CHANNEL_COLOR); + if (colorParam instanceof int[] color) { + return new int[][] { color, gradient }; + } else { + return new int[][] { DEFAULT_COLOR, gradient }; + } + } + } + return defaultValue; + } + + private String getStringValue(Map params, String key, String defaultValue) { + if (params.containsKey(key)) { + @Nullable + Object value = params.get(key); + if (value instanceof String stringValue) { + return stringValue; + } + } + return defaultValue; + } + + private Map getColorConfig() { + Map fields = new HashMap(); + // When we don't have a valid gradient array, we just provide a color if available + if (this.gradient.length != 2) { + if (this.color.length == 3) { + fields.put("color", this.color); + } + } else { + // Here we have a gradient array. Use it unless it's not a valid gradient + if (this.gradient[0] != null && this.gradient[0].length == 3 && this.gradient[1] != null + && this.gradient[1].length == 3) { + fields.put("gradient", this.gradient); + } else { + // If we don't have a valid gradient, we try to provide any color we find + if (this.color.length == 3) { + fields.put("color", this.color); + } else if (this.gradient[0] != null && this.gradient[0].length == 3) { + fields.put("color", this.gradient); + } else if (this.gradient[1] != null && this.gradient[1].length == 3) { + fields.put("color", this.gradient); + } + } + } + return fields; + } + + private Map getTextEffectConfig() { + Map fields = new HashMap(); + if (Arrays.equals(this.color, DEFAULT_COLOR) && Arrays.equals(this.gradient, DEFAULT_GRADIENT)) { + if (this.blinkText > 0) { + fields.put("blinkText", this.blinkText); + } else if (this.fadeText > 0) { + fields.put("fadeText", this.fadeText); + } else if (this.rainbow) { + fields.put("rainbow", this.rainbow); + } + } + return fields; + } + + private Map getBackgroundConfig() { + Map fields = new HashMap(); + if (this.background.length == 3) { + fields.put("background", this.background); + } + return fields; + } + + private Map getIconConfig() { + Map fields = new HashMap(); + if (!"None".equals(this.icon)) { + fields.put("icon", this.icon); + fields.put("pushIcon", this.pushIcon); + } + return fields; + } + + private Map getGraphConfig() { + Map fields = new HashMap(); + String graphType = null; + int[] data = null; + if (this.bar.length > 0) { + graphType = "bar"; + if ("None".equals(this.icon)) { + data = Helper.leftTrim(this.bar, 16); + } else { + data = Helper.leftTrim(this.bar, 11); + } + } else if (this.line.length > 0) { + graphType = "line"; + if ("None".equals(this.icon)) { + data = Helper.leftTrim(this.line, 16); + } else { + data = Helper.leftTrim(this.line, 11); + } + } + if (graphType != null && data != null) { + fields.put(graphType, data); + fields.put("autoscale", this.autoscale); + } + return fields; + } + + private Map getProgressConfig() { + Map fields = new HashMap(); + if (progress > -1 && progress <= 100) { + fields.put("progress", this.progress); + fields.put("progressC", this.progressC); + fields.put("progressBC", this.progressBC); + } + return fields; + } + + private Map getEffectConfig() { + Map fields = new HashMap(); + Map effectSettings = new HashMap(); + fields.put("effect", this.effect); + if (!"None".equals(this.effect)) { + if (getEffectSpeed() > -1) { + effectSettings.put("speed", getEffectSpeed()); + } + effectSettings.put("palette", getEffectPalette()); + effectSettings.put("blend", getEffectBlend()); + fields.put("effectSettings", effectSettings); + } + return fields; + } + + public int getEffectSpeed() { + @Nullable + Object effectSpeed = this.effectSettings.get("speed"); + if (effectSpeed instanceof Number numberValue) { + return numberValue.intValue(); + } else { + return DEFAULT_EFFECTSPEED; + } + } + + public void setEffectSpeed(int effectSpeed) { + this.effectSettings.put("speed", effectSpeed); + } + + public String getEffectPalette() { + @Nullable + Object effectPalette = this.effectSettings.get("palette"); + if (effectPalette instanceof String stringValue) { + return stringValue; + } else { + return DEFAULT_EFFECTPALETTE; + } + } + + public void setEffectPalette(String effectPalette) { + this.effectSettings.put("palette", effectPalette); + } + + public Boolean getEffectBlend() { + @Nullable + Object effectBlend = this.effectSettings.get("blend"); + if (effectBlend instanceof Boolean boolValue) { + return boolValue; + } else { + return DEFAULT_EFFECTBLEND; + } + } + + public void setEffectBlend(Boolean effectBlend) { + this.effectSettings.put("blend", effectBlend); + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/app/AwtrixNotification.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/app/AwtrixNotification.java new file mode 100644 index 0000000000000..1fb9aaf1f229d --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/app/AwtrixNotification.java @@ -0,0 +1,127 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.awtrixlight.internal.app; + +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.binding.mqtt.awtrixlight.internal.Helper; + +/** + * The {@link AwtrixNotification} is the representation of a notification configuration and provides a method to create + * a config + * string for the clock. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class AwtrixNotification extends AwtrixApp { + + public static final boolean DEFAULT_HOLD = false; + public static final boolean DEFAULT_WAKEUP = false; + public static final boolean DEFAULT_STACK = true; + public static final String DEFAULT_RTTTL = ""; + public static final String DEFAULT_SOUND = ""; + public static final boolean DEFAULT_SOUND_LOOP = false; + + private boolean hold = DEFAULT_HOLD; + private boolean wakeUp = DEFAULT_WAKEUP; + private boolean stack = DEFAULT_STACK; + private String rtttl = DEFAULT_RTTTL; + private String sound = DEFAULT_SOUND; + private boolean loopSound = DEFAULT_SOUND_LOOP; + + private Map getNotificationParams() { + Map fields = new HashMap(); + fields.put("hold", this.hold); + fields.put("wakeUp", this.wakeUp); + fields.put("stack", this.stack); + fields.put("rtttl", this.rtttl); + fields.put("sound", this.sound); + fields.put("loopSound", this.loopSound); + return fields; + } + + @Override + public String getAppConfig() { + Map fields = getAppParams(); + return Helper.encodeJson(fields); + } + + @Override + public Map getAppParams() { + Map params = super.getAppParams(); + params.putAll(getNotificationParams()); + return params; + } + + public boolean isHold() { + return hold; + } + + public void setHold(boolean hold) { + this.hold = hold; + } + + public boolean isWakeUp() { + return wakeUp; + } + + public void setWakeUp(boolean wakeUp) { + this.wakeUp = wakeUp; + } + + public boolean isStack() { + return stack; + } + + public void setStack(boolean stack) { + this.stack = stack; + } + + public String getRtttl() { + return rtttl; + } + + public void setRtttl(String rtttl) { + this.rtttl = rtttl; + } + + public String getSound() { + return sound; + } + + public void setSound(String sound) { + this.sound = sound; + } + + public boolean isLoopSound() { + return loopSound; + } + + public void setLoopSound(boolean loopSound) { + this.loopSound = loopSound; + } + + @Override + protected String propertiesAsString() { + return super.propertiesAsString() + ", wakeUp=" + wakeUp + ", stack=" + stack + ", rtttl=" + rtttl + ", sound=" + + sound + ", loopSound=" + loopSound; + } + + @Override + public String toString() { + return "AwtrixNotification [" + propertiesAsString() + "]"; + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/discovery/AwtrixLightBridgeDiscoveryService.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/discovery/AwtrixLightBridgeDiscoveryService.java new file mode 100644 index 0000000000000..6137d4d74042c --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/discovery/AwtrixLightBridgeDiscoveryService.java @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.openhab.binding.mqtt.awtrixlight.internal.discovery; + +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.*; + +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.handler.AwtrixLightBridgeHandler; +import org.openhab.core.config.discovery.AbstractDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.thing.ThingUID; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerService; + +/** + * The {@link AwtrixLightBridgeDiscoveryService} is responsible for finding awtrix + * apps and setting them up for the handlers. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class AwtrixLightBridgeDiscoveryService extends AbstractDiscoveryService implements ThingHandlerService { + + @Nullable + AwtrixLightBridgeHandler bridgeHandler = null; + + public AwtrixLightBridgeDiscoveryService() { + super(Set.of(THING_TYPE_APP), 3, true); + } + + public void appDiscovered(String baseTopic, String appName) { + AwtrixLightBridgeHandler localHandler = this.bridgeHandler; + if (localHandler != null) { + Map bridgeProperties = localHandler.getThing().getProperties(); + @Nullable + String bridgeHardwareId = bridgeProperties.get(PROP_UNIQUEID); + if (bridgeHardwareId != null) { + publishApp(localHandler.getThing().getUID(), bridgeHardwareId, baseTopic, appName); + } + } + } + + @Override + public void setThingHandler(ThingHandler handler) { + if (handler instanceof AwtrixLightBridgeHandler bridgeHandler) { + this.bridgeHandler = bridgeHandler; + bridgeHandler.setAppDiscoveryCallback(this); + } + } + + @Override + public @Nullable ThingHandler getThingHandler() { + return this.bridgeHandler; + } + + @Override + public void deactivate() { + AwtrixLightBridgeHandler localHandler = this.bridgeHandler; + if (localHandler != null) { + localHandler.removeAppDiscoveryCallback(); + } + super.deactivate(); + } + + @Override + protected void startScan() { + // Do nothing. We get results pushed in from the bridge as they come + } + + void publishApp(ThingUID connectionBridgeUid, String bridgeHardwareId, String basetopic, String appName) { + if (!"Notification".equals(appName)) { + String appId = bridgeHardwareId + "-" + appName; + thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_APP, connectionBridgeUid, appName)) + .withBridge(connectionBridgeUid).withProperty(PROP_APPID, appId) + .withProperty(PROP_APP_CONTROLLABLE, false).withProperty(PROP_APPNAME, appName) + .withRepresentationProperty(PROP_APPID).withLabel("Awtrix App " + appName).build()); + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/discovery/AwtrixLightDiscoveryService.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/discovery/AwtrixLightDiscoveryService.java new file mode 100644 index 0000000000000..a1898c96f9ab0 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/discovery/AwtrixLightDiscoveryService.java @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.openhab.binding.mqtt.awtrixlight.internal.discovery; + +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.*; + +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.Helper; +import org.openhab.binding.mqtt.discovery.AbstractMQTTDiscovery; +import org.openhab.binding.mqtt.discovery.MQTTTopicDiscoveryService; +import org.openhab.core.config.discovery.DiscoveryResultBuilder; +import org.openhab.core.config.discovery.DiscoveryService; +import org.openhab.core.io.transport.mqtt.MqttBrokerConnection; +import org.openhab.core.thing.ThingUID; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AwtrixLightBridgeDiscoveryService} is responsible for finding awtrix + * clocks and setting them up for the handlers. + * + * @author Thomas Lauterbach - Initial contribution + */ + +@Component(service = DiscoveryService.class, configurationPid = "discovery.awtrixlight") +@NonNullByDefault +public class AwtrixLightDiscoveryService extends AbstractMQTTDiscovery { + protected final MQTTTopicDiscoveryService discoveryService; + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + @Activate + public AwtrixLightDiscoveryService(@Reference MQTTTopicDiscoveryService discoveryService) { + super(Set.of(THING_TYPE_BRIDGE), 3, true, TOPIC_BASE + "/+" + TOPIC_STATS); + this.discoveryService = discoveryService; + } + + @Override + public void receivedMessage(ThingUID connectionBridge, MqttBrokerConnection connection, String topic, + byte[] payload) { + resetTimeout(); + String message = new String(payload, StandardCharsets.UTF_8); + if (topic.endsWith(TOPIC_STATS)) { + String baseTopic = topic.replace(TOPIC_STATS, ""); + Map messageParams = Helper.decodeStatsJson(message); + String vendorString = "Unknown"; + @Nullable + Object vendor = messageParams.get(FIELD_BRIDGE_TYPE); + if (vendor instanceof Integer) { + vendorString = vendor.equals(0) ? "Ulanzi" : "Generic"; + } + String firmwareString = "Unknown"; + @Nullable + Object firmware = messageParams.get(FIELD_BRIDGE_FIRMWARE); + if (firmware instanceof String) { + firmwareString = (String) firmware; + } + String hardwareUidString = "Unknown"; + @Nullable + Object hardwareUid = messageParams.get(FIELD_BRIDGE_UID); + if (hardwareUid instanceof String) { + hardwareUidString = (String) hardwareUid; + } + logger.trace("Publishing an Awtrix Clock with ID :{}", hardwareUidString); + publishClock(connectionBridge, baseTopic, vendorString, firmwareString, hardwareUidString); + } + } + + @Override + public void topicVanished(ThingUID connectionBridge, MqttBrokerConnection connection, String topic) { + } + + @Override + public void deactivate() { + super.deactivate(); + } + + @Override + protected MQTTTopicDiscoveryService getDiscoveryService() { + return discoveryService; + } + + void publishClock(ThingUID connectionBridgeUid, String baseTopic, String vendor, String firmware, + String hardwareUid) { + String name = baseTopic.replace(TOPIC_BASE + "/", ""); + thingDiscovered(DiscoveryResultBuilder.create(new ThingUID(THING_TYPE_BRIDGE, connectionBridgeUid, hardwareUid)) + .withBridge(connectionBridgeUid).withProperty(PROP_VENDOR, vendor).withProperty(PROP_FIRMWARE, firmware) + .withProperty(PROP_UNIQUEID, hardwareUid).withProperty(PROP_BASETOPIC, baseTopic) + .withRepresentationProperty(PROP_UNIQUEID).withLabel("Awtrix Clock " + name).build()); + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/handler/AwtrixLightAppHandler.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/handler/AwtrixLightAppHandler.java new file mode 100644 index 0000000000000..5f274ba053b24 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/handler/AwtrixLightAppHandler.java @@ -0,0 +1,652 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.openhab.binding.mqtt.awtrixlight.internal.handler; + +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.*; + +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.AppConfigOptions; +import org.openhab.binding.mqtt.awtrixlight.internal.Helper; +import org.openhab.binding.mqtt.awtrixlight.internal.app.AwtrixApp; +import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseThingHandler; +import org.openhab.core.thing.binding.BridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.openhab.core.util.ColorUtil; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AwtrixLightAppHandler} is responsible for handling commands for an app and will send mqtt messages to + * update the app configuration on the awtrix clock. It will also emit trigger events as long as the app is locked on + * the clock. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class AwtrixLightAppHandler extends BaseThingHandler implements MqttMessageSubscriber { + + public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_APP); + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + + private String channelPrefix = ""; + private String appName = ""; + private Boolean synchronizationRequired = true; + private Boolean buttonControlled = false; + private Boolean active = true; + + private AwtrixApp app = new AwtrixApp(); + + private final Object syncLock = new Object(); + private @Nullable ScheduledFuture finishInitJob; + + public AwtrixLightAppHandler(Thing thing) { + super(thing); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.trace("Received command {} of type {} on channel {}", command.toString(), command.getClass(), + channelUID.getAsString()); + if (this.synchronizationRequired) { + // Don't accept any commands while we're synchronizing our settings + return; + } + + if (command instanceof RefreshType) { + updateApp(); + return; + } + switch (channelUID.getId()) { + case CHANNEL_ACTIVE: + // WARNING: Inactive Apps will return with default values after OH reboot + if (command instanceof OnOffType) { + if (OnOffType.OFF.equals(command)) { + this.active = false; + deleteApp(); + } else if (OnOffType.ON.equals(command)) { + this.active = true; + } + } + break; + case CHANNEL_RESET: + if (command instanceof OnOffType) { + if (OnOffType.ON.equals(command)) { + deleteApp(); + this.app = new AwtrixApp(); + updateApp(); + initStates(); + return; + } + } + break; + case CHANNEL_COLOR: + if (command instanceof HSBType hsbCommand) { + int[] rgb = ColorUtil.hsbToRgb(hsbCommand); + this.app.setColor(rgb); + if (this.app.getGradient().length != 0) { + this.app.setGradient(new int[][] { rgb, this.app.getGradient()[1] }); + } + } + break; + case CHANNEL_GRADIENT_COLOR: + if (command instanceof HSBType hsbCommand) { + int[] rgb = ColorUtil.hsbToRgb(hsbCommand); + this.app.setGradient(new int[][] { this.app.getColor(), rgb }); + } + break; + case CHANNEL_SCROLLSPEED: + if (command instanceof QuantityType quantityCommand) { + this.app.setScrollSpeed(quantityCommand.intValue()); + } + break; + case CHANNEL_DURATION: + if (command instanceof QuantityType quantityCommand) { + this.app.setDuration(quantityCommand.intValue()); + } + break; + case CHANNEL_EFFECT: + if (command instanceof StringType) { + this.app.setEffect(command.toString()); + } + break; + case CHANNEL_EFFECT_SPEED: + if (command instanceof QuantityType quantityCommand) { + this.app.setEffectSpeed(quantityCommand.intValue()); + } + break; + case CHANNEL_EFFECT_PALETTE: + if (command instanceof StringType) { + this.app.setEffectPalette(command.toString()); + } + break; + case CHANNEL_EFFECT_BLEND: + if (command instanceof OnOffType) { + this.app.setEffectBlend(OnOffType.ON.equals(command)); + } + break; + case CHANNEL_TEXT: + if (command instanceof StringType) { + this.app.setText(command.toString()); + } + break; + case CHANNEL_TEXT_OFFSET: + if (command instanceof QuantityType quantityCommand) { + this.app.setTextOffset(quantityCommand.intValue()); + } + break; + case CHANNEL_TOP_TEXT: + if (command instanceof OnOffType) { + this.app.setTopText(OnOffType.ON.equals(command)); + } + break; + case CHANNEL_TEXTCASE: + if (command instanceof QuantityType quantityCommand) { + this.app.setTextCase(quantityCommand.intValue()); + } + break; + case CHANNEL_CENTER: + if (command instanceof OnOffType) { + this.app.setCenter(OnOffType.ON.equals(command)); + } + break; + case CHANNEL_BLINK_TEXT: + if (command instanceof QuantityType quantityCommand) { + QuantityType blinkInS = quantityCommand.toUnit(Units.SECOND); + if (blinkInS != null) { + int blinkInMs = blinkInS.intValue() * 1000; + this.app.setBlinkText(blinkInMs); + } + } + break; + case CHANNEL_FADE_TEXT: + if (command instanceof QuantityType quantityCommand) { + QuantityType fadeInS = quantityCommand.toUnit(Units.SECOND); + if (fadeInS != null) { + int fadeInMs = fadeInS.intValue() * 1000; + this.app.setFadeText(fadeInMs); + } + } + break; + case CHANNEL_RAINBOW: + if (command instanceof OnOffType) { + this.app.setRainbow(OnOffType.ON.equals(command)); + } + break; + case CHANNEL_ICON: + if (command instanceof StringType) { + this.app.setIcon(command.toString()); + } + break; + case CHANNEL_PUSH_ICON: + if (command instanceof StringType) { + switch (command.toString()) { + case PUSH_ICON_OPTION_0: + this.app.setPushIcon(0); + break; + case PUSH_ICON_OPTION_1: + this.app.setPushIcon(1); + break; + case PUSH_ICON_OPTION_2: + this.app.setPushIcon(2); + break; + } + } + break; + case CHANNEL_BACKGROUND: + if (command instanceof HSBType hsbCommand) { + int[] rgb = ColorUtil.hsbToRgb(hsbCommand); + this.app.setBackground(rgb); + } + break; + case CHANNEL_LINE: + if (command instanceof StringType) { + try { + String[] points = command.toString().split(","); + int[] pointsAsInt = Arrays.stream(points).mapToInt(Integer::parseInt).toArray(); + this.app.setLine(pointsAsInt); + } catch (Exception e) { + logger.warn("Command {} cannot be parsed as line graph. Format should be: 1,2,3,4,5", + command.toString()); + } + } + break; + case CHANNEL_LIFETIME: + if (command instanceof QuantityType quantityCommand) { + this.app.setLifetime(quantityCommand.intValue()); + } + break; + case CHANNEL_LIFETIME_MODE: + if (command instanceof StringType) { + switch (command.toString()) { + case "DELETE": + this.app.setLifetimeMode(0); + break; + case "STALE": + this.app.setLifetimeMode(1); + break; + } + } + break; + case CHANNEL_BAR: + if (command instanceof StringType) { + try { + String[] points = command.toString().split(","); + int[] pointsAsInt = Arrays.stream(points).mapToInt(Integer::parseInt).toArray(); + this.app.setBar(pointsAsInt); + } catch (Exception e) { + logger.warn("Command {} cannot be parsed as bar graph. Format should be: 1,2,3,4,5", + command.toString()); + } + } + break; + case CHANNEL_AUTOSCALE: + if (command instanceof OnOffType) { + this.app.setAutoscale(OnOffType.ON.equals(command)); + } + break; + case CHANNEL_OVERLAY: + if (command instanceof StringType) { + this.app.setOverlay(command.toString()); + } + break; + case CHANNEL_PROGRESS: + if (command instanceof QuantityType quantityCommand) { + this.app.setProgress(quantityCommand.intValue()); + } + break; + case CHANNEL_PROGRESSC: + if (command instanceof HSBType hsbCommand) { + int[] rgb = ColorUtil.hsbToRgb(hsbCommand); + this.app.setProgressC(rgb); + } + break; + case CHANNEL_PROGRESSBC: + if (command instanceof HSBType hsbCommand) { + int[] rgb = ColorUtil.hsbToRgb(hsbCommand); + this.app.setProgressBC(rgb); + } + break; + } + logger.debug("Current app configuration: {}", this.app.toString()); + if (this.active) { + updateApp(); + } + } + + @Override + public void channelUnlinked(ChannelUID channelUID) { + switch (channelUID.getId()) { + case CHANNEL_COLOR: + this.app.setColor(AwtrixApp.DEFAULT_COLOR); + break; + case CHANNEL_GRADIENT_COLOR: + this.app.setGradient(AwtrixApp.DEFAULT_GRADIENT); + break; + case CHANNEL_SCROLLSPEED: + this.app.setScrollSpeed(AwtrixApp.DEFAULT_SCROLLSPEED); + break; + case CHANNEL_DURATION: + this.app.setDuration(AwtrixApp.DEFAULT_DURATION); + break; + case CHANNEL_EFFECT: + this.app.setEffect(AwtrixApp.DEFAULT_EFFECT); + break; + case CHANNEL_EFFECT_SPEED: + this.app.setEffectSpeed(AwtrixApp.DEFAULT_EFFECTSPEED); + break; + case CHANNEL_EFFECT_PALETTE: + this.app.setEffectPalette(AwtrixApp.DEFAULT_EFFECTPALETTE); + break; + case CHANNEL_EFFECT_BLEND: + this.app.setEffectBlend(AwtrixApp.DEFAULT_EFFECTBLEND); + break; + case CHANNEL_TEXT: + this.app.setText(AwtrixApp.DEFAULT_TEXT); + break; + case CHANNEL_TEXT_OFFSET: + this.app.setTextOffset(AwtrixApp.DEFAULT_TEXTOFFSET); + break; + case CHANNEL_TOP_TEXT: + this.app.setTopText(AwtrixApp.DEFAULT_TOPTEXT); + break; + case CHANNEL_TEXTCASE: + this.app.setTextCase(AwtrixApp.DEFAULT_TEXTCASE); + break; + case CHANNEL_CENTER: + this.app.setCenter(AwtrixApp.DEFAULT_CENTER); + break; + case CHANNEL_BLINK_TEXT: + this.app.setBlinkText(AwtrixApp.DEFAULT_BLINKTEXT); + break; + case CHANNEL_FADE_TEXT: + this.app.setFadeText(AwtrixApp.DEFAULT_FADETEXT); + break; + case CHANNEL_RAINBOW: + this.app.setRainbow(AwtrixApp.DEFAULT_RAINBOW); + break; + case CHANNEL_ICON: + this.app.setIcon(AwtrixApp.DEFAULT_ICON); + break; + case CHANNEL_PUSH_ICON: + this.app.setPushIcon(AwtrixApp.DEFAULT_PUSHICON); + break; + case CHANNEL_BACKGROUND: + this.app.setBackground(AwtrixApp.DEFAULT_BACKGROUND); + break; + case CHANNEL_LINE: + this.app.setLine(AwtrixApp.DEFAULT_LINE); + break; + case CHANNEL_LIFETIME: + this.app.setLifetime(AwtrixApp.DEFAULT_LIFETIME); + break; + case CHANNEL_LIFETIME_MODE: + this.app.setLifetimeMode(AwtrixApp.DEFAULT_LIFETIME_MODE); + break; + case CHANNEL_BAR: + this.app.setBar(AwtrixApp.DEFAULT_BAR); + break; + case CHANNEL_AUTOSCALE: + this.app.setAutoscale(AwtrixApp.DEFAULT_AUTOSCALE); + break; + case CHANNEL_OVERLAY: + this.app.setOverlay(AwtrixApp.DEFAULT_OVERLAY); + break; + case CHANNEL_PROGRESS: + this.app.setProgress(AwtrixApp.DEFAULT_PROGRESS); + break; + case CHANNEL_PROGRESSC: + this.app.setProgressC(AwtrixApp.DEFAULT_PROGRESSC); + break; + case CHANNEL_PROGRESSBC: + this.app.setProgressBC(AwtrixApp.DEFAULT_PROGRESSBC); + break; + } + logger.debug("Current app configuration: {}", this.app.toString()); + updateApp(); + } + + @Override + public void channelLinked(ChannelUID channelUID) { + // One might consider not updating all unaffected channels but it does not really hurt + initStates(); + } + + @Override + public void dispose() { + Future localFinishJob = this.finishInitJob; + if (localFinishJob != null && !localFinishJob.isCancelled() && !localFinishJob.isDone()) { + localFinishJob.cancel(true); + } + } + + @Override + public void handleRemoval() { + deleteApp(); + updateStatus(ThingStatus.REMOVED); + } + + @Override + public void initialize() { + this.synchronizationRequired = true; + AppConfigOptions config = getConfigAs(AppConfigOptions.class); + if (!this.appName.isBlank() && !this.appName.equals(config.appname)) { + // The app name has changed. Get rid of the old App first and init a new one + deleteApp(); + } + this.appName = config.appname; + this.buttonControlled = config.useButtons; + this.channelPrefix = getThing().getUID() + ":"; + thing.setProperty(PROP_APPID, this.appName); + logger.trace("Configured handler for app {} with channelPrefix {}", this.appName, this.channelPrefix); + bridgeStatusChanged(getBridgeStatus()); + } + + public ThingStatusInfo getBridgeStatus() { + Bridge b = getBridge(); + if (b != null) { + return b.getStatusInfo(); + } else { + return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, null); + } + } + + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + return; + } + if (bridgeStatusInfo.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + return; + } + + Bridge localBridge = this.getBridge(); + if (localBridge == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, "Bridge is missing or offline."); + return; + } + ThingHandler handler = localBridge.getHandler(); + if (handler instanceof AwtrixLightBridgeHandler albh) { + Map bridgeProperties = albh.getThing().getProperties(); + @Nullable + String bridgeHardwareId = bridgeProperties.get(PROP_UNIQUEID); + if (bridgeHardwareId != null) { + thing.setProperty(PROP_APPID, bridgeHardwareId + "-" + this.appName); + } + if (this.synchronizationRequired) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NOT_YET_READY, "Synchronizing..."); + this.finishInitJob = scheduler.schedule(this::finishInit, 15, TimeUnit.SECONDS); + } else { + finishInit(); + } + } + } + + @Override + public void processMessage(String topic, byte[] payload) { + synchronized (syncLock) { + if (this.synchronizationRequired) { + this.synchronizationRequired = false; + String payloadString = new String(payload, StandardCharsets.UTF_8); + this.app = Helper.decodeAppJson(payloadString); + initStates(); + finishInit(); + } + } + } + + public String getAppName() { + return this.appName; + } + + public boolean isButtonControlled() { + return this.buttonControlled; + } + + void handleLeftButton(String event) { + triggerChannel(new ChannelUID(channelPrefix + CHANNEL_BUTLEFT), event); + } + + void handleRightButton(String event) { + triggerChannel(new ChannelUID(channelPrefix + CHANNEL_BUTRIGHT), event); + } + + void handleSelectButton(String event) { + triggerChannel(new ChannelUID(channelPrefix + CHANNEL_BUTSELECT), event); + } + + private void deleteApp() { + Bridge bridge = getBridge(); + if (bridge != null) { + BridgeHandler bridgeHandler = bridge.getHandler(); + if (bridgeHandler instanceof AwtrixLightBridgeHandler albh) { + albh.deleteApp(this.appName); + } + } + } + + private void updateApp() { + Bridge bridge = getBridge(); + if (bridge != null) { + BridgeHandler bridgeHandler = bridge.getHandler(); + if (bridgeHandler instanceof AwtrixLightBridgeHandler albh) { + albh.updateApp(this.appName, this.app.getAppConfig()); + } + } + } + + private void initStates() { + updateState(new ChannelUID(channelPrefix + CHANNEL_ACTIVE), this.active ? OnOffType.ON : OnOffType.OFF); + + int[] color = this.app.getColor(); + updateState(new ChannelUID(channelPrefix + CHANNEL_COLOR), HSBType.fromRGB(color[0], color[1], color[2])); + updateState(new ChannelUID(channelPrefix + CHANNEL_GRADIENT_COLOR), + HSBType.fromRGB(color[0], color[1], color[2])); + + updateState(new ChannelUID(channelPrefix + CHANNEL_SCROLLSPEED), + new QuantityType<>(this.app.getScrollSpeed(), Units.PERCENT)); + + updateState(new ChannelUID(channelPrefix + CHANNEL_DURATION), + new QuantityType<>(this.app.getDuration(), Units.SECOND)); + + updateState(new ChannelUID(channelPrefix + CHANNEL_EFFECT), new StringType(this.app.getEffect())); + + updateState(new ChannelUID(channelPrefix + CHANNEL_EFFECT_SPEED), + new QuantityType<>(this.app.getEffectSpeed(), Units.ONE)); + + updateState(new ChannelUID(channelPrefix + CHANNEL_EFFECT_PALETTE), + new StringType(this.app.getEffectPalette())); + + updateState(new ChannelUID(channelPrefix + CHANNEL_EFFECT_BLEND), + this.app.getEffectBlend() ? OnOffType.ON : OnOffType.OFF); + + updateState(new ChannelUID(channelPrefix + CHANNEL_TEXT), new StringType(this.app.getText())); + + updateState(new ChannelUID(channelPrefix + CHANNEL_TEXT_OFFSET), + new QuantityType<>(this.app.getTextOffset(), Units.ONE)); + + updateState(new ChannelUID(channelPrefix + CHANNEL_TEXTCASE), + new QuantityType<>(this.app.getTextCase(), Units.ONE)); + + updateState(new ChannelUID(channelPrefix + CHANNEL_TOP_TEXT), + this.app.getTopText() ? OnOffType.ON : OnOffType.OFF); + + updateState(new ChannelUID(channelPrefix + CHANNEL_CENTER), + this.app.getCenter() ? OnOffType.ON : OnOffType.OFF); + + int blinkTextInSeconds = Math.round(this.app.getBlinkText() / 1000); + updateState(new ChannelUID(channelPrefix + CHANNEL_BLINK_TEXT), + new QuantityType<>(blinkTextInSeconds, Units.SECOND)); + + int fadeTextInSeconds = Math.round(this.app.getFadeText() / 1000); + updateState(new ChannelUID(channelPrefix + CHANNEL_FADE_TEXT), + new QuantityType<>(fadeTextInSeconds, Units.SECOND)); + + updateState(new ChannelUID(channelPrefix + CHANNEL_RAINBOW), + this.app.getRainbow() ? OnOffType.ON : OnOffType.OFF); + + updateState(new ChannelUID(channelPrefix + CHANNEL_ICON), new StringType(this.app.getIcon())); + + switch (this.app.getPushIcon()) { + case 0: + updateState(new ChannelUID(channelPrefix + CHANNEL_PUSH_ICON), new StringType(PUSH_ICON_OPTION_0)); + break; + case 1: + updateState(new ChannelUID(channelPrefix + CHANNEL_PUSH_ICON), new StringType(PUSH_ICON_OPTION_1)); + break; + case 2: + updateState(new ChannelUID(channelPrefix + CHANNEL_PUSH_ICON), new StringType(PUSH_ICON_OPTION_2)); + break; + } + + int[] background = this.app.getBackground(); + updateState(new ChannelUID(channelPrefix + CHANNEL_BACKGROUND), + HSBType.fromRGB(background[0], background[1], background[2])); + + updateState(new ChannelUID(channelPrefix + CHANNEL_LINE), new StringType(Arrays.toString(this.app.getLine()))); + + updateState(new ChannelUID(channelPrefix + CHANNEL_LIFETIME), + new QuantityType<>(this.app.getLifetime(), Units.SECOND)); + + String lifetimeMode = this.app.getLifetimeMode() == 0 ? "DELETE" : "STALE"; + updateState(new ChannelUID(channelPrefix + CHANNEL_LIFETIME_MODE), new StringType(lifetimeMode)); + + updateState(new ChannelUID(channelPrefix + CHANNEL_BAR), new StringType(Arrays.toString(this.app.getBar()))); + + updateState(new ChannelUID(channelPrefix + CHANNEL_AUTOSCALE), + this.app.getAutoscale() ? OnOffType.ON : OnOffType.OFF); + + updateState(new ChannelUID(channelPrefix + CHANNEL_OVERLAY), new StringType(this.app.getOverlay())); + + int progress = Math.max(this.app.getProgress(), 0); + updateState(new ChannelUID(channelPrefix + CHANNEL_PROGRESS), new QuantityType<>(progress, Units.PERCENT)); + + int[] progressC = this.app.getProgressC(); + updateState(new ChannelUID(channelPrefix + CHANNEL_PROGRESSC), + HSBType.fromRGB(progressC[0], progressC[1], progressC[2])); + + int[] progressBC = this.app.getProgressBC(); + updateState(new ChannelUID(channelPrefix + CHANNEL_PROGRESSBC), + HSBType.fromRGB(progressBC[0], progressBC[1], progressBC[2])); + } + + private void finishInit() { + synchronized (syncLock) { + if (this.synchronizationRequired) { + this.synchronizationRequired = false; + initStates(); + updateApp(); + } + } + updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE); + Future localJob = this.finishInitJob; + if (localJob != null) { + localJob.cancel(true); + this.finishInitJob = null; + } + } + + public void setActive(boolean active) { + if (this.active != active) { + this.active = active; + updateState(new ChannelUID(channelPrefix + CHANNEL_ACTIVE), active ? OnOffType.ON : OnOffType.OFF); + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/handler/AwtrixLightBridgeHandler.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/handler/AwtrixLightBridgeHandler.java new file mode 100644 index 0000000000000..c7202c0f24dbd --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/java/org/openhab/binding/mqtt/awtrixlight/internal/handler/AwtrixLightBridgeHandler.java @@ -0,0 +1,638 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ + +package org.openhab.binding.mqtt.awtrixlight.internal.handler; + +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.*; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ExecutionException; +import java.util.concurrent.Future; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.TimeoutException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.awtrixlight.internal.BridgeConfigOptions; +import org.openhab.binding.mqtt.awtrixlight.internal.Helper; +import org.openhab.binding.mqtt.awtrixlight.internal.action.AwtrixActions; +import org.openhab.binding.mqtt.awtrixlight.internal.app.AwtrixNotification; +import org.openhab.binding.mqtt.awtrixlight.internal.discovery.AwtrixLightBridgeDiscoveryService; +import org.openhab.binding.mqtt.handler.AbstractBrokerHandler; +import org.openhab.core.io.transport.mqtt.MqttBrokerConnection; +import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.RawType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.library.unit.Units; +import org.openhab.core.thing.Bridge; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.thing.ThingStatus; +import org.openhab.core.thing.ThingStatusDetail; +import org.openhab.core.thing.ThingStatusInfo; +import org.openhab.core.thing.ThingTypeUID; +import org.openhab.core.thing.binding.BaseBridgeHandler; +import org.openhab.core.thing.binding.ThingHandler; +import org.openhab.core.thing.binding.ThingHandlerCallback; +import org.openhab.core.thing.binding.ThingHandlerService; +import org.openhab.core.types.Command; +import org.openhab.core.types.RefreshType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AwtrixLightBridgeHandler} is responsible for handling commands for an awtrix clock device. It is also + * responsible for pushing discovery events to the discovery service. It also delegates trigger events to apps when they + * have been locked onto the clock. + * + * @author Thomas Lauterbach - Initial contribution + */ +@NonNullByDefault +public class AwtrixLightBridgeHandler extends BaseBridgeHandler implements MqttMessageSubscriber { + + public static final Set SUPPORTED_THING_TYPES = Set.of(THING_TYPE_BRIDGE); + + private final Logger logger = LoggerFactory.getLogger(this.getClass()); + private @Nullable MqttBrokerConnection connection; + + private boolean appLock = false; + private int appLockTimeout = 10; + private String basetopic = ""; + private String channelPrefix = ""; + private String currentApp = ""; + private boolean discoverDefaultApps = false; + private int lowBatteryThreshold = 25; + + private Map appHandlers = new HashMap(); + + private @Nullable AwtrixLightBridgeDiscoveryService discoveryCallback; + private @Nullable ScheduledFuture scheduledAppLockTimeout; + private @Nullable ScheduledFuture scheduledFadeBeforeTimeout; + + public AwtrixLightBridgeHandler(Bridge bridge) { + super(bridge); + } + + @Override + public void handleCommand(ChannelUID channelUID, Command command) { + logger.debug("Received command {} of type {} via channel {}", command, command.getClass(), + channelUID.getAsString()); + if (command instanceof RefreshType) { + if (CHANNEL_SCREEN.equals(channelUID.getId())) { + sendMQTT(this.basetopic + TOPIC_SEND_SCREEN, "", false); + } + return; + } + switch (channelUID.getId()) { + case CHANNEL_AUTO_BRIGHTNESS: + if (command instanceof OnOffType) { + boolean param = OnOffType.ON.equals(command); + sendMQTT(this.basetopic + TOPIC_SETTINGS, + "{\"" + FIELD_BRIDGE_SET_AUTO_BRIGHTNESS + "\":" + param + "}", false); + } + break; + case CHANNEL_BRIGHTNESS: + if (command instanceof QuantityType quantityCommand) { + double brightnessInt = quantityCommand.doubleValue(); + if (0 <= brightnessInt && brightnessInt <= 100) { + long param = Math.round(255 * (brightnessInt / 100)); + sendMQTT(this.basetopic + TOPIC_SETTINGS, "{\"" + FIELD_BRIDGE_SET_AUTO_BRIGHTNESS + + "\":false,\"" + FIELD_BRIDGE_SET_BRIGHTNESS + "\":" + param + "}", false); + updateState(new ChannelUID(channelPrefix + CHANNEL_AUTO_BRIGHTNESS), OnOffType.OFF); + } + } + break; + case CHANNEL_DISPLAY: + handleDisplay(command); + break; + case CHANNEL_INDICATOR1: + if (command instanceof OnOffType) { + if (OnOffType.ON.equals(command)) { + activateIndicator(1, new int[] { 0, 255, 0 }); + } else { + deactivateIndicator(1); + } + } + break; + case CHANNEL_INDICATOR2: + if (command instanceof OnOffType) { + if (OnOffType.ON.equals(command)) { + activateIndicator(2, new int[] { 0, 255, 0 }); + } else { + deactivateIndicator(2); + } + } + break; + case CHANNEL_INDICATOR3: + if (command instanceof OnOffType) { + if (OnOffType.ON.equals(command)) { + activateIndicator(3, new int[] { 0, 255, 0 }); + } else { + deactivateIndicator(3); + } + } + break; + case CHANNEL_SOUND: + if (command instanceof StringType) { + playSound(command.toString()); + } + break; + case CHANNEL_RTTTL: + if (command instanceof StringType) { + playRtttl(command.toString()); + } + break; + } + } + + public String getBaseTopic() { + return this.basetopic; + } + + @Override + public void initialize() { + BridgeConfigOptions config = getConfigAs(BridgeConfigOptions.class); + this.basetopic = config.basetopic; + this.appLockTimeout = config.appLockTimeout; + this.discoverDefaultApps = config.discoverDefaultApps; + this.lowBatteryThreshold = config.lowBatteryThreshold; + this.channelPrefix = thing.getUID() + ":"; + logger.debug( + "Configured handler with baseTopic {}, channelPrefix {}, appLockTimeout {}, discoverDefaultApps {}", + this.basetopic, this.channelPrefix, this.appLockTimeout, this.discoverDefaultApps); + bridgeStatusChanged(getBridgeStatus()); + } + + @Override + public void processMessage(String topic, byte[] payload) { + String payloadString = new String(payload, StandardCharsets.UTF_8); + if (topic.endsWith(TOPIC_STATS)) { + if (thing.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.ONLINE); + } + handleStatsMessage(payloadString); + } else if (topic.endsWith(TOPIC_STATS_CURRENT_APP)) { + handleCurrentAppMessage(payloadString); + } else if (topic.endsWith(TOPIC_SCREEN)) { + handleScreenMessage(payloadString); + } else if (topic.endsWith(TOPIC_BUTLEFT)) { + String event = "1".equals(payloadString) ? "PRESSED" : "RELEASED"; + handleLeftButton(event); + } else if (topic.endsWith(TOPIC_BUTRIGHT)) { + String event = "1".equals(payloadString) ? "PRESSED" : "RELEASED"; + handleRightButton(event); + } else if (topic.endsWith(TOPIC_BUTSELECT)) { + String event = "1".equals(payloadString) ? "PRESSED" : "RELEASED"; + handleSelectButton(event); + } + } + + public ThingStatusInfo getBridgeStatus() { + Bridge b = getBridge(); + if (b != null) { + return b.getStatusInfo(); + } else { + return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, null); + } + } + + @Override + public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) { + if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE); + connection = null; + return; + } + if (bridgeStatusInfo.getStatus() != ThingStatus.ONLINE) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR); + return; + } + + Bridge localBridge = this.getBridge(); + if (localBridge == null) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, + "Bridge is missing or offline, you need to setup a working MQTT broker first."); + return; + } + ThingHandler handler = localBridge.getHandler(); + if (handler instanceof AbstractBrokerHandler abh) { + @Nullable + final MqttBrokerConnection connection; + try { + connection = abh.getConnectionAsync().get(500, TimeUnit.MILLISECONDS); + } catch (InterruptedException | ExecutionException | TimeoutException ignored) { + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED, + "Bridge handler has no valid broker connection!"); + return; + } + this.connection = connection; + updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.CONFIGURATION_PENDING, + "Waiting for first MQTT message to be received."); + connection.subscribe(this.basetopic + TOPIC_STATS + "/#", this); + connection.subscribe(this.basetopic + TOPIC_STATS_CURRENT_APP + "/#", this); + connection.subscribe(this.basetopic + TOPIC_SCREEN + "/#", this); + } + return; + } + + public @Nullable MqttBrokerConnection getBrokerConnection() { + return connection; + } + + @Override + public void dispose() { + leaveAppControlMode(); + MqttBrokerConnection localConnection = connection; + if (localConnection != null) { + localConnection.unsubscribe(this.basetopic + TOPIC_STATS + "/#", this); + localConnection.unsubscribe(this.basetopic + TOPIC_STATS_CURRENT_APP + "/#", this); + localConnection.unsubscribe(this.basetopic + TOPIC_SCREEN + "/#", this); + } + } + + @Override + public void childHandlerInitialized(ThingHandler childHandler, Thing childThing) { + if (childHandler instanceof AwtrixLightAppHandler alah) { + this.appHandlers.put(alah.getAppName(), alah); + MqttBrokerConnection localConnection = connection; + if (localConnection != null) { + localConnection.subscribe(basetopic + "/custom/" + alah.getAppName(), alah); + } + } + } + + @Override + public void childHandlerDisposed(ThingHandler childHandler, Thing childThing) { + if (childHandler instanceof AwtrixLightAppHandler alah) { + this.appHandlers.remove(alah.getAppName()); + MqttBrokerConnection localConnection = connection; + if (localConnection != null) { + localConnection.unsubscribe(basetopic + "/custom/" + alah.getAppName(), alah); + } + } + } + + @Override + public Collection> getServices() { + ArrayList> services = new ArrayList>(); + services.add(AwtrixActions.class); + services.add(AwtrixLightBridgeDiscoveryService.class); + return services; + } + + public void reboot() { + this.sendMQTT(this.basetopic + TOPIC_REBOOT, "", false); + } + + public void sleep(int seconds) { + this.sendMQTT(this.basetopic + TOPIC_SLEEP, "{\"sleep\":" + seconds + "}", false); + } + + public void playSound(String melodyName) { + this.sendMQTT(this.basetopic + TOPIC_SOUND, "{\"sound\":\"" + melodyName + "\"}", false); + } + + public void playRtttl(String rtttl) { + this.sendMQTT(this.basetopic + TOPIC_RTTTL, rtttl, false); + } + + public void upgrade() { + this.sendMQTT(this.basetopic + TOPIC_UPGRADE, "", false); + } + + public void showNotification(boolean hold, boolean wakeUp, boolean stack, @Nullable String rtttl, + @Nullable String sound, boolean loopSound, Map params) { + AwtrixNotification notification = new AwtrixNotification(); + notification.updateFields(params); + notification.setHold(hold); + notification.setWakeUp(wakeUp); + notification.setStack(stack); + if (rtttl != null) { + notification.setRtttl(rtttl); + } + if (sound != null) { + notification.setSound(sound); + } + notification.setLoopSound(loopSound); + String notificationMessage = notification.getAppConfig(); + this.sendMQTT(this.basetopic + TOPIC_NOTIFY, notificationMessage, false); + } + + public void setAppDiscoveryCallback(AwtrixLightBridgeDiscoveryService awtrixLightBridgeDiscoveryService) { + this.discoveryCallback = awtrixLightBridgeDiscoveryService; + } + + public void removeAppDiscoveryCallback() { + this.discoveryCallback = null; + } + + void updateApp(String appName, String payload) { + sendMQTT(this.basetopic + "/custom/" + appName, payload, true); + } + + void deleteApp(String appName) { + logger.debug("Deleting app {}", appName); + sendMQTT(this.basetopic + "/custom/" + appName, "", true); + } + + private String getIndicatorTopic(int indicatorId) { + String indicatorTopic = ""; + if (indicatorId == 1) { + indicatorTopic = TOPIC_INDICATOR1; + } else if (indicatorId == 2) { + indicatorTopic = TOPIC_INDICATOR2; + } else if (indicatorId == 3) { + indicatorTopic = TOPIC_INDICATOR3; + } + return indicatorTopic; + } + + public void blinkIndicator(int id, int[] rgb, int blinkInMs) { + String indicatorTopic = getIndicatorTopic(id); + if (!"".equals(indicatorTopic) && rgb.length == 3) { + sendMQTT(this.basetopic + indicatorTopic, + "{\"color\":[" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "],\"blink\":" + blinkInMs + "}", false); + } + } + + public void fadeIndicator(int id, int[] rgb, int fadeInMs) { + String indicatorTopic = getIndicatorTopic(id); + if (!"".equals(indicatorTopic) && rgb.length == 3) { + sendMQTT(this.basetopic + indicatorTopic, + "{\"color\":[" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "],\"fade\":" + fadeInMs + "}", false); + } + } + + public void activateIndicator(int id, int[] rgb) { + String indicatorTopic = getIndicatorTopic(id); + if (!"".equals(indicatorTopic) && rgb.length == 3) { + sendMQTT(this.basetopic + indicatorTopic, "{\"color\":[" + rgb[0] + "," + rgb[1] + "," + rgb[2] + "]}", + false); + } + } + + public void deactivateIndicator(int id) { + String indicatorTopic = getIndicatorTopic(id); + if (!"".equals(indicatorTopic)) { + sendMQTT(this.basetopic + indicatorTopic, "{\"color\":\"0\"}", false); + } + } + + private void handleDisplay(Command command) { + if (command instanceof OnOffType) { + HashMap params = new HashMap(); + params.put("power", OnOffType.ON.equals(command)); + String json = Helper.encodeJson(params); + sendMQTT(this.basetopic + TOPIC_POWER, json, false); + } + } + + private void sendMQTT(String commandTopic, String payload, boolean retain) { + logger.debug("Will send {} to topic {}", payload, commandTopic); + byte[] payloadBytes = payload.getBytes(); + MqttBrokerConnection localConnection = connection; + if (localConnection != null) { + localConnection.publish(commandTopic, payloadBytes, 1, retain); + } + } + + private void handleLeftButton(String event) { + triggerChannel(new ChannelUID(channelPrefix + CHANNEL_BUTLEFT), event); + if (this.appLock) { + scheduleAppLockTimeout(); + @Nullable + AwtrixLightAppHandler alah = this.appHandlers.get(this.currentApp); + if (alah != null) { + alah.handleLeftButton(event); + } + } + } + + private void handleRightButton(String event) { + triggerChannel(new ChannelUID(channelPrefix + CHANNEL_BUTRIGHT), event); + if (this.appLock) { + scheduleAppLockTimeout(); + @Nullable + AwtrixLightAppHandler alah = this.appHandlers.get(this.currentApp); + if (alah != null) { + alah.handleRightButton(event); + } + } + } + + private void handleSelectButton(String event) { + triggerChannel(new ChannelUID(channelPrefix + CHANNEL_BUTSELECT), event); + @Nullable + AwtrixLightAppHandler alah = this.appHandlers.get(this.currentApp); + if (alah != null) { + if (!this.appLock) { + if (alah.isButtonControlled()) { + if ("RELEASED".equals(event)) { + sendMQTT(this.basetopic + TOPIC_SETTINGS, "{\"" + FIELD_BRIDGE_SET_AUTO_TRANSITION + + "\":false,\"" + FIELD_BRIDGE_SET_BLOCK_KEYS + "\":true}", false); + this.appLock = true; + scheduleAppLockTimeout(); + } + } + } else { + scheduleAppLockTimeout(); + alah.handleSelectButton(event); + } + } + } + + private void scheduleAppLockTimeout() { + activateIndicator(3, new int[] { 255, 0, 0 }); + Future localSignalSchedule = this.scheduledFadeBeforeTimeout; + if (localSignalSchedule != null && !localSignalSchedule.isCancelled() && !localSignalSchedule.isDone()) { + localSignalSchedule.cancel(true); + } + Future localSchedule = this.scheduledAppLockTimeout; + if (localSchedule != null && !localSchedule.isCancelled() && !localSchedule.isDone()) { + localSchedule.cancel(true); + scheduleAppLockTimeout(); + } else { + this.scheduledAppLockTimeout = scheduler.schedule(this::leaveAppControlMode, this.appLockTimeout, + TimeUnit.SECONDS); + if (this.appLockTimeout > 3) { + this.scheduledFadeBeforeTimeout = scheduler.schedule(this::signalLeaveAppControlMode, + this.appLockTimeout - 3, TimeUnit.SECONDS); + } + } + } + + private void leaveAppControlMode() { + this.appLock = false; + Future localSignalSchedule = this.scheduledFadeBeforeTimeout; + if (localSignalSchedule != null && !localSignalSchedule.isCancelled() && !localSignalSchedule.isDone()) { + localSignalSchedule.cancel(true); + } + Future localSchedule = this.scheduledAppLockTimeout; + if (localSchedule != null && !localSchedule.isCancelled() && !localSchedule.isDone()) { + localSchedule.cancel(true); + } + deactivateIndicator(3); + sendMQTT(this.basetopic + TOPIC_SETTINGS, + "{\"" + FIELD_BRIDGE_SET_AUTO_TRANSITION + "\":true,\"" + FIELD_BRIDGE_SET_BLOCK_KEYS + "\":false}", + false); + } + + private void signalLeaveAppControlMode() { + fadeIndicator(3, new int[] { 255, 0, 0 }, 500); + } + + private void handleScreenMessage(String screenMessage) { + byte[] bytes = Helper.decodeImage(screenMessage); + updateState(new ChannelUID(channelPrefix + CHANNEL_SCREEN), new RawType(bytes, "image/png")); + } + + private void handleCurrentAppMessage(String currentAppMessage) { + this.currentApp = currentAppMessage; + ThingHandlerCallback callback = getCallback(); + if (callback != null && callback.isChannelLinked(new ChannelUID(this.channelPrefix + CHANNEL_SCREEN))) { + sendMQTT(this.basetopic + TOPIC_SEND_SCREEN, "", false); + } + if (!this.appHandlers.containsKey(currentAppMessage)) { + if (this.discoverDefaultApps || !Arrays.stream(DEFAULT_APPS).anyMatch(currentAppMessage::equals)) { + AwtrixLightBridgeDiscoveryService localDiscoveryCallback = discoveryCallback; + if (localDiscoveryCallback != null) { + localDiscoveryCallback.appDiscovered(this.basetopic, currentAppMessage); + } + } + } else { + @Nullable + AwtrixLightAppHandler alah = this.appHandlers.get(currentAppMessage); + if (alah != null) { + alah.setActive(true); + } + } + } + + private void handleStatsMessage(String statsMessage) { + Map params = Helper.decodeStatsJson(statsMessage); + for (Map.Entry entry : params.entrySet()) { + @Nullable + String key = entry.getKey(); + @Nullable + Object value = entry.getValue(); + switch (key) { + case FIELD_BRIDGE_UID: + thing.setProperty(PROP_UNIQUEID, (String) value); + break; + case FIELD_BRIDGE_BATTERY: + if (value instanceof Number numberVal) { + updateState(new ChannelUID(channelPrefix + CHANNEL_BATTERY), + new QuantityType<>(numberVal, Units.PERCENT)); + OnOffType lowBattery = numberVal.intValue() < this.lowBatteryThreshold ? OnOffType.ON + : OnOffType.OFF; + updateState(new ChannelUID(channelPrefix + CHANNEL_LOW_BATTERY), lowBattery); + } + break; + case FIELD_BRIDGE_BATTERY_RAW: + // Not mapped to channel atm + break; + case FIELD_BRIDGE_FIRMWARE: + thing.setProperty(PROP_FIRMWARE, value.toString()); + break; + case FIELD_BRIDGE_TYPE: + if (value instanceof Number numberVal) { + String vendor = numberVal.intValue() == 0 ? "Ulanzi" : "Generic"; + thing.setProperty(PROP_VENDOR, vendor); + } + break; + case FIELD_BRIDGE_LUX: + if (value instanceof Number numberVal) { + updateState(new ChannelUID(channelPrefix + CHANNEL_LUX), + new QuantityType<>(numberVal, Units.LUX)); + } + break; + case FIELD_BRIDGE_LDR_RAW: + // Not mapped to channel atm + break; + case FIELD_BRIDGE_RAM: + // Not mapped to channel atm + break; + case FIELD_BRIDGE_MATRIX: + if (value instanceof Boolean booleanVal) { + updateState(new ChannelUID(channelPrefix + CHANNEL_DISPLAY), + booleanVal ? OnOffType.ON : OnOffType.OFF); + } + break; + case FIELD_BRIDGE_BRIGHTNESS: + if (value instanceof Number numberVal) { + long brightnessInPercent = Math.round((numberVal.doubleValue() / 255) * 100); + updateState(new ChannelUID(channelPrefix + CHANNEL_BRIGHTNESS), + new QuantityType<>(brightnessInPercent, Units.PERCENT)); + } + break; + case FIELD_BRIDGE_TEMPERATURE: + if (value instanceof Number numberVal) { + updateState(new ChannelUID(channelPrefix + CHANNEL_TEMPERATURE), + new QuantityType<>(numberVal, SIUnits.CELSIUS)); + } + break; + case FIELD_BRIDGE_HUMIDITY: + if (value instanceof Number numberVal) { + updateState(new ChannelUID(channelPrefix + CHANNEL_HUMIDITY), + new QuantityType<>(numberVal, Units.PERCENT)); + } + break; + case FIELD_BRIDGE_UPTIME: + // Not mapped to channel atm + break; + case FIELD_BRIDGE_WIFI_SIGNAL: + if (value instanceof Number numberVal) { + updateState(new ChannelUID(channelPrefix + CHANNEL_RSSI), + new QuantityType<>(numberVal, Units.ONE)); + } + break; + case FIELD_BRIDGE_MESSAGES: + // Not mapped to channel atm + break; + case FIELD_BRIDGE_INDICATOR1: + if (value instanceof Boolean booleanVal) { + OnOffType indicator1 = booleanVal ? OnOffType.ON : OnOffType.OFF; + updateState(new ChannelUID(channelPrefix + CHANNEL_INDICATOR1), indicator1); + } + break; + case FIELD_BRIDGE_INDICATOR2: + if (value instanceof Boolean booleanVal) { + OnOffType indicator2 = booleanVal ? OnOffType.ON : OnOffType.OFF; + updateState(new ChannelUID(channelPrefix + CHANNEL_INDICATOR2), indicator2); + } + break; + case FIELD_BRIDGE_INDICATOR3: + if (value instanceof Boolean booleanVal) { + OnOffType indicator3 = booleanVal ? OnOffType.ON : OnOffType.OFF; + updateState(new ChannelUID(channelPrefix + CHANNEL_INDICATOR3), indicator3); + } + break; + case FIELD_BRIDGE_APP: + if (value instanceof String stringVal) { + updateState(new ChannelUID(channelPrefix + CHANNEL_APP), new StringType(stringVal)); + } + break; + } + } + } +} diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/resources/OH-INF/config/config.xml new file mode 100644 index 0000000000000..829fcdebb1359 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/resources/OH-INF/config/config.xml @@ -0,0 +1,47 @@ + + + + + + + Base topic as configured in the Awtrix Light device. + awtrix + + + + Timeout in seconds until selected apps return control and the app rotation continues. + 10 + + + + Currently changing settings for the built-in default apps is not implemented. It is therefore + recommended to ignore them during app discovery. + false + + + + Threshold for issuing a low battery warning. + 25 + + + + + + + Name of the app + + + + + + false + + + + diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/resources/OH-INF/thing/thing-types.xml new file mode 100644 index 0000000000000..3bd34326edecd --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,491 @@ + + + + + + + + + Device with Awtrix Light firmware + screen + + + + + + + + Left button pressed + + + + Right button pressed + + + + Select button pressed + + + + Switches the display ON or OFF + recommend + + + + + + + + + + + + + + + + + + + uniqueId + + + + + + + + + An app for an Awtrix Light device + screen + + + recommend + + + recommend + + + + + + + + Left button pressed + + + + Right button pressed + + + + Select button pressed + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + appid + + + + + Number:Illuminance + + Brightness in lux + sun + + Measurement + Light + + + + + + Switch + + Let the clock set brightness automatically based on internal brightness sensor readings + screen + + Control + Light + + + + + Number:Dimensionless + + Screen brightness in percent + screen + + Control + Light + + + + + + Number:Dimensionless + + RSSI value of WiFi signal + qualityofservice + + + + + Switch + + Indicator state + switch + + Control + Power + + + + + String + + App currently shown on screen + screen + + + + + Image + + + + screen + + + + Color + + Color to display text and charts on the screen + colorpicker + + + + Switch + + Resets the app and the linked items to the default settings + switch + + + + Color + + Color for display background + colorpicker + + + + Color + + Color text as gradient from Main Color to Gradient Color + colorpicker + + + + Number:Time + + How long the app should be displayed + time + + + + String + + Effect shown in the background of the app + screen + + + + + + + + + + + + + + + + + + + + + + + + + + + String + + Changes the color scheme of the effect settings + colorpicker + + + + + + + + + + + + + + + + + Switch + + Smoother effect animations + switch + + + + Number:Dimensionless + + Playback speed of background animations + time + + + + String + + Text displayed in the app + text + + + + Number:Dimensionless + + Change case of displayed text. Default uses the global preset. + text + + + + + + + + + + + Number:Dimensionless + + Offset on x-axis in pixel for displayed text + text + + + + Switch + + Aligns the text with the top of the display + text + + + + Number:Time + + Blink text in specified time interval + text + + Control + Duration + + + + + + Number:Time + + Fade text in specified interval + text + + Control + Duration + + + + + + Switch + + Fades the text color in rainbow colors + text + + + + Switch + + Short texts will be centered instead of scrolling + text + + + + String + + Icon ID or filename without extension + screen + + + + String + + Make the icon scroll along with the text + screen + + + + + + + + + + + String + + Draw a line graph (format: "value1,value2,value3", last 16 entries will be displayed, last 11 with icon) + qualityofservice + + + + Number:Time + + Remove the app if there was no update within specified seconds + time + + Control + Duration + + + + + String + + Delete the app or mark as stale after lifetime + screen + + + + + + + + + + String + + Draw a bar graph (format: "value1,value2,value3", last 16 entries will be displayed, last 11 with icon) + qualityofservice + + + + Switch + + Automatically scales graphs to fit onto display + qualityofservice + + + + String + + Overlay effect (overriden by global clock overlay) + screen + + + + + + + + + + + + + + + Number:Dimensionless + + Show progress bar with specified percentage + qualityofservice + + + + + Color + + Color of progress bar + colorpicker + + + + Color + + Color of progress bar background + colorpicker + + + + Number:Dimensionless + + Speed of text scrolling as percentage of default speed + + + + + String + + Name of the melody file in the MELODIES folder + text + + + + String + + Ring Tone Text Transfer Language (RTTTL) compliant sound string + text + + + diff --git a/bundles/org.openhab.binding.mqtt.awtrixlight/src/test/java/org/openhab/binding/mqtt/awtrixlight/internal/HelperTest.java b/bundles/org.openhab.binding.mqtt.awtrixlight/src/test/java/org/openhab/binding/mqtt/awtrixlight/internal/HelperTest.java new file mode 100644 index 0000000000000..535d164f0b46d --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.awtrixlight/src/test/java/org/openhab/binding/mqtt/awtrixlight/internal/HelperTest.java @@ -0,0 +1,176 @@ +/* + * Copyright (c) 2010-2025 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.awtrixlight.internal; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.openhab.binding.mqtt.awtrixlight.internal.AwtrixLightBindingConstants.*; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.binding.mqtt.awtrixlight.internal.app.AwtrixApp; + +/** + * Test cases for the {@link Helper} service. + * + * @author Thomas Lauterbach - Initial contribution + */ +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +@NonNullByDefault +public class HelperTest { + + @Test + public void convertJson() { + String message = "{\"bat\":100,\"bat_raw\":670,\"type\":0,\"lux\":958,\"ldr_raw\":975,\"ram\":148568,\"bri\":10,\"temp\":24,\"hum\":41,\"uptime\":50092,\"wifi_signal\":-53,\"messages\":2,\"version\":\"0.83\",\"indicator1\":false,\"indicator2\":false,\"indicator3\":false,\"app\":\"Temperature\"}"; + + Map convertedJson = Helper.decodeStatsJson(message); + assertEquals(0, 17 - convertedJson.size()); + + String[] statsProperties = { FIELD_BRIDGE_BATTERY, FIELD_BRIDGE_BATTERY_RAW, FIELD_BRIDGE_FIRMWARE, + FIELD_BRIDGE_TYPE, FIELD_BRIDGE_LUX, FIELD_BRIDGE_LDR_RAW, FIELD_BRIDGE_RAM, FIELD_BRIDGE_BRIGHTNESS, + FIELD_BRIDGE_TEMPERATURE, FIELD_BRIDGE_HUMIDITY, FIELD_BRIDGE_UPTIME, FIELD_BRIDGE_WIFI_SIGNAL, + FIELD_BRIDGE_MESSAGES, FIELD_BRIDGE_INDICATOR1, FIELD_BRIDGE_INDICATOR2, FIELD_BRIDGE_INDICATOR3, + FIELD_BRIDGE_APP }; + + for (String s : statsProperties) { + assertTrue(convertedJson.containsKey(s)); + String[] stringProperties = { FIELD_BRIDGE_APP, FIELD_BRIDGE_FIRMWARE }; + String[] booleanProperties = { FIELD_BRIDGE_INDICATOR1, FIELD_BRIDGE_INDICATOR2, FIELD_BRIDGE_INDICATOR3 }; + if (Arrays.stream(stringProperties).anyMatch(s::equals)) { + @Nullable + Object prop = convertedJson.get(s); + assertNotNull(prop); + assertEquals(String.class, prop.getClass()); + } else if (Arrays.stream(booleanProperties).anyMatch(s::equals)) { + @Nullable + Object prop = convertedJson.get(s); + assertNotNull(prop); + assertEquals(Boolean.class, prop.getClass()); + } else { + @Nullable + Object prop = convertedJson.get(s); + assertNotNull(prop); + assertEquals(Double.class, prop.getClass()); + } + } + } + + @Test + public void encodeJson() { + HashMap inputMap = new HashMap<>(); + inputMap.put("Test1", "Test1"); + inputMap.put("Test2", 100); + inputMap.put("Test3", -100); + inputMap.put("Test4", true); + inputMap.put("Test5", false); + + String json = Helper.encodeJson(inputMap); + + assertTrue(json.contains("{")); + assertTrue(json.contains("\"Test1\":\"Test1\"")); + assertTrue(json.contains("\"Test2\":100")); + assertTrue(json.contains("\"Test3\":-100")); + assertTrue(json.contains("\"Test4\":true")); + assertTrue(json.contains("\"Test5\":false")); + assertTrue(json.contains("}")); + assertEquals(4, json.chars().filter(ch -> ch == ',').count()); + } + + @Test + public void convertImage() { + String imageMessage = "[0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,14276351,0,0,0,0,0,0,0,0,0,0,0,16777215,0,16777215,0,0,16777215,0,0,16777215,0,16777215,0,0,0,0,0,0,0,0,16777215,13094911,9017343,0,0,0,0,0,0,0,0,0,0,16777215,0,16777215,0,16777215,16777215,0,0,0,0,16777215,0,0,0,0,0,0,0,16777215,14276351,13094911,9017343,6582015,0,0,0,0,0,0,0,0,0,16777215,16777215,16777215,0,0,16777215,0,0,0,16777215,0,0,0,0,0,0,0,0,14276351,13094911,13094911,6582015,6582015,0,0,0,0,0,0,0,0,0,0,0,16777215,0,0,16777215,0,0,16777215,0,0,0,0,0,0,0,0,0,14276351,9017343,9017343,6582015,3030679,0,0,0,0,0,0,0,0,0,0,0,16777215,0,16777215,16777215,16777215,0,16777215,0,16777215,0,0,0,0,0,0,0,0,6582015,6582015,3030679,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0]"; + Helper.decodeImage(imageMessage); + } + + private AwtrixApp getTestApp() { + AwtrixApp app = new AwtrixApp(); + app.setText("Test"); + app.setColor(new int[] { 255, 255, 255 }); + app.setEffect("Radar"); + app.setEffectSpeed(80); + app.setEffectPalette("test"); + app.setScrollSpeed(70); + return app; + } + + private AwtrixApp getTestAppWithGradient() { + AwtrixApp app = new AwtrixApp(); + app.setText("Test"); + app.setColor(new int[] { 255, 255, 255 }); + app.setGradient(new int[][] { { 255, 255, 255 }, { 255, 0, 0 } }); + return app; + } + + private AwtrixApp getTestAppWithIncompatibleOptions() { + AwtrixApp app = getTestApp(); + app.setFadeText(100); + // Rainbow is incompatible with fadeText and will be ignored when generating the JSON + app.setRainbow(true); + return app; + } + + @Test + public void copyAppViaJson() { + AwtrixApp app = getTestApp(); + String json = app.getAppConfig(); + AwtrixApp app2 = Helper.decodeAppJson(json); + + assertEquals(json, app2.getAppConfig()); + assertEquals(app.toString(), app2.toString()); + } + + @Test + public void copyAppViaJsonWithGradient() { + AwtrixApp app = getTestAppWithGradient(); + String json = app.getAppConfig(); + AwtrixApp app2 = Helper.decodeAppJson(json); + + assertEquals(json, app2.getAppConfig()); + } + + @Test + public void copyAppViaJsonWithIncompatibleOptions() { + AwtrixApp app = getTestAppWithIncompatibleOptions(); + String json = app.getAppConfig(); + AwtrixApp app2 = Helper.decodeAppJson(json); + + // Incompatible options are not copied to the new app + assertNotEquals(app.getRainbow(), app2.getRainbow()); + assertEquals(app.getFadeText(), app2.getFadeText()); + + // But the generated json should still be the same + assertEquals(json, app2.getAppConfig()); + } + + @Test + public void trimArray() { + int[] untrimmed = { 0, 1, 10, 1000 }; + int[] trimmed = Helper.leftTrim(untrimmed, 2); + + assertTrue(trimmed.length == 2); + assertEquals(trimmed[0], untrimmed[untrimmed.length - 2]); + assertEquals(trimmed[1], untrimmed[untrimmed.length - 1]); + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index 6fed251e59339..e64e00ca7f412 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -276,6 +276,7 @@ org.openhab.binding.monopriceaudio org.openhab.binding.mpd org.openhab.binding.mqtt + org.openhab.binding.mqtt.awtrixlight org.openhab.binding.mqtt.espmilighthub org.openhab.binding.mqtt.fpp org.openhab.binding.mqtt.generic diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml index 42e4a59b78c28..6a1d944130665 100644 --- a/features/openhab-addons/src/main/resources/footer.xml +++ b/features/openhab-addons/src/main/resources/footer.xml @@ -29,6 +29,7 @@ mvn:com.fasterxml.jackson.datatype/jackson-datatype-jdk8/${jackson.version} mvn:org.openhab.osgiify/com.hubspot.immutables.immutables-exceptions/1.9 mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.awtrixlight/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.espmilighthub/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.fpp/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.mqtt.generic/${project.version}