Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion bundles/org.openhab.binding.mqtt.awtrixlight/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,13 +110,45 @@ The button events can be used by rules to change the displayed app or perform an
| `rainbow` | 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` | 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` | String | RW | Text to display. |
| `text` | String | RW | Text to display. Supports inline color formatting with font color tags (see Text Color Tags section below for details). |
| `text-case` | Number:Dimensionless | RW | Set text case (0=normal, 1=uppercase, 2=lowercase). |
| `text-offset` | Number:Dimensionless | RW | Text offset position: Horizontal offset of the text in pixels. |
| `top-text` | String | RW | Draws the text on the top of the display. |

\* Cannot be used with notification Actions (see section Actions)

## Text Color Tags

The `text` channel supports inline color formatting using simple HTML-like font color tags. This allows you to display text with multiple colors in a single app.

### Syntax

Use the following format to apply colors to specific parts of your text:

```html
<font color="#RRGGBB">colored text</font>
```

Where `RRGGBB` is a 6-digit hexadecimal color code (e.g., `FF0000` for red, `00FF00` for green, `0000FF` for blue).

### Examples

```java
// Multiple colored segments - "Hello" and "in" will use the color from the color channel, "World" and "Color" will use the specified colors
Custom_Text.sendCommand('Hello <font color="#FF0000">World</font> in <font color="#00FF00">Color</font>')

// All text in custom color
Custom_Text.sendCommand('<font color="#FF6600">Temperature: 25°C</font>')
```

### Important Notes

- **Default color**: Text outside of `<font>` tags will be displayed in the color defined by the `color` channel.
- **Text effects disabled**: When color tags are used, the `blink`, `fade`, and `rainbow` effects are automatically disabled, as each text segment has its own color. The `gradient-color` channel is also ignored.
- **Tags cannot be nested**: `<font>` tags must not be placed inside other `<font>` tags. Nesting is not supported and will result in incorrect parsing.
- **Case-insensitive hex values**: Both uppercase and lowercase hex values are supported (e.g., `#FF0000` or `#ff0000`).
- **Malformed tags**: If a tag is malformed (e.g., missing closing tag), the parser will gracefully handle it by applying the default color.

## 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).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,38 @@
*/
package org.openhab.binding.mqtt.awtrixlight.internal.app;

import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
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;

import com.google.gson.annotations.SerializedName;

/**
* The {@link TextSegment} is the representation of a text segment in an App.
*
* @author Thomas Lauterbach - Initial contribution
*/
@NonNullByDefault
class TextSegment {
@SerializedName("t")
public final String text;

@SerializedName("c")
public final String color;

public TextSegment(String text, String color) {
this.text = text;
this.color = color;
}
}

/**
* The {@link AwtrixApp} is the representation of the current app configuration and provides a method to create a config
* string for the clock.
Expand All @@ -30,6 +53,8 @@
@NonNullByDefault
public class AwtrixApp {

private static final int CLOSING_TAG_LENGTH = 7; // 7 = "</font>".length()

public static final String DEFAULT_TEXT = "New Awtrix App";
public static final int DEFAULT_TEXTCASE = 0;
public static final boolean DEFAULT_TOPTEXT = false;
Expand Down Expand Up @@ -88,6 +113,9 @@ public class AwtrixApp {
// effectSettings properties
private Map<String, Object> effectSettings;

private static final java.util.regex.Pattern TEXT_COLOR_PATTERN = java.util.regex.Pattern
.compile("color=\"#([0-9A-Fa-f]{6})\"");

public AwtrixApp() {
this.effectSettings = new HashMap<String, Object>();
this.effectSettings.put("speed", DEFAULT_EFFECTSPEED);
Expand Down Expand Up @@ -362,7 +390,12 @@ public String toString() {

public Map<String, Object> getAppParams() {
Map<String, Object> fields = new HashMap<String, Object>();
fields.put("text", this.text);
if (textHasColorTags(this.text)) {
fields.put("text", this.parseTextSegments());
} else {
fields.put("text", this.text);
fields.putAll(getTextEffectConfig());
}
fields.put("textCase", this.textCase);
fields.put("topText", this.topText);
fields.put("textOffset", this.textOffset);
Expand All @@ -371,7 +404,6 @@ public Map<String, Object> getAppParams() {
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);
Expand Down Expand Up @@ -515,6 +547,92 @@ private Map<String, Object> getColorConfig() {
return fields;
}

private boolean textHasColorTags(String text) {
if (text.isEmpty()) {
return false;
}
// Check for the basic structure and use regex to validate color format
// We need both opening and closing tags, and at least one valid color attribute
return text.contains("<font") && text.contains("</font>") && TEXT_COLOR_PATTERN.matcher(text).find();
}

private static String rgbToHex(int[] rgb) {
// Ensure values are in 0-255 range
int r = Math.min(255, Math.max(0, rgb[0]));
int g = Math.min(255, Math.max(0, rgb[1]));
int b = Math.min(255, Math.max(0, rgb[2]));

// Format as 6-digit hex string, padding with zeros if needed
return String.format("%02x%02x%02x", r, g, b);
}

private List<TextSegment> parseTextSegments() {
List<TextSegment> segments = new ArrayList<>();
if (this.text.isEmpty()) {
return segments;
}

String remaining = this.text;
String defaultColor = rgbToHex(this.color);

while (true) {
int startTag = remaining.indexOf("<font");
if (startTag < 0) {
// No more tags, add remaining text
if (!remaining.isEmpty()) {
segments.add(new TextSegment(remaining, defaultColor));
}
break;
}

// Add text before the tag
if (startTag > 0) {
segments.add(new TextSegment(remaining.substring(0, startTag), defaultColor));
}

// Find the end of the opening tag
int endTag = remaining.indexOf(">", startTag);
if (endTag < 0) {
// Malformed tag, add everything and stop
segments.add(new TextSegment(remaining, defaultColor));
break;
}

// Extract color from tag (or use default)
String tag = remaining.substring(startTag, endTag + 1);
String color = extractColor(tag);
if (color == null) {
color = defaultColor;
}

// Find the closing tag
int closeTag = remaining.indexOf("</font>", endTag);
if (closeTag < 0) {
// No closing tag, add rest with color and stop
segments.add(new TextSegment(remaining.substring(endTag + 1), color));
break;
}

// Add text between tags
segments.add(new TextSegment(remaining.substring(endTag + 1, closeTag), color));

// Move past the closing tag
remaining = remaining.substring(closeTag + CLOSING_TAG_LENGTH);
}

return segments;
}

@Nullable
private String extractColor(String tag) {
java.util.regex.Matcher matcher = TEXT_COLOR_PATTERN.matcher(tag);
if (matcher.find()) {
// Group 1 contains the hex color value (without the #)
return matcher.group(1).toLowerCase();
}
return null;
}

private Map<String, Object> getTextEffectConfig() {
Map<String, Object> fields = new HashMap<String, Object>();
if (Arrays.equals(this.color, DEFAULT_COLOR) && Arrays.equals(this.gradient, DEFAULT_GRADIENT)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
/*
* 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.junit.jupiter.api.Assertions.*;

import org.junit.jupiter.api.Test;

import com.google.gson.JsonArray;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;

/**
* Test cases for the {@link AwtrixApp} object.
*
* @author Thomas Lauterbach - Initial contribution
*/
class AwtrixAppTest {

@Test
void testTextWithMixedContent() {
AwtrixApp app = new AwtrixApp();
app.setText("This is a <font color=\"#cc33cc\">Multi</font> Colored <font color=\"#34ab12\">Text</font>!");
app.setColor(new int[] { 255, 255, 255 }); // Default white color

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();
JsonArray text = json.get("text").getAsJsonArray();

// Should be 5 segments: "This is a ", "Multi", " Colored ", "Text", "!"
assertEquals(5, text.size());

// Verify each segment's text
assertEquals("This is a ", text.get(0).getAsJsonObject().get("t").getAsString());
assertEquals("Multi", text.get(1).getAsJsonObject().get("t").getAsString());
assertEquals(" Colored ", text.get(2).getAsJsonObject().get("t").getAsString());
assertEquals("Text", text.get(3).getAsJsonObject().get("t").getAsString());
assertEquals("!", text.get(4).getAsJsonObject().get("t").getAsString());

// Optionally, verify colors
assertEquals("ffffff", text.get(0).getAsJsonObject().get("c").getAsString()); // Default color
assertEquals("cc33cc", text.get(1).getAsJsonObject().get("c").getAsString());
assertEquals("ffffff", text.get(2).getAsJsonObject().get("c").getAsString()); // Default color
assertEquals("34ab12", text.get(3).getAsJsonObject().get("c").getAsString());
assertEquals("ffffff", text.get(4).getAsJsonObject().get("c").getAsString()); // Default color
}

@Test
void testTextStartingWithColor() {
AwtrixApp app = new AwtrixApp();
app.setText("<font color=\"#ff0000\">Red</font> text at start");
app.setColor(new int[] { 0, 0, 0 }); // Default black color

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();

assertTrue(json.has("text"));
JsonArray text = json.get("text").getAsJsonArray();
assertEquals(2, text.size());
assertEquals("Red", text.get(0).getAsJsonObject().get("t").getAsString());
assertEquals(" text at start", text.get(1).getAsJsonObject().get("t").getAsString());
}

@Test
void testTextWithBrackets() {
AwtrixApp app = new AwtrixApp();
app.setText("<font color=\"#ff0000\">Red <- Nice!</font>");
app.setColor(new int[] { 0, 0, 0 }); // Default black color

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();

assertTrue(json.has("text"));
JsonArray text = json.get("text").getAsJsonArray();
assertEquals(1, text.size());
assertEquals("Red <- Nice!", text.get(0).getAsJsonObject().get("t").getAsString());
}

@Test
void testAdjacentColorSegments() {
AwtrixApp app = new AwtrixApp();
app.setText("<font color=\"#ff0000\">Red</font><font color=\"#0000ff\">Blue</font>");
app.setColor(new int[] { 0, 0, 0 });

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();

assertTrue(json.has("text"));
JsonArray text = json.get("text").getAsJsonArray();
assertEquals(2, text.size());
assertEquals("Red", text.get(0).getAsJsonObject().get("t").getAsString());
assertEquals("Blue", text.get(1).getAsJsonObject().get("t").getAsString());
}

@Test
void testAdjacentColorSegmentsWithSpace() {
AwtrixApp app = new AwtrixApp();
app.setText("<font color=\"#ff0000\">Red</font> <font color=\"#0000ff\">Blue</font>");
app.setColor(new int[] { 0, 0, 0 });

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();

assertTrue(json.has("text"));
JsonArray text = json.get("text").getAsJsonArray();
assertEquals(3, text.size());
assertEquals("Red", text.get(0).getAsJsonObject().get("t").getAsString());
assertEquals(" ", text.get(1).getAsJsonObject().get("t").getAsString());
assertEquals("Blue", text.get(2).getAsJsonObject().get("t").getAsString());
}

@Test
void testPlainText() {
AwtrixApp app = new AwtrixApp();
app.setText("Just plain text with no colors");
app.setColor(new int[] { 18, 52, 86 }); // Some default color

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();

assertTrue(json.has("text"));
String text = json.get("text").getAsString();
assertEquals("Just plain text with no colors", text);
}

@Test
void testEmptyString() {
AwtrixApp app = new AwtrixApp();
app.setText("");
app.setColor(new int[] { 0, 0, 0 });

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();

// Depending on implementation, the text field might be empty or not present
if (json.has("text")) {
assertTrue(json.get("text").getAsString().isEmpty());
}
}

@Test
void testOnlySpaces() {
AwtrixApp app = new AwtrixApp();
app.setText(" ");
app.setColor(new int[] { 0, 0, 0 });

String config = app.getAppConfig();
JsonObject json = JsonParser.parseString(config).getAsJsonObject();

assertTrue(json.has("text"));
assertEquals(" ", json.get("text").getAsString());
}
}