A word about HA -> HE integration

Here's a stab at genericizing some of the handling and also mapping component device types for more binary_sensor types: door, garage_door, moisture, motion, opening, presence, window

I started from @ogiewon's "UPDATE3" in this post and then added binary_sensor handling on top.

(Interim code removed. Now integrated into ymerj's GitHub repo)

EDIT: note that I changed the signature of createChild to account for the device_class entry where it was needed (just send null for that parameter otherwise), so make sure you pay attention if you mix-and-match code between different posts.

3 Likes

Wow! I'm happy people who knows what they are doing expand its fonctionalities.

@SmartHomePrimer Originally, for a child device to be created an event has to occur on HA. If a child device is deleted, one would need to trigger an event in order to recreate the child device. That's what @ogiewon did by physically flipping the switch.

4 Likes

As a side note, while developping that integration, I disabled the sun.sun integration on HA side because it litteraly spam the event bus every minute with a ton off useless information (to me that is). Not that it is important. It just prevent the parent device to be solicited every minute for nothing.

2 Likes

You set the whole structure and got the ideas in motion. :slight_smile:

You should put the code on GitHub or somewhere else where you can have version history and consider merging in changes from other people.

1 Like

I did, but since I'm clueless how GitHub works, I decided to host the file like this instead.

For GitHub I'm not even sure if this address is the one to give:

https://github.com/ymerj

2 Likes

@aaiyar

Maybe one of you would have added thermostat support by then, at the rate you all are going!!

[Kevin's MQTT app ([Beta] MQTT beta 3d (released 5th July) - #376 by kevin) already supports the "climate" device class. I can't compare it to this app/driver but I just started using MQTT for local status updates from my Ecobee. Seems to be working quite well.

I don't have slot of expertise in this area but the MQTT side of things does feel a bit clunky at times. Maybe this method will be cleaner?

I made a pull request for changes by @ogiewon and me. @ogiewon, you may want to review and make sure you like the couple of tweaks I made that impacted your sections.

1 Like

Pull request merged (I guess it's what I needed to do)

3 Likes

Now we need @SmartHomePrimer to test and see if the sensors and switches both work correctly.

2 Likes

Itā€™s a color tunable OSRAM. Just using for testing.

So youā€™re able to delete the child devices, then go right back and click initialize and the same child devices will regenerate?

@ymerj I did actuate the device to force a change. That wasnā€™t enough for the light, or the contact sensors to be regenerated. Itā€™s an odd thing Iā€™ve not seen before with child devices. Iā€™ll test some of the iterations and see how things progress with that. I didnā€™t try removing them and re-adding to HA yet.

Very please with the interest in this driver development.

Not quite. You need a device event on the HA side after initialize.

EDIT: for clarity, @ogiewon is right that as long as the ws connection was never closed you shouldn't even need the initialize step.

Are your switches polled by HA, @SmartHomePrimer? Maybe there is some lag before HA sees the update and publishes an event. Just a guess.

I didnā€™t even have to click Initialize after deleting a child device. Just needed to manually toggle the switch from the HA side.

1 Like

After you deleted the child device, you need to go to your wall switch and flip it so an event get generated (on HA) in order to recreate the child device. For the sensor, it should recreate the child device the next time it reports a change state to HA.

It is a event in HA that trigger the creation of a corresponding child device on HE.

Not tested with a switch yet. Just contact sensors and a bulb. Iā€™ll test some more. It was late (early :stuck_out_tongue_winking_eye:) so maybe something was flawed in my test.

Iā€™m testing with Home Assistant on a Mac (not HASS.IO). I do have HASS.IO working in Virtual Box, but not sure I can get the Zigbee stick connected that way. Need to test.

So far, it seems the only devices that are able to generate on HE are those that are directly paired on HA. The devices that were joined via HomeKit Controller on HA didnā€™t generate on the HE side.

Again, Iā€™ll need to test more. Wonā€™t be able to get to this until this evening though.

I'm not using or testing this integration but it still fascinates me. Oh how I wish all these options were there for me during my transition. Great job getting things started @ymerj and I had a feeling I would see @ogiewon lend his expertise on this one :wink:.

What devices are you referring to specifically? I skimmed the code and it looks like this may only be getting data from entities. If your HA devices do not have associated entities in HA they may not work. Just a guess, based on what I've read so far.

2 Likes

Thanks Stephan, Iā€™m very new to HA so there liable to be some of the finer points I donā€™t see.

There should be entities from the devices coming in via HomeKit Controller to HA, but again Iā€™m going to need to repeat my testing this evening to confirm I have not made a mistake somewhere.

@tomw Line 100 ( subdomain = response.event.data.attributes.device_class ) is causing a parse error in my log preventing status update. The command goes trough but no event are registered.

My bad...it should probably be:

subdomain = response.event.data.new_state.attributes.device_class

1 Like

yes that's it.

1 Like

@tomw Also seeing an issue with the Binary Sensor lookup code. Here is my latest version that you can merge in, as I see you already have a PR open. I also tried to suppress as many errors as possible.

/*
HA integration
*
* Description:
* Allow control of HA devices.
*
* Required Information:
* Home Asisstant IP and Port number
* Home Assistant long term Access Token
*
* Features List:
*
* Licensing:
* Copyright 2021 Yves Mercier.
* Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except
* in compliance with the License. You may obtain a copy of the License at: http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software distributed under the License is distributed
* on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License
* for the specific language governing permissions and limitations under the License.
*
* Version Control:
*
* 2021-02-06 Dan Ogorchock      Added basic support for simple "Light" devices from Home Assistant using Hubitat Generic Component Dimmer driver
* 2021-02-06 tomw               Added handling for some binary_sensor subtypes based on device_class
* 2021-02-06 Dan Ogorchock      Bug Fixes 
*
* Thank you(s):
*/

import groovy.json.JsonSlurper
import groovy.json.JsonOutput

metadata {
    definition (name: "HomeAssistant Hub Parent", namespace: "ymerj", author: "Yves Mercier") {
        capability "Initialize"

//        command "createChild", [[ name: "entity", type: "STRING", description: "HomeAssistant Entity ID" ]]
//        command "removeChild", [[ name: "entity", type: "STRING", description: "HomeAssistant Entity ID" ]]
//        command "closeConnection"
    }

    preferences {
        input ("ip", "text", title: "IP", description: "HomeAssistant IP Address", required: true)
        input ("port", "text", title: "Port", description: "HomeAssistant Port Number", required: true, defaultValue: "8123")
        input ("token", "text", title: "Token", description: "HomeAssistant Access Token", required: true)
        input ("logEnable", "bool", title: "Enable debug logging", defaultValue: true)
        input ("txtEnable", "bool", title: "Enable description text logging", defaultValue: true)        
    }
}

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

def updated(){
    log.info("updated...")
    log.warn("debug logging is: ${logEnable == true}")
    log.warn("description logging is: ${txtEnable == true}")
    unschedule()
    if (logEnable) runIn(1800,logsOff)
    initialize()
}

def initialize() {
    log.info("initializing...")
    state.id = 2
    auth = '{"type":"auth","access_token":"' + "${token}" + '"}'
    evenements = '{"id":1,"type":"subscribe_events","event_type":"state_changed"}'
    try {
        interfaces.webSocket.connect("ws://${ip}:${port}/api/websocket")
        interfaces.webSocket.sendMessage("${auth}")
        interfaces.webSocket.sendMessage("${evenements}")
    } 
    catch(e) {
        log.error("initialize error: ${e.message}")
    }
}

def uninstalled() {
    closeConnection()
}

def webSocketStatus(String status){
    if ((status == "status: open") || (status == "status: closing")) log.info("websocket ${status}")
    else {
        log.warn("WebSocket ${status}, trying to reconnect")
        runIn(10, initialize)
    }
}

def parse(String description) {
    if (logEnable) log.debug("parsed: $description")
    def response = null;
    try{
        response = new groovy.json.JsonSlurper().parseText(description)
        if (response.type != "event") return
        
        def entity = response?.event?.data.entity_id
        def domain = entity?.tokenize(".")[0]
        def subdomain = response?.event?.data?.attributes?.device_class
        if (!subdomain) subdomain = response?.event?.data?.new_state?.attributes?.device_class
        def friendly = response?.event?.data?.new_state?.attributes?.friendly_name
        def etat = response?.event?.data?.new_state?.state
        
        if (logEnable) log.debug "parse: domain: ${domain}, subdomain: ${subdomain}, entity: ${entity}, etat: ${etat}, friendly: ${friendly}"
        
        switch (domain) {
            case "switch":
                onOff(domain, entity, friendly, etat)
                break
            case "light":
                def level = response?.event?.data?.new_state?.attributes?.brightness
                if (level) {
                    level = level.toInteger()
                }
                onOffDim(domain, entity, friendly, etat, level)
                break
            default:
                if (subdomain) sendChildEvent(domain, subdomain, entity, friendly, etat)
        }
        return
    }  
    catch(e) {
        log.error("Parsing error: ${e}")
        return
    }
}

def onOff(domain, entity, friendly, etat) {
    if ((etat == "on") || (etat == "off")) {
        def ch = createChild(domain, null, entity, friendly)
        ch.parse([[name: "switch", value: etat, descriptionText:"${ch.label} was turned ${etat}"]])
    }
}

def onOffDim(domain, entity, friendly, etat, level) {
    
    onOff(domain, entity, friendly, etat)
    
    if (level) {
        def ch = createChild(domain, null, entity, friendly)
        level = (level * 100 / 255)
        level = Math.round(level) 
        ch.parse([[name:"level", value: level, descriptionText:"${ch.label} level set to ${level}"]])
    }
}

def sendChildEvent(domain, subdomain, entity, friendly, etat)
{
    def ch = createChild(domain, subdomain, entity, friendly)
    def mapping
    switch(domain)
    {
        case "binary_sensor":
            mapping = translateBinarySensorTypes(subdomain)
            break
    }
    if (mapping) {
        def name =  mapping?.attributes?.name ?: subdomain
        def value = mapping?.attributes?.states?."${etat}"?: "${etat}"
        ch.parse([[name: name, value: value, descriptionText:"${ch.label} updated"]])    
    }
}

def createChild(domain, subdomain, entity, friendly)
{
    String thisId = device.id
    def ch = getChildDevice("${thisId}-${entity}")
    if (!ch)
    {
        def deviceType
        switch(domain)
        {
            case "switch":
                deviceType = "Generic Component Switch"
                break
            case "light":
                deviceType = "Generic Component Dimmer"
                break
            case "binary_sensor":
                deviceType = translateBinarySensorTypes(subdomain).type
                break
            
            default:
                return null
        }
        
        ch = addChildDevice("hubitat", deviceType, "${thisId}-${entity}", [name: "${entity}", label: "${friendly}", isComponent: false])
    }
    
    return ch
}

def translateBinarySensorTypes(device_class)
{
    def mapping =
        [
            door: [type: "Generic Component Contact Sensor", attributes: [name: "contact", states: [on: "closed", off: "open"]]],
            garage_door: [type: "Generic Component Contact Sensor", attributes: [name: "contact", states: [on: "closed", off: "open"]]],
            moisture: [type: "Generic Component Water Sensor", attributes: [name: "water", states: [on: "wet", off: "dry"]]],
            motion: [type: "Generic Component Motion Sensor", attributes: [name: "motion", states: [on: "active", off: "inactive"]]],
            opening: [type: "Generic Component Contact Sensor", attributes: [name: "contact", states: [on: "closed", off: "open"]]],
            presence: [type: "Generic Component Presence Sensor", attributes: [name: "presence", states: [on: "present", off: "not present"]]],
            window: [type: "Generic Component Contact Sensor", attributes: [name: "contact", states: [on: "closed", off: "open"]]]
        ]
    return mapping[device_class]
}

def removeChild(entity){
    String thisId = device.id
    def ch = getChildDevice("${thisId}-${entity}")
    if (ch) {deleteChildDevice("${thisId}-${entity}")}
}
    
def componentOn(ch){
    if (logEnable) log.info("received on request from ${ch.label}")
    state.id = state.id + 1
    entity = ch.name
    domain = entity.tokenize(".")[0]
    if (!ch.currentValue("level")) {
        messOn = JsonOutput.toJson([id: state.id, type: "call_service", domain: "${domain}", service: "turn_on", service_data: [entity_id: "${entity}"]])
    }
    else {
        messOn = JsonOutput.toJson([id: state.id, type: "call_service", domain: "${domain}", service: "turn_on", service_data: [entity_id: "${entity}", brightness_pct: "${ch.currentValue("level")}"]])        
    }
    if (logEnable) log.debug("messOn = ${messOn}")
    interfaces.webSocket.sendMessage("${messOn}")
}

def componentOff(ch){
    if (logEnable) log.info("received off request from ${ch.label}")
    state.id = state.id + 1
    entity = ch.name
    domain = entity.tokenize(".")[0]
    messOff = JsonOutput.toJson([id: state.id, type: "call_service", domain: "${domain}", service: "turn_off", service_data: [entity_id: "${entity}"]])
    if (logEnable) log.debug("messOff = ${messOff}")
    interfaces.webSocket.sendMessage("${messOff}")
}

def componentSetLevel(ch, level, transition=1){
    if (logEnable) log.info("received setLevel request from ${ch.label}")
    if (level > 100) level = 100
    if (level < 0) level = 0
    state.id = state.id + 1
    entity = ch.name
    domain = entity.tokenize(".")[0]
    messLevel = JsonOutput.toJson([id: state.id, type: "call_service", domain: "${domain}", service: "turn_on", service_data: [entity_id: "${entity}", brightness_pct: "${level}", transition: "${transition}"]])
    if (logEnable) log.debug("messLevel = ${messLevel}")
    interfaces.webSocket.sendMessage("${messLevel}")
}

def componentRefresh(ch){
    if (logEnable) log.info("received refresh request from ${ch.label}")
}

def closeConnection() {
    if (logEnable) log.debug("Closing connection...")
    interfaces.webSocket.sendMessage('{"id":2,"type":"unsubscribe_events","subscription":1}')
    interfaces.webSocket.close()
}
1 Like