Need something like the Third Reality Night Light, but smaller/sleaker (WAF issue)

Anything special I need to do to switch to your drivers w/my Matter over Wi-Fi joined Shelly plug 4? Just normal driver change, right?

Should be, yes. There's a library that has literally all of the code, and the drivers themselves are just the metadata/capabilities (since Hubitat doesn't allow dynamic capabilities).

I'm working on getting some driver support together to control the LEDs on things with "PLUGS_UI" on Shelly (which is only on the Gen 4 Plug, presently).

From looking at the documentation on Shelly, and what this thread is about, I'm working up a "child RGB" device for them that'll act like an RGB light in Hubitat and on the Shelly Plug will change the LED to match.

The way the LEDs work on the Shelly side of things, there's an "on" setting, with 0-100 RGB value, and an "off" setting, also with 0-100 for RGB. From what I gather, this is so you can set one color for when the plug is "on" and another for when it is "off".

Is this what we want here? Or should the driver just apply the same RGB to both "on" and "off" settings, effectively overriding the "status light" functionality of the LEDs and turning them into just a night-light/indicator RGB for use in Hubitat. Right now I'm thinking of just having it be a unified RGB for both on/off states of the plug, so there's no confusion on Hubitat.

Otherwise it would need 2 child devices, one RGB light for when the plug is on and another for when it's off. That's easily workable, but I'm not sure if the "let's use this LED feature as a night light" folks would want to have to set 2 RGB devices on Hubitat every time they want to change the color.

I suppose I could add 2 options:
"Enable RGB child" and "Create separate RGB children for plug on/off states"? Just not sure if there's much use case for that?

Edit: Going to go with 2 options on devices with LEDs, one for "create unified RGB" and one for "create separate RGB children for power on and power off state".

I should have a Gen 4 Plug here soon to test with, but the code is mostly completed. Feel free to manually download the updated library and the "gen 4 plug" driver from the "Gen4" branch on GitHub, if you're adventurous. Note that I have not tested this whatsoever yet since I don't have a device in-hand. It's entirely coded "blind" based on documentation alone.

Thanks for confirming about the driver change.

Good/interesting questions about managing the LED states...

I don't want to try to speak for everyone, but for me I want to be able to set one color for both on/off, since I only use the LED colors for communicating status of other devices/groups of devices and don't want it changing if the switch turns on/off. I think that would probably be the most common desired usage around here, seeing the interest that has been shown for the Third Reality night light and this topic.

That said, it is very little additional work to choose two child devices vs one device in an RM device list when setting colors (click-click on the two child devices sitting next to each other in the Device list). So if you did create the two child devices I don't see it inconveniencing anyone significantly, and would provide more flexibility overall in LED use. A bigger tent, so to speak. :slight_smile:

That's my 2 cents, and I'll stick to it until someone points out that I've missed an obvious problem with my suggestion. :wink:

I agree with DANA, two children gives the added flexibility.

Plus, don’t forget it also supports a β€œnightlight” mode that has on/off times and brightness. I don’t know if you want to handle that also.

2 Likes

@TArman has not been sitting on his heels...more updates to his RPC driver, including simple on/off switch option, ability to choose color for each state (or all states) of the switch via Set Level command, ability to set power monitoring reporting intervals via Polling command, and an option to provide individual instances of power reporting via "Power" command. Nice stuff!


Code:

import groovy.transform.Field

import groovy.json.JsonSlurper

metadata {
    definition(
        name: "Shelly RPC Color Picker",
        namespace: "custom",
        author: "You"
    ) {

        capability "Switch"
        capability "Actuator"
		capability "SwitchLevel"
        
        attribute "color", "string"    
        
        command "Power"
        command "Reset"
		command "Red"
        command "Blue"
        command "Magenta"
        command "Yellow"
        command "Green"
        command "Orange"
        command "White"
		command "setLevel", [
 			[name:"Brightness*", description:"LED Brightness", type: "NUMBER"],
			[name:"Which LED", type:"ENUM", description:"Where applied...", constraints: ["ON","OFF", "BOTH", "NIGHT"]] ]
        command "polling", [
      		[name:"Rate*", description:"Poll rate in seconds",type: "INTEGER"]]
    }

    preferences {
        input name: "deviceIp",
              type: "text",
              title: "Device IP Address",
              defaultValue: "192.168.1.33",
              required: true

        input name: "requestTimeout",
              type: "number",
              title: "HTTP request timeout (seconds)",
              defaultValue: 10,
              range: "5..30"

        input name: "logDesc",
              type: "bool",
              title: "Enable descriptive text logging",
              defaultValue: true

        input name: "logDebug",
              type: "bool",
              title: "Enable debug logging",
              defaultValue: false
    }

}


// Lifecycle methods
def installed() {

}

def updated() {
    unschedule()
    if (logDebug) runIn(1800, logsOff) // Auto-disable debug logs after 30 min
}

def logsOff() {
    log.warn "Debug logging disabled"
    device.updateSetting("logDebug", [value: "false", type: "bool"])
}


/* =========================
   Button handlers
   ========================= */

def push(buttonNumber, String color) {    
    if (logDesc) log.info "Button ${buttonNumber} (${color}) pushed"
    sendEvent(name: "color", value: color, isStateChange: true)
    atomicState.LastColor = color
    applyConfig(buttonNumber)
   }

/* =========================
   RPC SETTINGS
   ========================= */

private String getRpcUrl() {
    String ip = settings.deviceIp ?: "192.168.20.102"
    return "http://${ip}/rpc"
}

@Field static final   String SET_METHOD = "PLUGS_UI.SetConfig"
@Field static  final String GET_METHOD = "PLUGS_UI.GetConfig"
@Field static  final String RPC_VERSION = "2.0"

/* =========================
   CONFIG DEFINITIONS
   ========================= */

private static Map createLedConfig(List<Number> rgb) {
    return [
        leds: [
            mode: "switch",
            colors: [
                "switch:0": [
                    on : [ rgb: rgb ],
                    off: [ rgb: rgb ]
                ]
            ]
        ]
    ]

}

private static Map createLedBrightBoth(Number level) {
    return [
        leds: [
            mode: "switch",
            colors: [
                "switch:0": [
                    on : [ brightness: level ],
                    off: [ brightness: level ]
                ]
            ]
        ]
    ]
}

private static Map createLedBrightOn(Number level) {
    return [
        leds: [
            mode: "switch",
            colors: [
                "switch:0": [
                    on : [ brightness: level ]
                ]
            ]
        ]
    ]
}

private static Map createLedBrightOff(Number level) {
    return [
        leds: [
            mode: "switch",
            colors: [
                "switch:0": [
                    off: [ brightness: level ]
                ]
            ]
        ]
    ]
}

private static Map createLedBrightNight(Number level) {
    return [
         leds: [
             night_mode: [
                 brightness: level                
            ]
        ]
    ]
}
def Blue() { push(1, "Blue") }
def Magenta() { push(2, "Magenta") }
def Yellow() { push(3, "Yellow") }
def Green() { push(4, "Green") }
def Orange() { push(5, "Orange") }
def Red() { push(6, "Red") }
def White() { push(7, "White") }

private Map getConfigForButton(Integer btn) {
    switch (btn) {
        case 1:
            return createLedConfig([0.0, 0.0, 100.0]) // Blue
        case 2:
            return createLedConfig([100.0, 0.0, 90.0]) // Magenta
        case 3:
            return createLedConfig([100.0, 100.0, 0.0]) // Yellow
        case 4:
            return createLedConfig([0.0, 100.0, 0.0]) // Green
        case 5:
            return createLedConfig([100.0, 50.0, 0.0]) // Orange
 		case 6:
            return createLedConfig([100.0, 0.0, 0.0]) // Red
		case 7:
  	 	    return createLedConfig([100.0, 100.0, 100.0]) // White
        default:
            log.warn "No config defined for button ${btn}"
            return null
    }
}

/* =========================
   RPC EXECUTION
   ========================= */

private void applyConfig(Integer buttonNumber) {
    Map config = getConfigForButton(buttonNumber)
    if (!config) return

    Map rpcBody = [
        jsonrpc: RPC_VERSION,
        id     : now(),
        method : SET_METHOD,
        params : [ config: config ]
    ]

    sendRpc(rpcBody, "Applied config ${buttonNumber}")
}


private void setLevel(Number level) {
	setLevel(level, "BOTH")
}
    

private void setLevel(Number level, String where) {  
    if (level < 0 || level > 100) {
        log.warn "Brightness not 0...100"
        return
    }
    
    Map config 

    if (where == "BOTH") {
 	    config = createLedBrightBoth(level)
     	sendEvent(name: "OnLevel", value: level, isStateChange: true)
        sendEvent(name: "OffLevel", value: level, isStateChange: true)
    }

    if (where == "ON") {
 	    config = createLedBrightOn(level)
     	sendEvent(name: "OnLevel", value: level, isStateChange: true)
    }

    if (where == "OFF") {
 	    config = createLedBrightOff(level)
        sendEvent(name: "OffLevel", value: level, isStateChange: true)
    }

    if (where == "NIGHT") {
 	    config = createLedBrightNight(level)
        sendEvent(name: "NightLevel", value: level, isStateChange: true)
    }

    if (!config) return
    
    Map rpcBody = [
        jsonrpc: RPC_VERSION,
        id     : now(),
        method : SET_METHOD,
        params : [ config: config ]
    ]
    String whereLevel = where  + "level"

    sendRpc(rpcBody, "Applied ${whereLevel}=${level}")
}

private void readCurrentConfig() {
    Map rpcBody = [
        jsonrpc: RPC_VERSION,
        id     : now(),
        method : GET_METHOD,
        params : [:]
    ]
    sendRpc(rpcBody, "Read current config")
}

def on()
	{
		urlString = getRpcUrl() + "/Switch.Set?id=0&on=true"
		String ans = urlString.toURL().text
		sendSwitchEvents("on","digital",0)
        if (logDesc) log.info "Turned on"
    }
    
def off()
	{
		urlString = getRpcUrl() + "/Switch.Set?id=0&on=false"
        String ans = urlString.toURL().text
		sendSwitchEvents("off","digital",0)
        if (logDesc) log.info "Turned off"
    }

@Field static Integer  pollRate = 22

def polling(Integer rate) {
    pollRate = rate
    atomicState.CurrentRate = rate 
    unschedule()
    if (pollRate > 0) {
        runIn(pollRate, Power) 
    	if (logDesc) log.info "Power polling every ${pollRate} seconds"
    } else {
        if (logDesc) log.info "Power polling disabled"
    }
}  


def Power()
	{
		urlString = getRpcUrl() + "/Switch.GetStatus?id=0"

		def jsonSlurper = new JsonSlurper()
		def object = jsonSlurper.parseText(urlString.toURL().text)
/*      aenergy:[total:137.099], apower:12.5, current:0.149, freq:60.0,*/ 
        String energy = object.aenergy.total
		String power = object.apower
		if ((atomicState.MaxPower = null)|| (power > atomicState.MaxPower)) atomicState.MaxPower = power
	            String current = object.current
		String volts = object.voltage
		String freq = object.freq

        sendEvent(name: "Voltage", value: volts, isStateChange: true)
        sendEvent(name: "Energy", value: energy, isStateChange: true)
        sendEvent(name: "Current", value: current, isStateChange: true)
        sendEvent(name: "Power", value: power, isStateChange: true)
        sendEvent(name: "Frequency", value: freq, isStateChange: true)
        if (logDesc) log.info "${object}"
        if (pollRate > 0) runIn(pollRate, Power) 
    }

def Reset()
{
    urlString = getRpcUrl() + '/Switch.ResetCounters?id=0&type=["aenergy","ret_aenergy"]'
    atomicState.MaxPower = 0
	def jsonSlurper = new JsonSlurper()
	def object = jsonSlurper.parseText(urlString.toURL().text)
    if (logDesc) log.info "${object}"
}


/*******************************************************************
 ***** Event Senders
********************************************************************/
//evt = [name, value, type, unit, desc, isStateChange]
void sendEventLog(Map evt, Integer ep=0) {
	//Set description if not passed in
	evt.descriptionText = evt.desc ?: "${evt.name} set to ${evt.value} ${evt.unit ?: ''}".trim()
	//Always send event to update last activity
	evt.isStateChange = true	
    sendEvent(evt)
}

void sendSwitchEvents(value, String type, Integer ep=0) {
	String desc = "switch is turned ${value}" + (type ? " (${type})" : "")
    sendEventLog(name:"switch", value:value, type:type, desc:desc, ep)
}

void sendBasicButtonEvent(buttonId, String name) {
	String desc = "button ${buttonId} ${name} (digital)"
	sendEventLog(name:name, value:buttonId, type:"digital", desc:desc, isStateChange:true)
}

def String parse(String stuff) {}
def String checkWebsocketConnection (String stuff) {}
    
private void sendRpc(Map rpcBody, String descText) {
    Map params = [
        uri: getRpcUrl(),
        requestContentType: "application/json",
        contentType: "application/json",
        body: rpcBody,
        timeout: settings.requestTimeout ?: 10,
        ignoreSSLIssues: true
    ]

    try {
        httpPost(params) { resp ->
            if (resp.status == 200) {
                if (logDesc) log.info descText
                if (logDebug) {
                    log.debug "RPC request: ${rpcBody}"
                    log.debug "RPC response: ${resp.data}"
                }
            } else {
                log.warn "RPC returned status ${resp.status}"
            }
        }
    } catch (groovy.json.JsonException e) {
        log.error "JSON parsing error: ${e.message}"
    } catch (java.net.SocketTimeoutException e) {
        log.error "Request timeout - check device connectivity"
    } catch (Exception e) {
        log.error "RPC failed: ${e.message}"
        if (logDebug) log.debug "Full exception: ${e}"
    }
}
2 Likes

I know this has been solved, but reading through it gave me an idea related to the first post:

The leds are connected in the back to a zigbee power strip with individual usb control. I think it looks neat and not bulky.

Now the only thing left is finding a use for it.

1 Like

Looks great, nicely done.

Curious about "...connected in the back to a zigbee power strip..." Is the power strip inside the wall, or do you have leads running from the LEDs to the power strip. I feel like this is going to turn out to be another of my "DOH" questions... :wink:

Other side of his wall:

8 Likes

This:

@John_Land you hid a camera in my closet?

3 Likes

Your significant other did, and streams to the world...

4 Likes

OK. So. @danabw inspired me to take up a some of the USB switch modules to build my own version of a notifier "thing" to supplement my Inovelli light bars.

Well. What a path I've wandered since then.

First up. "The Octopus".

I initially wanted 4 three state notifiers.

4 of the USB Switches, a compact USB hub, 16 6" USB extensions...a wood box...it worked, but what a mess.

Ultimately, the USB leds were going to require a bunch of effort to mount in a useful way.

So. Off to AliExpress & Amazon.

16 5v LEDs, a 16 port "web relay", a roll of 22AWG Zipwire, a distrbution board, a USB C 2 pin feed thru, some cheap wire connectors and a 15 Watt 5VDC wall wart with a USB C connection.

Photos of the proof-of-concept here: link

Lots of refinement to go. I need to use a smaller web relay (8 ports). I want to find different connectors, add some voltage control to reduce brightness, use a nice piece of Walnut with the electronics on the back, and perhaps some nice brass plate labels.

Thanks for the inspiration Dana!

S.

6 Likes

I feel both honored and a little frightened ... lights, so many lights ... :wink:

Looks great even at this early stage. Some nice labels next to each status light, various notification types (solid, slow fade in & out), and maybe a little stain on the wood? :slight_smile:

I can see orders pouring in to keep you out of retirement. :rofl:

Looking forward to v2!

1 Like

Ummm... yeah. About that. Label's (planned - check), stain (planned - check).

Fade in/Fade out. Not with this design -- right now, I have the ability to "on/off" the LEDs. I can grossly adjust the brightness as of this afternoon (I installed a Voltage Regulator module) like this.

But...fading in and out, or any sort of individual LED effects are a bit beyond my skill level at the moment.

I expect with a bit more research and some learning, I could direct attach the LEDs to something like a Pi or Arduino and achieve some sort of effects -- or perhaps with some sort of addressable LED strip.

Worth thinking on for sure. This weekend I'm going to try to sort out the ESP8266 so that instead of a hot spot, it joins the existing network. Then I can work on learning to talk to the Web Interface -- It's not exactly RESTful, but I think I can treat similarly, and use http get's to control the relays.

Lots to think about for sure.

Thanks for the kind words!

S.

2 Likes

I use Inovelli Blue switches/dimmers throughout my home. The LED bar on selected strategically positioned units is used to indicate various events, such as pulsing red when a door lock is open between late at night and waking hours the next day. Some of my experiments are noted here:

and here:

My home office Inovelli Blue unit got a bit overloaded with notifications, to the point that I sometimes didn't remember what color and pattern meant what for the less common events. So I put together one of the Zigbee USB block+LED's for a discrete indicator of the most important notifications:

I still was not satisfied: what I really wanted was a compact indicator unit that actually told me more about events. So I started playing a couple of months ago with Cheap Yellow Displays (CYD's). With the great help of Claude and ChatGPT, I programmed a 3.5" capacitive display unit to show messages sent by Rule Machine POST commands, like this:

The photos don't show all of the available characteristics of messages: flashing, pulsing, scrolling (vertical or horizontal), font size and color selection, etc., as well as a message history buffer:

The display doesn't sit idle while waiting for messages from Rule Machine. It's also a clock, local weather reporter, and perpetual calendar (either or both of which can be enabled to automatically show for X seconds every Y minutes):




There is also an HTML interface that allows message construction, shows device status, allows over-the-air updates of the CYD's (I have 4 of them now), night mode (reduced brightness between specified times), full clock configuration (colors, fonts, time and date format, screensaver settings), a web log for debugging, dark and light mode settings, and much more.

If anyone is interested, I can make the source code available. You will need the Arduino IDE to compile and load it into the CYD, and you MIGHT have to tweak 2 or 3 lines of standard graphics library code to get it to run. And, as-is, it will ONLY run on this device (about $25):

https://www.aliexpress.us/item/3256807380001174.html?spm=a2g0o.order_list.order_list_main.58.ec741802NIw0QC&gatewayAdapt=glo2usa

I'm only an amateur "programmer" (more of a "AI, add this feature" kind of guy), so the code is strictly on a permanent "beta" status with no guarantee of support (that's why you get the source code).

4 Likes

This is great. I have just bought a CYD to play with. Can you offer any guidance for the best place to find some code or sketches for them? What did you use to program it, did you use the Arduino IDE / Visual studio or something els?. My initial idea was to stream a CCTV camera to it

Below is a link to the current versions of my ESP32 MultiDisplay sketches. One variant is for a 5” Elecrow Advance device with capacitive touch screen, and the other is for a 3.5” JC3248W535C_I_Y device [i.e., a CYD device] with capacitive touch screen. I have older versions that run on a Waveshare CYD and on a resistive display CYD if you are interested (contact me), but they don’t have all of the functionality of the two github versions.

The Elecrow version includes MQTT Broker functionality. Below is a driver that allows Hubitat rules to publish messages to the broker. The non-broker β€œPlus” version of the code has MQTT subscriber functionality. I also have POST drivers if you are interested.

Note: I like the larger Elecrow display for my desk, but it has a problem with β€œtearing” at times, sometimes showing artifacts (e.g., random short line segments); my able coder, Claude AI, says it’s just the nature of that device. I have the smaller CYD’s in various areas of the house and garage.

I use the Arduino IDE v. 2.3.9 to compile and to monitor serial (print) output because the serial monitor auto disconnects during uploading and auto reconnects after loading completes. I edit code with Visual Studio Code. VSC and the Arduino IDE both monitor β€œsaves” to storage so saved code in VSC is automatically shown in the IDE.

/**
 * Hubitat MQTT Display Publisher
 * Replaces MultiPOST Sender for display message routing via MQTT.
 *
 * INSTALLATION:
 *   Drivers Code β†’ New Driver β†’ paste this file β†’ Save
 *   Devices β†’ Add Virtual Device β†’ select "MQTT Display Publisher"
 *   Configure the device with your broker IP/port.
 *
 * USAGE IN RULE MACHINE:
 *   Action: Run Custom Action on device "MQTT Display Publisher"
 *     - publishMessage(body)          β†’ display/message        (all displays)
 *     - publishClear()                β†’ display/clear          (all displays)
 *     - publishToGroup(group, body)   β†’ display/message/<group>
 *     - publishToDevice(name, body)   β†’ display/message/device/<name>
 *     - publishControl(json)          β†’ device/control
 *     - publishClockConfig(json)      β†’ device/clockconfig
 *     - publishScreen(screen)         β†’ display/screen
 *     - clearPublishCount()           β†’ resets the publishCount counter to 0
 *
 * GROUP NAMES:
 *   The group parameter in publishToGroup() is a free-form string matching
 *   the MQTT topic suffix used in the broker sketch:
 *     "elecrow" β†’ targets the Elecrow 5" display only
 *     "cyd"     β†’ targets all CYD displays only
 *   Add new groups to the broker sketch and use the same names here.
 *   The "Available Groups" preference below is a reminder of configured groups.
 *
 * DEVICE NAMES:
 *   The name parameter in publishToDevice() must exactly match the "name"
 *   field in the broker's displays.json (case-sensitive, including underscores).
 *   Example: "CYD_Bookcase"
 *
 * DEPENDENCIES:
 *   Requires the MQTT built-in capability introduced in Hubitat firmware 2.3.x
 *   (uses hubitat.device.HubAction with MQTT protocol β€” no external app needed).
 */

metadata {
    definition(
        name:        "MQTT Display Publisher 1.1",
        namespace:   "John Land",
        author:      "John Land and Claude AI",
        description: "Publishes display messages and control commands to an MQTT broker"
    ) {
        capability "Actuator"
        capability "Initialize"

        command "publishMessage", [
            [name: "Body*", type: "STRING",
             description: "JSON message body (same format your displays already accept)"]
        ]

        command "sendPosts", [
            [name: "Body*", type: "STRING",
            description: "Compatibility alias for publishMessage() β€” drop-in replacement for MultiPOST Sender's sendPosts command"]
        ]

        command "publishClear"

        command "publishToGroup", [
            [name: "Group*", type: "STRING",
             description: "Group name (e.g. elecrow, cyd). See 'Available Groups' preference."],
            [name: "Body*",  type: "STRING",
             description: "JSON message body"]
        ]

        command "publishToDevice", [
            [name: "DeviceName*", type: "STRING",
             description: "Exact display name from displays.json (e.g. CYD_Bookcase)"],
            [name: "Body*",       type: "STRING",
             description: "JSON message body"]
        ]

        command "publishControl", [
            [name: "Json*", type: "STRING",
             description: "JSON matching /control endpoint (e.g. {\"reboot\":true}, {\"clockdark\":\"toggle\"})"]
        ]

        command "publishClockConfig", [
            [name: "Json*", type: "STRING",
             description: "JSON matching POST /clockconfig endpoint"]
        ]

        command "publishScreen", [
            [name: "Screen*", type: "ENUM", constraints: ["clock", "weather", "calendar"],
             description: "Screen to navigate to"]
        ]

        command "reconnect"
        command "clearPublishCount"

        attribute "connectionStatus",  "STRING"   // "connected" / "disconnected"
        attribute "lastTopic",         "STRING"
        attribute "lastPublishBody",   "STRING"
        attribute "lastPublishTime",   "STRING"
        attribute "publishCount",      "NUMBER"
        attribute "lastError",         "STRING"   // cleared on success; set on publish exception
    }

    preferences {
        input name:  "brokerIP",
              type:  "string",
              title: "MQTT Broker IP address",
              description: "IP of your Elecrow 5\" broker",
              required: true,
              defaultValue: "192.168.1.32"

        input name:  "brokerPort",
              type:  "string",
              title: "MQTT Broker port",
              required: true,
              defaultValue: "1883"

        input name:  "clientId",
              type:  "string",
              title: "MQTT Client ID",
              description: "Unique ID for this Hubitat connection β€” must be unique across all MQTT clients",
              required: true,
              defaultValue: "hubitat-display"

        input name:  "defaultTopic",
              type:  "string",
              title: "Default topic for publishMessage()",
              description: "Topic used by publishMessage(). Change to 'display/message/elecrow' or 'display/message/cyd' to narrow the default target.",
              required: true,
              defaultValue: "display/message"

        input name:  "availableGroups",
              type:  "string",
              title: "Available Groups (reference only)",
              description: "Comma-separated list of group names configured in the broker sketch. " +
                           "Used as a reminder when calling publishToGroup() from Rule Machine. " +
                           "Does not affect behaviour β€” the group name is typed freely in each rule action.",
              required: false,
              defaultValue: "elecrow, cyd"

        input name:  "enableDebug",
              type:  "bool",
              title: "Enable debug logging",
              defaultValue: true
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// LIFECYCLE
// ─────────────────────────────────────────────────────────────────────────────

def installed() {
    log.info "${device.label}: installed"
    sendEvent(name: "publishCount", value: 0)
    sendEvent(name: "lastError",    value: "")
    initialize()
}

def updated() {
    log.info "${device.label}: updated β€” reconnecting"
    initialize()
}

def initialize() {
    try { mqttDisconnect() } catch (e) { /* ignore if not previously connected */ }
    mqttConnect()
}

// ─────────────────────────────────────────────────────────────────────────────
// MQTT CONNECTION
// ─────────────────────────────────────────────────────────────────────────────

def mqttConnect() {
    def broker = "tcp://${brokerIP}:${brokerPort}"
    if (enableDebug) log.debug "${device.label}: connecting to ${broker}"

    try {
        interfaces.mqtt.connect(broker, clientId, null, null)
        // Connection result arrives in mqttClientStatus() callback below
    } catch (e) {
        log.error "${device.label}: MQTT connect exception: ${e.message}"
        sendEvent(name: "connectionStatus", value: "disconnected")
        runIn(30, "initialize")
    }
}

def mqttDisconnect() {
    try { interfaces.mqtt.disconnect() } catch (e) { /* ignore */ }
}

void mqttClientStatus(String status) {
    if (enableDebug) log.debug "${device.label}: MQTT status: ${status}"

    if (status.startsWith("Status: Connection succeeded")) {
        sendEvent(name: "connectionStatus", value: "connected")
        log.info "${device.label}: MQTT connected to ${brokerIP}:${brokerPort}"
    } else {
        log.warn "${device.label}: MQTT disconnected β€” ${status} β€” retrying in 30s"
        sendEvent(name: "connectionStatus", value: "disconnected")
        runIn(30, "initialize")
    }
}

def reconnect() {
    log.info "${device.label}: Manual reconnect requested"
    initialize()
}

// ─────────────────────────────────────────────────────────────────────────────
// PUBLISH COMMANDS
// ─────────────────────────────────────────────────────────────────────────────

/**
 * Publish to the default topic (all displays by default).
 * Rule Machine: Run Custom Action β†’ publishMessage β†’ JSON body
 */
def publishMessage(String body) {
    publishToTopic(defaultTopic ?: "display/message", body)
}

/**
 * Compatibility alias for the old MultiPOST Sender 'sendPosts' command.
 * Delegates directly to publishMessage() so existing rules can be
 * migrated without modification.
 * Rule Machine: Run Custom Action β†’ sendPosts β†’ JSON body
 */
def sendPosts(String body) {
    if (enableDebug) log.debug "${device.label}: sendPosts() called β€” delegating to publishMessage()"
    publishMessage(body)
}

/**
 * Clear all displays.
 * Rule Machine: Run Custom Action β†’ publishClear
 */
def publishClear() {
    publishToTopic("display/clear", "{}")
}

/**
 * Publish to a named group of displays.
 * Rule Machine: Run Custom Action β†’ publishToGroup β†’ group name β†’ JSON body
 * Group names are free-form strings matching the broker sketch topic routing.
 * See 'Available Groups' preference for a reminder of configured names.
 */
def publishToGroup(String group, String body) {
    if (!group?.trim()) {
        log.warn "${device.label}: publishToGroup called with empty group name β€” ignored"
        return
    }
    publishToTopic("display/message/${group.trim()}", body)
}

/**
 * Publish to a single named display.
 * The name must exactly match the "name" field in the broker's displays.json.
 * Rule Machine: Run Custom Action β†’ publishToDevice β†’ device name β†’ JSON body
 */
def publishToDevice(String deviceName, String body) {
    if (!deviceName?.trim()) {
        log.warn "${device.label}: publishToDevice called with empty device name β€” ignored"
        return
    }
    publishToTopic("display/message/device/${deviceName.trim()}", body)
}

/**
 * Publish a control command to device/control.
 * Accepts the same JSON keys as POST /control on the broker sketch.
 * Rule Machine: Run Custom Action β†’ publishControl β†’ JSON
 * Examples: {"reboot":true}  {"clockdark":"toggle"}  {"clear":true}
 */
def publishControl(String json) {
    publishToTopic("device/control", json)
}

/**
 * Publish a clock configuration command to device/clockconfig.
 * Accepts the same JSON keys as POST /clockconfig on the broker sketch.
 * Rule Machine: Run Custom Action β†’ publishClockConfig β†’ JSON
 * Example: {"timefont":60,"nightmodeenable":true}
 */
def publishClockConfig(String json) {
    publishToTopic("device/clockconfig", json)
}

/**
 * Navigate to a screen on the broker display.
 * Rule Machine: Run Custom Action β†’ publishScreen β†’ screen name
 */
def publishScreen(String screen) {
    publishToTopic("display/screen", screen)
}

/**
 * Reset the publishCount counter to zero.
 * Rule Machine: Run Custom Action β†’ clearPublishCount
 */
def clearPublishCount() {
    sendEvent(name: "publishCount", value: 0)
    sendEvent(name: "lastError",    value: "")
    log.info "${device.label}: publishCount and lastError reset"
}

// Internal publish helper β€” used by all publish commands above
private publishToTopic(String topic, String body) {
    if (device.currentValue("connectionStatus") != "connected") {
        log.warn "${device.label}: Not connected β€” attempting reconnect before publish"
        initialize()
        pauseExecution(1000)
    }

    try {
        interfaces.mqtt.publish(topic, body, 1, false)
        // QoS 1 = at-least-once delivery; false = not retained

        int count = (device.currentValue("publishCount") ?: 0) as int
        sendEvent(name: "publishCount",    value: count + 1)
        sendEvent(name: "lastTopic",       value: topic)
        sendEvent(name: "lastPublishBody", value: body.take(200))
        sendEvent(name: "lastPublishTime", value: new Date().toString())
        sendEvent(name: "lastError",       value: "")

        if (enableDebug) log.debug "${device.label}: Published to ${topic}: ${body.take(80)}"

    } catch (e) {
        log.error "${device.label}: Publish failed: ${e.message}"
        sendEvent(name: "connectionStatus", value: "disconnected")
        sendEvent(name: "lastError",        value: e.message ?: "Publish exception")
        runIn(5, "initialize")
    }
}

// ─────────────────────────────────────────────────────────────────────────────
// INCOMING MESSAGES
// ─────────────────────────────────────────────────────────────────────────────

def parse(String message) {
    // This driver only publishes; it does not subscribe to any topics.
    if (enableDebug) log.debug "${device.label}: Received: ${message}"
}

3 Likes

That’s brilliant, much appreciated.