/**
* HTD Lync 12 Whole House Audio
* Version 1.0.3
* Download: TODO: Update repo
* Description:
* This is a parent device handler designed to manage and control HTD Lync6/12 connected to the same network
* as via GW-SL1 gateway. This device handler requires the installation of a child device handler available from
* the github repo.
*-------------------------------------------------------------------------------------------------------------------
* Copyright 2022 Igor Kuznetsov
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
* documentation files (the 'Software'), to deal in the Software without restriction, including without limitation
* the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software,
* and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of
* the Software.
*
* THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
* THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
* CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
* IN THE SOFTWARE.
*-------------------------------------------------------------------------------------------------------------------
**/
metadata {
definition(
name: "HTD Lync 12 Whole House Audio Interface",
namespace: "igorkuz",
author: "Igor Kuz",
importUrl: "https://raw.githubusercontent.com/igorek24/HTD-Lync-12-Whole-House-Audio-Hubitata-Driver/main/htd_lync_12_whole_house_audio_interface.groovy"
) {
capability "HealthCheck"
capability "Switch"
capability "SwitchLevel"
capability 'Refresh'
capability 'Initialize'
capability "MediaTransport" // transportStatus - ENUM - ["playing", "paused", "stopped"]
command "getAllZonesStatus"
command "getId"
command "getFirmware"
command "createAllZones"
command "deleteAllZones"
command "A1Dev"
command "queryAll"
command "createZone", [[name:'Select Zone to create', type: 'ENUM', constraints: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ] ] ]
command "deleteZone", [[name:'Select Zone to delete', type: 'ENUM', constraints: [
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 ] ] ]
attribute "firmware", "string"
attribute "systemId", "string"
attribute "zones", "number"
attribute "sourceNames", "string"
attribute "sources", "number"
}
}
preferences{
input name: 'ipAddress', type: 'text', title: 'Gateway IP address',description: "Enter IP address of the GW-SL1 Gateway", required: true
input name: 'port', type: 'number', title: 'Gateway Port', required: true, defaultValue: 10006, description: 'IP Port for Gateway'
input "debugOn", "bool", title: "Enable debug logging for 1 hour", description: 'Debug logging will turn off automatically after 1 hour.', defaultValue: true
input "infoOn", "bool", title: "Enable info logging", description: 'Enable Info logging. You can disable it if you don\'t want to see it in your logs.', defaultValue: true
}
/**
*
* logging Functions
*
**/
void debugOff(){
log.warn "${device.getDisplayName()}: Debug logging disabled..."
device.updateSetting("debugOn",[value:"false",type:"bool"])
}
def debugLog(msg){
if (debugOn) {
log.debug " ${device.getDisplayName()}: ${msg}"
}
}
def infoLog(msg){
if (infoOn) log.info "${device.getDisplayName()}: ${msg}"
}
def warnLog(msg){
log.warn "${device.getDisplayName()}: ${msg}"
}
def errLog(msg){
log.error "${device.getDisplayName()}: ${msg}"
}
void configure() {}
def updated(){
getId()
unschedule()
initialize()
runIn(2, getFirmware, [overwrite: true])
runIn(4, createAllZones, [overwrite: true])
runIn(6, refresh, [overwrite: true])
runIn(8, queryAll, [overwrite: true])
}
void installed() {
device.setName("HTD Lync 12 Whole House Audio")
}
def uninstalled() {
unschedule()
log.info("${device.getDisplayName()}: Uninstalling, removing zone devices...")
deleteAllZones()
log.info "${device.getDisplayName()}: Uninstalled"
}
def initialize() {
if (debugOn) {
if (infoOn) infoLog("${linkText} debug logging enabled for 1 hour")
runIn(3600, debugOff, [overwrite: true])
}
}
void ping() {
if (ipAddress){
hubitat.helper.NetworkUtils.PingData pingData = hubitat.helper.NetworkUtils.ping(ipAddress, 3)
infoLog("Ping requests: ${pingData.packetsTransmitted} Responses received: ${pingData.packetsReceived}, lost packets : ${pingData.packetLoss}")
} else{ errLog("IP address is empty, ping not possible!")}
}
void refresh() {
getId()
runIn(1, getFirmware, [overwrite: true])
runIn(3, getAllZonesStatus, [overwrite: true])
}
// Used for dev to test
void A1Dev() {
//sendEvent(name: "systemId", value: "Lync12", displayed: true)
//device.updateSetting("source1Name",[value:"Source 1",type:"text"])
//state.systemId = null
// state.zones = null
//state.inputs = null
//sendEvent(name: "systemId", value: null, displayed: true)
//sendEvent(name: "zones", value: null, displayed: true)
// sendEvent(name: "inputs", value: null, displayed: true)
//sendEvent(name: "inputs", value: 18, displayed: true)
//device.updateDataValue("test2", "This is a test")
//device.removeDataValue("test")
//queryAll()
//state.systemId = 'Lync6'
//sendEvent(name: "systemId", value: 'Lync6', displayed: true)
//getZoneName(1)
//getSourceName(1)
}
/**
*
* Zone creation, deletion
*
**/
void createZone(zone) {
if(!zone){
errorLog("Zone must be between 1 and 12, received ${zone}")
return
}
def zoneNumRange = 1..12
if ( zoneNumRange.contains(zone as int) ) {
def zoneDeviceNetworkId = "${device.deviceNetworkId}-zd${zone}" as String
def zoneDeviceDisplayName = "${device.displayName} (Zone ${zone})"
def zoneDeviceLabel = "Zone ${zone}"
def zoneDevice = getChildDevices().contains(zoneDeviceDisplayName as String)
if(!getChildDevice(zoneDeviceNetworkId)) {
zoneDevice = addChildDevice("igorkuz", "HTD Lync 12 Whole House Audio Zone", zoneDeviceNetworkId, [name: zoneDeviceDisplayName,label: zoneDeviceLabel, isComponent: false])
infoLog("Creating zone ${zoneDeviceDisplayName} with network ID: ${zoneDeviceNetworkId}")
zoneDevice.setZoneNumber(zone)
zoneDevice.sendEvent(name: "zoneNumber", zone: null, displayed: true)
} else {
warnLog("Zone ${zone} child device already exist.")
}
} else {
errorLog("Invalid zone number: ${zone}")
}
}
void createAllZones() {
int zones = device.currentValue("zones") as int
if(zones == 6 || zones == 12){
infoLog("Creating all ${zones} zones.")
for (i in 1..zones){
createZone(i)
}
}
}
void deleteZone(zone) {
if(!zone){
errorLog("Zone must be between 1 and 12, received ${zone}")
return
}
def zoneNumRange = 1..12
if(getChildDevice("${device.deviceNetworkId}-zd${zone}")) {
if ( zoneNumRange.contains(zone as int) ) {
debugLog("Deleting zone ${zone} (${device.deviceNetworkId}-zd${zone}.)")
deleteChildDevice("${device.deviceNetworkId}-zd${zone}")
debugLog("Zone ${zone} (${device.deviceNetworkId}-zd${zone}) deleted.")
}else {
errLog("${getLinkText(device)}: Invalid zone number: ${zone}")
}
} else {
infoLog("Zone ${zone} doesn't exist, nothing to delete.")
}
}
void deleteAllZones() {
zones = getChildDevices()
zoneCount = zones.size()
if(zones){
debugLog("Zones: ${zones} will be deleted")
for (i in 1..zoneCount) {
deleteZone(i)
}
}else{
infoLog("No zones found, nothing to delete.")
}
}
void updateZoneState(zone,zoneStates) {
zoneDevice = getChildDevice("${device.deviceNetworkId}-zd${zone}")
if (zoneDevice) {
zoneDevice.updateState(zoneStates)
debugLog("Zone ${zone} state updated.")
} else {
debugLog("Zone doesn't exist, skipping zone ${zone} state update")
}
}
/**
*
* Message processing
*
**/
def bytesToAscii(bytes) {
//cleanBytes = bytes.replaceAll(0)
debugLog("Converting byte message to ASCII")
ascii = new String(bytes as byte[], "UTF-8");
clean_text = ascii.replaceAll("\\�","")
return clean_text
}
void setSystemIdState(systemId,zones,inputNumber) {
state.systemId = systemId as String
state.zones = zones as int
state.sources = inputNumber as int
sendEvent(name: "systemId", value: systemId, displayed: true)
sendEvent(name: "zones", value: zones, displayed: true)
sendEvent(name: "sources", value: inputNumber, displayed: true)
}
void processIdMsg(byteMsg) {
String systemId = bytesToAscii(byteMsg)
String stateSystemId = state.systemId
String stateFirmware = state.firmware
Integer stateSources = state.sources
debugLog("Received ID: ${systemId}")
if(systemId == "Lync12") {
zoneNumber = 12
inputNumber = 18
} else if(systemId == "Lync6") {
zoneNumber = 6
inputNumber = 12
} else {
zoneNumber = 0
inputNumber = 0
}
inputNumber = (stateFirmware.equals("v3") && stateSystemId.equals("Lync12"))? 19 : inputNumber
infoLog("ID message is: ${systemId} with ${zoneNumber} zones and ${inputNumber} sources")
if(!stateSystemId){
setSystemIdState(systemId,zoneNumber,inputNumber)
debugLog("State systemId not set, setting it to ${systemId}")
debugLog("Detected ${systemId} with ${zoneNumber} zones and ${inputNumber}")
}else if(!stateSystemId.equals("Lync12") && !stateSystemId.equals("Lync6")) {
setSystemIdState(systemId,zoneNumber,inputNumber)
debugLog("State system ID is set but I can't detect if it's Lync 6 or Lync 12, setting it to ${systemId}")
} else if(!stateSources.equals(inputNumber)) {
debugLog("Updating sources number for v3 to ${inputNumber}")
state.sources = inputNumber
sendEvent(name: "sources", value: inputNumber, displayed: true)
}
else {
debugLog("State system ID already set to ${systemId}, skiping this step.")
}
}
void processFirmwareMsg(byteMsg) {
// message 0x33 means firmware v3
infoLog('Firmware: v3 ')
firmware = "v3"
state.firmware = firmware
sendEvent(name: "firmware", value: firmware, displayed: true)
}
void processZoneStatusMsg(msg){
byteMsg = hubitat.helper.HexUtils.hexStringToByteArray(msg) as byte[]
intMsg = hubitat.helper.HexUtils.hexStringToIntArray(msg) as int[]
if(byteMsg[3] == 5){
zone = byteMsg[2]
debugLog("Received zone ${zone} status.")
if (zone == 0 && byteMsg[4] == 6) { // This comes from All Zones Status Message
processKeypadMsg(byteMsg)
return
}
// Process power, mute and dnd status
if(byteMsg[4] == -128 as byte || byteMsg[4] == 0 as byte) {
powerOn = false
muteOn = false
dndOnn = false
}else if (byteMsg[4] == -127 as byte || byteMsg[4] == 1 as byte) {
powerOn = true
muteOn = false
dndOnn = false
}else if (byteMsg[4] == -125 as byte || byteMsg[4] == 3 as byte){
powerOn = true
muteOn = true
dndOnn = false
}else if (byteMsg[4] == -123 as byte || byteMsg[4] == 5 as byte){
powerOn = true
muteOn = false
dndOnn = true
}else if (byteMsg[4] == -121 as byte || byteMsg[4] == 7 as byte){
powerOn = true
muteOn = true
dndOnn = true
}
//Process Doorbell status
if(intMsg[13] > 240 || intMsg[13] < 16){
doorbell = "off"
}else if (intMsg[4] == 100 || intMsg[13] < 240){
doorbell = "on"
} else { doorbell=""}
def power = (powerOn) ? 'on' : 'off'
def mute = (muteOn) ? 'on' : 'off'
def dnd = (dndOnn) ? 'on' : 'off'
def input = byteMsg[8]* 1 + 1 as int
def dB = byteMsg[9] as int
def treble = byteMsg[10] as int
def bass = byteMsg[11] as int
def balance = byteMsg[12] as int
mute = (dB == -60) ? "on" : mute
int volumePercentage = (dB + 60)*100/60
def keypadVolume = 60 + dB
def zoneDeviceNetworkId = "${device.deviceNetworkId}-zd${zone}" as String
if( getChildDevice(zoneDeviceNetworkId) ) {
def zoneStates = [
'switch' : power,
'mute' : mute,
'DND' : dnd,
'volume' : volumePercentage,
'level' : volumePercentage,
'dB' : dB,
'keypadVolume' : keypadVolume,
'bass' : bass,
'treble' : treble,
'balance' : balance,
'source' : input,
'zoneNumber': zone,
'doorbell': doorbell,
'sourceName': state."source${input}Name"
]
debugLog("Zone status message: ${zoneStates}")
updateZoneState(zone,zoneStates)
debugLog("Zone ${zone} state updated.")
}else{
debugLog("Zone ${zone} state not updated, zone child doesn't exist.")
}
}else{
warnLog("Invalid zone status message.")
debugLog("Invalid zone status message: ${byteMsg}")
}
}
void processAllZonesStatusMsg(msg) {
byteMsg = hubitat.helper.HexUtils.hexStringToByteArray(msg) as byte[]
// All zones status update 14 bites per zone
debugLog("Received all zones status update ${byteMsg}")
//byteMsg.properties.each{ log.info it}
def list = byteMsg.toList()
def zoneMsgs = list.collate( 14, false )
for(int i in 0..zoneMsgs.size()-1) {
zMsg = hubitat.helper.HexUtils.byteArrayToHexString(zoneMsgs[i] as byte[])
debugLog("Zone ${i} message is: ${zoneMsgs[i]}")
receiveMessage(zMsg)
}
}
void processAllZoneNamesMsg(msg) {
byteMsg = hubitat.helper.HexUtils.hexStringToByteArray(msg) as byte[]
// All zones Names update 18 bites per zone
debugLog("Received all zones Names update (Byte msg: ${byteMsg})")
debugLog("Received all zones Names update (HEX str msg: ${msg})")
def list = byteMsg.toList()
def nameMsgs = list.collate( 18, false )
for(int i in 0..nameMsgs.size()-1) {
zMsg = hubitat.helper.HexUtils.byteArrayToHexString(nameMsgs[i] as byte[])
debugLog("Zone ${i} message is: ${nameMsgs[i]}")
receiveMessage(zMsg)
}
}
void processAllInputNamesMsg(msg) {
byteMsg = hubitat.helper.HexUtils.hexStringToByteArray(msg) as byte[]
// All source Names update 18 bites per zone
debugLog("Received all source Names update (Byte msg: ${byteMsg})")
debugLog("Received all source Names update (HEX str msg: ${msg})")
def list = byteMsg.toList()
def nameMsgs = list.collate( 18, false )
for(int i in 0..nameMsgs.size()-1) {
zMsg = hubitat.helper.HexUtils.byteArrayToHexString(nameMsgs[i] as byte[])
debugLog("Source ${i} message is: ${nameMsgs[i]}")
processInputNameMsg(nameMsgs[i])
}
}
void processZoneNameMsg(byte[] byteMsg) {
def zone = byteMsg[2]
debugLog("Received zone ${zone} name message " + byteMsg)
def zoneName = bytesToAscii(byteMsg[4..13])
debugLog("Zone ${zone} name is: ${zoneName}")
updateZoneState(zone,['zoneName' : zoneName])
}
void processAllOnOffMsg(byteMsg) {
def zone = byteMsg[2]
debugLog("Received All On/Off response message" + byteMsg)
def list = byteMsg.toList()
def zoneMsgs = list.collate( 14, false )
for(int i in 0..zoneMsgs.size()-1) {
zMsg = hubitat.helper.HexUtils.byteArrayToHexString(zoneMsgs[i] as byte[])
debugLog("Zone ${i} message is: ${zoneMsgs[i]}")
receiveMessage(zMsg)
}
}
void processKeypadMsg(byteMsg) {
// TODO: figure out how to deal with it
infoLog("Received keypad message but it's still in TODO list ;)")
}
void processInputNameMsg(byteMsg){
def zone = byteMsg[2]
def inputNumber = byteMsg[15] + 1
debugLog("Received source ${inputNumber} name message " + byteMsg)
def inputName = bytesToAscii(byteMsg[4..13])
debugLog("Source ${inputNumber} controller name is: ${inputName}")
currentName = state."source${inputNumber}Name"
newName = inputName
if(!currentName.equals( newName )) {
state."source${inputNumber}Name" = inputName
infoLog("New source name is ${newName}, old one was ${currentName}")
}else{
debugLog("Source ${inputNumber} name is the same, no name change")
}
}
void processQueryAllResponseMessages(msg) {
def zoneNumber = state.zones
// When Query all, we receive 5 large messages
// 1. Echo All Zone Status.
// 2. Echo All Zone Names.
// 3. Echo All Source Name
// 4. Echo MP3 On/Off
// 5. Echo MP3 File Name and Artist Name
if(msg.size() >= 2000) {
def zoneStatusCharNum = (zoneNumber == 12)? 364 : 196 // All zone status message is in this char range
def zoneNameCharNum = (zoneNumber == 12)? 796 : 398
def zoneNameInputCharNum = (zoneNumber == 12)? 1480 : 740
String status = msg.substring(0, zoneStatusCharNum) // firs 364 chars are all zones status for Lync12 message on firs message
String names = msg.substring(zoneStatusCharNum, zoneNameCharNum)
String inputNames = msg.substring(zoneNameCharNum, zoneNameInputCharNum)
if(status[7] == "6" && status[35] == "5") {
processAllZonesStatusMsg(status)
}
if ((names[5] == "1") && (names[427] == "C")) {
processAllZoneNamesMsg(names)
}
if ((inputNames[7] == "E") && (inputNames[427] == "B")) {
processAllInputNamesMsg(inputNames)
}
}
}
/**
*
* Send command and receive message
*
**/
void receiveMessage(msg){
byteMsg = hubitat.helper.HexUtils.hexStringToByteArray(msg) as byte[]
// process ID Message (determine if it's Lync6 or Lync12)
if(byteMsg[0] == 0x4C){ // ID message
processIdMsg(byteMsg)
} else if(byteMsg[3] == 0x06 && byteMsg.size() > 14 && byteMsg.size() < 500){ // All zones status message
processAllZonesStatusMsg(msg)
} else if(byteMsg[3] == 0x06 && byteMsg.size() <= 14){ // Audio and Keypad Exist Channel
processKeypadMsg(byteMsg)
} else if(byteMsg[3] == 0x33){ // Firmware message
processFirmwareMsg(byteMsg)
} else if(byteMsg[3] == 5){ // Single Zone status message
processZoneStatusMsg(msg)
} else if(byteMsg[3] == 0x0D ) { // Zone Name message
processZoneNameMsg(byteMsg)
} else if(byteMsg[3] == 0x0E && byteMsg.size() <= 18 ) { // Source name message
processInputNameMsg(byteMsg)
} else if(byteMsg[4] == 0x09 ) { // MP3 Play End Stop
// Process MP3 Play End Stop
} else if(byteMsg[4] == 0x11 ) { // MP3 File Name
// TODO: Process mp3 file name
}
else if(byteMsg.size() >= 500 ) { // Query all response messages
processQueryAllResponseMessages(msg)
}
}
/**
*
* Send commands
*
**/
void getId() {
def cmd = [2,0,0,8,0,0x0A] as byte[]
sendCmd(cmd)
}
void getAllZonesStatus() {
def cmd = [2, 0, 0, 5, 0] as byte[]
sendCmd(cmd)
}
void getAllZonesNames() {
def cmd = [2, 0, 0, 6, 0, 7] as byte[]
sendCmd(cmd)
}
void queryAll() {
def cmd = [2, 0, 1, 0x0C, 0] as byte[]
sendCmd(cmd)
}
void getFirmware() {
def cmd = [2,0,0,0x0F,0,0x11] as byte[]
sendCmd(cmd)
}
void on() {
def cmd = [0x02,0x00,0x00,0x04,0x55] as byte[]
sendCmd(cmd)
}
void off(){
def cmd = [0x02,0x00,0x00,0x04,0x56] as byte[]
sendCmd(cmd)
}
void getZoneName(zone) {
def cmd = [2,0,zone,0x0D,0] as byte[]
sendCmd(cmd)
}
void getSourceName(input) {
input == --input
def cmd = [2,0,1,0x0E,input] as byte[]
sendCmd(cmd)
}
void changeZoneName(name) {
// TODO: make it work
}
void changeSourceName(name) {
// TODO: make it work
}
/**
*
* Send mp3 player commands
*
**/
void play(){
playPause()
}
void playPause(){
def cmd = [2,0,0,4,0x0B] as byte[]
sendCmd(cmd)
}
void pause(){
playPause()
}
void stop(){
def cmd = [2,0,0,4,0x0D] as byte[]
//sendCmd(cmd)
}
void sendCmd(byte[] byteMsg) {
def cksum = [0] as byte[]
for (byte i : byteMsg)
{
cksum[0] += i
}
debugLog("Cheksum computed as: ${cksum}")
def msgCksum = [byteMsg, cksum].flatten() as byte[]
def strMsg = hubitat.helper.HexUtils.byteArrayToHexString(msgCksum)
debugLog("Sending Message: ${strMsg} to ${ipAddress}:${port}")
interfaces.rawSocket.connect(ipAddress as String, port as int, 'byteInterface':true)
interfaces.rawSocket.sendMessage(strMsg)
//interfaces.rawSocket.close()
}
/**
*
* Asynchronous receive function
*
**/
void parse(String msg) {
debugLog("New message received: ${msg}")
receiveMessage(msg)
}