Plugins/Plugwise

From Domoticz
Revision as of 15:44, 11 December 2023 by Walter vl (talk | contribs)
(diff) ← Older revision | Latest revision (diff) | Newer revision → (diff)
Jump to navigation Jump to search

This plugin communicates with the Plugwise Stretch 3.0 (End of Life!).

Unlike the plugwise2py, which communicates directly with the USB stick.

Features:

  • Basic switch operations
  • Logging op power consumption.

Installation

  1. Create a folder under the domoticz\plugins, called Plugwise. Another name will require a change in the python code.
  2. Create a file "plugin.py" in this folder, containing the code below (in plugin.txt, as the form does not allow .py files).3
  3. Restart domoticz
  4. Add hardware (Plugwise should be in the hardware list). Setup parameters....
  5. Add unused devices to the UI.

Devices should be added to the setup upon start of the plugin.

Note: In the plugin folder, a file is created 'devices.json'. This file contains the link between circle/sensor and unitid in domoticz. Do _not_ deleted (unused) devices in domoticz or this file if you do not know what you are doing as this will case a mismatch between plugwise/plugin/domoticz. Also, if debug is enabled, a log.txt file is created.

Reset

If you made a mess: delete the 'hardware', delete the devices.json and repeat from step 4.

Plugin

# Plugwise stretch 3.x plugin
#
# Author: Gerrit Hulleman
#
"""
<plugin key="PlugwiseStretch" name="Plugwise stretch 3.x plugin" author="Gerrit Hulleman" version="1.0.3" wikilink="http://www.domoticz.com/wiki/plugins/plugin.html" externallink="https://www.google.com/">
    <description>
        <h2>Plugwise stretch plugin</h2><br/>
        Uses the stretch to communicate with circles to read consumption (not total power usage) and toggle the relay. 
        <h3>Features</h3>
        <ul style="list-style-type:square">
                 <li>Toggle circle/stealth relays</li>
            <li>Read circle/stealth power usage</li>
        </ul>
        <h3>Recent history</h3>
        Version 1.0.0 - initial
		Version 1.0.2 - added try/catch handling and dumping
		Version 1.0.3 - removed modelname from code (not used)
    </description>
    <params>
        <param field="Address" label="Stretch host" width="200px" required="true" default="stretch08126a.fritz.box"/>
        <param field="Username" label="Stretch username" width="200px" required="true" default="stretch"/>
        <param field="Password" label="Stretch password/id" width="200px" required="true" default="" password="true"/>
        <param field="Mode1" label="Interval" width="200px" required="true" default="10"/>
        <param field="Mode6" label="Debug" width="75px">
            <options>
                <option label="True" value="Debug"/>
                <option label="False" value="Normal"  default="true" />
            </options>
        </param>
        
    </params>
</plugin>
"""
import json
import os               #for file IO operations
import urllib
import urllib.request
import base64
import xml.etree.ElementTree as ET
import time
import Domoticz

class BasePlugin:
    enabled = False
    debugMode = True
    devicesfile = 'plugins/Plugwise/devices.json'
    stretchHost = 'stretch08126a.fritz.box'
    stretchUsername = 'stretch'
    stretchPassword = ''
    heartbeat = 10

    unitId = 0

    serviceIdApplianceIdMap = {} #contains a serviceId -> applianceId key/value map so the module can be related to the appliance. 
    moduleIdDataMap = {} #contains module id (applianceId, mac address)
    applianceIdDataMap = {} #Contains appliance data (name)
    moduleDeviceKeyToUnit = {} #Contains  the unique key (moduleId+sensor name) and Domoticz unitId (for update)
    
    def __init__(self):
        #self.var = 123
        return

    def onStart(self):
        self.logMessage("Loading parameters")
        #if 'Parameters' in locals(): # in case of a debug, do not load the parameters. 
        self.stretchHost = Parameters["Address"]
        self.stretchPassword = Parameters["Username"]
        self.stretchPassword = Parameters["Password"]
        self.heartbeat = Parameters["Mode1"]
        if Parameters["Mode6"] != "Debug":
            Domoticz.Debugging(0)
            self.debugMode = False;

        self.logMessage("Loading appliances from stretch")
        # A call is made to the stretch to load the appliances. An appliance is a name and one/more actions/sensors (like the basic: relay on/off and power consumptions).
        #  a modules is a single device (also switches / sensors) and the only link between appliances and modules are the id's of the sensors in it
        data = self.callStretch('core/appliances')        
        root = ET.fromstring(data)
        self.logMessage("Processing appliances")
        for child in root.findall("./appliance"):
            name = child.find('name').text
            applianceId = child.attrib['id']
            if self.debugMode:
                self.logMessage("Appliance found: "+applianceId+' - '+name)
            self.applianceIdDataMap[applianceId] = { 'name' : name }
            # find all services (relay/power usage etc.) linked to the appliance. This is the link to the module later on
            for service in child.findall("./services/*"):
                serviceId = service.attrib['id']
                self.serviceIdApplianceIdMap[serviceId] = applianceId        
        # All known appliance are stored in the serviceIdApplianceIdMap, so the modules can be linked to the appliance based on the services they share
        # We might want to timeout the cached appliances, as new devices are not automatically added unles the plugin is restarted. Optimalisation

        self.logMessage("Loading mapped devices from config file")
        # Load mapped modules / devices from file
        #  this wil link services to existing devices, if any. 
        if os.path.exists(self.devicesfile):    
            with open(self.devicesfile) as json_file:
                data = json.load(json_file)
                for device in data['devices']:
                    if self.debugMode:
                        self.logMessage(json.dumps(device))
                    self.moduleDeviceKeyToUnit[device['devicekey']]= {'unit' : device['domoticzunit'],
                                                                      'applianceId' : device['applianceid'],
                                                                      'name': device['name']}
                    #print(p)
        self.logMessage("{} devices loaded from configuration file".format(len(self.moduleDeviceKeyToUnit)))        
        if self.debugMode:
            DumpConfigToLog()
        # find all devices in domoticz to find the last-used-unitid. In case new devices are created
        for x in Devices:
            if (x > self.unitId):
                self.unitId = x
        if self.debugMode:                
            self.logMessage("Last unitId : "+str(self.unitId))
            self.logMessage("Heartbeat : "+str(self.heartbeat))
        Domoticz.Heartbeat(int(self.heartbeat))
        self.logMessage("Plugin operational")
    def onStop(self):
        if self.debugMode:
            self.logMessage("onStop called")

    def onConnect(self, Connection, Status, Description):
        if self.debugMode:
            self.logMessage("onConnect called")

    def onMessage(self, Connection, Data):
        if self.debugMode:
            self.logMessage("onMessage called")

    def onCommand(self, Unit, Command, Level, Hue):
        if self.debugMode:
            self.logMessage("onCommand called for Unit " + str(Unit) + ": Parameter '" + str(Command) + "', Level: " + str(Level))
        #2019-11-04 16:08:13.300 (testplugwise) onCommand called for Unit 14: Parameter 'Off', Level: 0
        # TEST : only relay
        switchstatus = Command.lower() # on/off
        applianceId = self.getApplianceIdFromUnit(Unit)
        if applianceId == "":
            Domoticz.Error("Unable to find appliance for unit {}".format(Unit))
            return
        url= 'http://{host}/core/appliances;id={applianceId}/relay'.format(host=self.stretchHost, applianceId=applianceId)
        postdata = "<relay><state>" + switchstatus + "</state></relay>"
        if self.debugMode:
            self.logMessage("Call to: "+url)
        
        userAndPass = '{}:{}'.format(self.stretchUsername, self.stretchPassword)
        userAndPassBase64 = base64.b64encode(userAndPass.encode('ascii')).decode('ascii')
        #print (userAndPassBase64)
        headers = {'Authorization': "Basic %s" % userAndPassBase64,
                   "Content-Type":"application/xml"}
        data = '';
        req = urllib.request.Request(url, postdata.encode('ascii'), headers)
        try:
            with urllib.request.urlopen(req) as response:
                if response.getcode() != 202:
                    self.logMessage("Response on relay: {}".format(response.getcode()))
                data = response.read()            
        except urllib.error.HTTPError as e:
            # Return code error (e.g. 404, 501, ...)
            # ...
            self.logMessage('HTTPError: {}'.format(e.code))
        except urllib.error.URLError as e:
            # Not an HTTP-specific error (e.g. connection refused)
            # ...
            self.logMessage('URLError: {}'.format(e.reason))        
        return
      

    def onNotification(self, Name, Subject, Text, Status, Priority, Sound, ImageFile):
        if self.debugMode:
            self.logMessage("Notification: " + Name + "," + Subject + "," + Text + "," + Status + "," + str(Priority) + "," + Sound + "," + ImageFile)

    def onDisconnect(self, Connection):
        return

    def onHeartbeat(self):
        if self.debugMode:
            self.logMessage("onHeartbeat called")        
        data = self.callStretch('core/modules')
        #if self.debugMode:
        #    self.logMessage("Modules data: " + str(data, 'utf-8'))
        root = ET.fromstring(data)
        
        for child in root.findall("./module"):
            try:
                moduleData = self.getModuleDataFromNode(child)
                for service in child.findall("./services/*"):
                    name = service.tag
                    #get device. Unique id: moduleId / name
                    deviceKey = moduleData['moduleId']+'.'+name
                    deviceUnitId = self.getDeviceUnit(deviceKey)
                    
                    #if (name == "electricity_interval_meter"): # power usage? - not used at this moment
                    #    valDouble = 0
                    #    for measurement in service.findall('./measurement'):
                    #        multiplier = 1 #default consumed
                    #        if measurement.attrib['directionality'] == 'produced':
                    #            multiplier = -1
                    #        valDouble += float(measurement.text) * multiplier
                    #    print (moduleData['name'], "interval", name, valDouble)
                    if (name == "relay"):  # actual relay for on/off purposes
                        relayMeasurement = service.find('measurement')
                        state = "On" if relayMeasurement.text == "on" else "Off"
                        logDateTime = relayMeasurement.attrib['log_date']
                        
                        #self.logMessage(moduleData['name']+','+name+','+state+','+logDateTime+','+str(deviceUnitId))
                        
                        if deviceUnitId == 0:
                            # create device
                            applianceName = moduleData['name'];
                            applianceId = moduleData['applianceId']
                            self.logMessage("Name, id: "+applianceName+','+applianceId)
                            deviceUnitId = self.createDevice(applianceName, "Switch", deviceKey, applianceId)
                            
                        #self.logMessage(json.dumps(moduleData))
                        # the sub will check it the device needs updating
                        self.updateDevice(deviceUnitId, 1 if state == "On" else 0, state, False)
                    if (name == "electricity_point_meter"): #current voltage, check if update needed
                        valDouble = 0
                        logDateTime = ''
                        # this service has two measurements: produced and consumed. The logdate seemstime to be in sync
                        for measurement in service.findall('./measurement'):
                            multiplier = 1 #default consumed
                            if measurement.attrib['directionality'] == 'produced':
                                multiplier = -1
                            valDouble += float(measurement.text) * multiplier
                            logDateTime = measurement.attrib['log_date'] 
                        if (not name in moduleData) or (moduleData[name] != logDateTime): # check if the module has been updated (logDateTime stored in moduleData['electricity_point_meter'])
                            #print (moduleData['name'], "point", name, valDouble)
                            moduleData[name] = logDateTime
                            if deviceUnitId == 0:
                                # create device
                                deviceUnitId = self.createDevice(moduleData['name'], "kWh", deviceKey, moduleData['applianceId'])
                            self.updateDevice(deviceUnitId, 0, str(valDouble)+';0', True) # force update, even if the voltage has no changed. 
            except :
                # exception on child, log
                self.logMessage("Exception caught")
                if self.debugMode:
                    self.logMessage(ET.tostring(child).decode('utf-8'))

    def createDevice(self, Name, Type, DeviceKey, ApplianceId):
        self.unitId += 1
        self.logMessage("Create device: "+Name+","+Type+","+DeviceKey)
        newDevice = Domoticz.Device(Name=Name, Unit=self.unitId, TypeName=Type)
        newDevice.Create()
        self.logMessage("Add to map: "+str(newDevice.Unit))
        self.moduleDeviceKeyToUnit[DeviceKey]={'unit' : newDevice.Unit, 'applianceId': ApplianceId, 'name': Name}
        
        # save data to config file, as onStop is not called on restart.
        data = {}
        devices = []
        for keyVal in self.moduleDeviceKeyToUnit:
            self.logMessage("Key found: "+keyVal)
            devices.append({
                'devicekey': keyVal,
                'domoticzunit': self.moduleDeviceKeyToUnit[keyVal]['unit'],
                'applianceid': self.moduleDeviceKeyToUnit[keyVal]['applianceId'],
                'name': self.moduleDeviceKeyToUnit[keyVal]['name']
            })
        data['devices'] = devices    
        
        with open(self.devicesfile, 'w+') as outfile:
            json.dump(data, outfile)
        return newDevice.Unit                 
                 
    def callStretch(self, SubUrl):
        url= 'http://{host}/{subUrl}'.format(host=self.stretchHost, subUrl=SubUrl)        
        userAndPass = '{}:{}'.format(self.stretchUsername, self.stretchPassword)
        userAndPassBase64 = base64.b64encode(userAndPass.encode('ascii')).decode('ascii')        
        headers = {'Authorization': "Basic %s" % userAndPassBase64}
        data = '';
        req = urllib.request.Request(url, None, headers)
        try:
            with urllib.request.urlopen(req) as response:
                if response.getcode() != 200:
                    self.logMessage("Response on call: {}".format(response.getcode()))                
                data = response.read()            
        except urllib.error.HTTPError as e:
            # Return code error (e.g. 404, 501, ...)
            self.logMessage('HTTPError: {}'.format(e.code))
        except urllib.error.URLError as e:
            # Not an HTTP-specific error (e.g. connection refused)
            self.logMessage('URLError: {}'.format(e.reason))        
        return data

    # return: {applianceId, moduleId, mac, name} for a given module
    def getModuleDataFromNode(self, ModuleNode):
        moduleId = ModuleNode.attrib['id']
        # For this logic, a module is an appliance. The link between a module and appliance is the services they share, unique service id's
        if (moduleId in self.moduleIdDataMap):
            return self.moduleIdDataMap[moduleId];
        applianceId = ''
        # modelName = ModuleNode.find('vendor_model').attrib['model_name'] # name: scan, 
        for service in ModuleNode.findall("./services/*"):
            serviceId = service.attrib['id']            
            if (serviceId in self.serviceIdApplianceIdMap):
                applianceId = self.serviceIdApplianceIdMap[serviceId]
                break
        # Get addional information: MAC,appliance name
        macNode = ModuleNode.find('./protocols/network_router/mac_address')
        macAddress = ''
        applianceName = '-not applicable-' # not all modules have an appliance. Such as scans / switches
        if (macNode):
            macAddress = macNode.text
        if applianceId in self.applianceIdDataMap:
            applianceName = self.applianceIdDataMap[applianceId]['name']
        

        moduleData = { 'applianceId' : applianceId, 'moduleId' : moduleId, 'mac' : macAddress, 'name' : applianceName }
        
        self.moduleIdDataMap[moduleId] = moduleData        
        #print ("Module:", moduleId, modelName, applianceName)
        return moduleData        

    # return the unit (id from domoticz) for a given devicekey (combi of moduleid + type of service)
    #  return 0 if not found
    def getDeviceUnit(self, DeviceKey):
        if DeviceKey in self.moduleDeviceKeyToUnit:
            return self.moduleDeviceKeyToUnit[DeviceKey]['unit']
        return 0
    # return thee applianceId for a given unit (id from domoticz)
    #  note: multiple domoticz devices can be the same module/appliance (plug is both a switch as a power monitor)
    #  note: not indexed, just enumerator through the moduledevicekeys to find the unit and return the appliance. Only called on Command, so not a big impact on performance
    def getApplianceIdFromUnit(self, Unit):
        for key, value in self.moduleDeviceKeyToUnit.items():
            if value['unit'] == Unit:
                return value['applianceId']
        return ''
    # Update the given unit (domoticz Id), only if the value differs from previous or forced
    def updateDevice(self, Unit, nValue, sValue, forceUpdate):
        # Make sure that the Domoticz device still exists (they can be deleted) before updating it
        #self.logMessage("Update "+str(nValue)+":'"+str(sValue)+"' ("+str(Unit)+")")
        if (Unit in Devices):            
            if forceUpdate or (Devices[Unit].nValue != nValue) or (Devices[Unit].sValue != sValue):                
                Devices[Unit].Update(nValue=nValue, sValue=str(sValue))
                if self.debugMode:
                    self.logMessage("Update do "+str(nValue)+":'"+str(sValue)+"' ("+Devices[Unit].Name+")")
    
    def logMessage(self, Message):
        #if 'Domoticz'in locals():
        if self.debugMode:
            f= open("plugins/Plugwise/log.txt","a+")
            f.write(Message+'\r\n')
        Domoticz.Log(Message)

        
global _plugin
_plugin = BasePlugin()

def onStart():
    global _plugin
    _plugin.onStart()

def onStop():
    global _plugin
    _plugin.onStop()

def onConnect(Connection, Status, Description):
    global _plugin
    _plugin.onConnect(Connection, Status, Description)

def onMessage(Connection, Data):
    global _plugin
    _plugin.onMessage(Connection, Data)

def onCommand(Unit, Command, Level, Hue):
    global _plugin
    _plugin.onCommand(Unit, Command, Level, Hue)

def onNotification(Name, Subject, Text, Status, Priority, Sound, ImageFile):
    global _plugin
    _plugin.onNotification(Name, Subject, Text, Status, Priority, Sound, ImageFile)

def onDisconnect(Connection):
    global _plugin
    _plugin.onDisconnect(Connection)

def onHeartbeat():
    global _plugin
    _plugin.onHeartbeat()

    # Generic helper functions
def DumpConfigToLog():
    for x in Parameters:
        if Parameters[x] != "":
            Domoticz.Log( "'" + x + "':'" + str(Parameters[x]) + "'")
    Domoticz.Log("Device count: " + str(len(Devices)))
    for x in Devices:
        Domoticz.Log("Device:           " + str(x) + " - " + str(Devices[x]))
        Domoticz.Log("Device ID:       '" + str(Devices[x].ID) + "'")
        Domoticz.Log("Device UnitID:   '" + str(Devices[x].Unit) + "'")
        Domoticz.Log("Device DeviceID: '" + str(Devices[x].DeviceID) + "'")
        Domoticz.Log("Device Name:     '" + Devices[x].Name + "'")
        Domoticz.Log("Device nValue:    " + str(Devices[x].nValue))
        Domoticz.Log("Device sValue:   '" + Devices[x].sValue + "'")
        Domoticz.Log("Device LastLevel: " + str(Devices[x].LastLevel))
    return