Skip to content
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
*.jar
*.tgz
*.zip
*.bash_history
*.gitconfig
*.sublime-project
*.sublime-workspace

/visu
/doc/build
/doc/sphinx_bootstrap_theme
23 changes: 19 additions & 4 deletions plugins/luxtronic2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
# Requirements
This plugin has no requirements or dependencies.

# How it works
At init it tries to connect to the device. If successful and if only one parameter, attribute or calculated is configured it reads the entire block. E.g. if one parameter is configured for reading, it reads the entire parameter block every 'cycle', but not the other ones. That’s by design of the device.
If the connection is lost or if the max number of timeouts ('max_timeouts') is reached it tries to reconnect every 'reconnect_cycle'.

# Configuration

## plugin.conf
Expand All @@ -12,11 +16,17 @@ This plugin has no requirements or dependencies.
class_path = plugins.luxtronic2
host = 192.168.0.123
# port = 8888
# cycle = 300
# reconnect_cycle = 60
# max_timeouts = 10
</pre>

### Attributes
* `host`: specifies the hostname of your heating server.
* `port`: if you want to use a nonstandard port.
* `cycle`: read cycle time in seconds.
* `reconnect_cycle`: cycle time for reconnect attempts in seconds.
* `max_timeouts`: max number of timeouts before connection is considered lost.

## items.conf

Expand Down Expand Up @@ -46,6 +56,12 @@ Defines a mapping to a attribute (read-only). All attribute values are bytes (nu
### lux2_c
Defines a mapping to a calculated value (read-only). All calculated values are integer (numbers).

### lux2_unpack
Python lambda function. Called before the value is sent towards the device.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unpack option sounds like the common eval setting for items. It evaluates the value and puts the result into the item. IMHO, I prefer to use common settings instead of having special plugin settings for all the plugins.

### lux2_pack
Called before the value is written to the smarthome item.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This could also be a common setting for items - something like eval_out - which works like eval, but the other way around when item is set.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see your point and I thought about it too. Something like eval_out would work most of the time, but it would fail for something like the example below. My thinking is that a plugin should provide you with a ready to use value and the other way around should take the unencoded value. E.g. for the knx plugin you have to provide knx_dpt and the plugin handles pack/unpack stuff.
First I only created *_pack for something like below and the way I think of plugins and I did *_unpack out of symmetry. E.g. one could use *_unpack to do the word/byte/... decoding stuff and eval for some value transformation (seconds to days).
I can make the *_pack and *_unpack optional, populated to hand the values through, but I would like to keep them.

 [[[dp1]]]
      type = num
      knx_dpt = 9
      knx_send = 10/4/8
      modbus_addr = Blub1 | 1 | 9 | 1
      modbus_type = HoldingRegister
      modbus_readInterval = -1
      modbus_pack = lambda x: [int((x * 1023) / 102.3)]

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ohinckel I looked a bit further into default values for modbus_pack (same for unpack and lux2).
I still don't see a good way for eval_out. To make something like my last example work, it would have to check the caller e.g. like: eval_out = return knxVal if caller == 'KNX' else return modbusVal
I gonna stick with pack(/unpack), if you don't have a better solution.
The somewhat defaults at the moment are lambda x: x. I think this means one does not have to use the pack/unpack functions if the type is set to list. If *_pack is used it could be any other type and it should be possible to use eval for unpacking. I did not test that.
So again I don't see any better way.

<pre>
[heating]
[[temp_outside]]
Expand All @@ -54,17 +70,16 @@ Defines a mapping to a calculated value (read-only). All calculated values are i
[[state_numeric]]
type = num
lux2_c = 119
lux2_unpack = lambda x: x/10.0
[[state]]
type = str
lux2 = 119
</pre>

## logic.conf

Currently there is no logic configuration for this plugin.
#### Pack Unpack

# Functions
lux_unpack and lux_pack require a standard Python lambda function. Pack is called before the value is sent towards the device, unpack for the other way around (device to Smarthome).

Currently there are no functions offered from this plugin.


133 changes: 113 additions & 20 deletions plugins/luxtronic2/__init__.py
100755 → 100644
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,9 @@
import threading
import struct
import time
from collections import defaultdict

logger = logging.getLogger('')
logger = logging.getLogger('luxtronic2')


class luxex(Exception):
Expand All @@ -35,12 +36,23 @@ class luxex(Exception):

class LuxBase():

def __init__(self, host, port=8888):
def __init__(self, host, port=8888, max_timeouts=10):
"""Summary

Args:
host (TYPE): Description
port (int, optional): Description
max_timeouts (int, optional): after this number
of timeouts occured the connection is closed.
If None its not evaluated.
"""
self.host = host
self.port = int(port)
self._sock = False
self._lock = threading.Lock()
self.is_connected = False
self._max_timeouts = max_timeouts
self._timeouts = 0 # number of already occured timeouts
self._connection_attempts = 0
self._connection_errorlog = 60
self._params = []
Expand Down Expand Up @@ -93,6 +105,13 @@ def close(self):
except:
pass

def __manage_timeouts(self):
self._timeouts += 1
if self._max_timeouts and self._timeouts >= self._max_timeouts:
self._timeouts = 0
self.close()
raise luxex("Max Nr timeouts reached: {}".format(self._max_timeouts))

def _request(self, request, length):
if not self.is_connected:
raise luxex("no connection to luxtronic.")
Expand All @@ -106,6 +125,7 @@ def _request(self, request, length):
answer = self._sock.recv(length)
except socket.timeout:
self._lock.release()
self.__manage_timeouts()
raise luxex("error receiving answer: timeout")
except Exception as e:
self._lock.release()
Expand All @@ -118,6 +138,7 @@ def _request_more(self, length):
return self._sock.recv(length)
except socket.timeout:
self._lock.release()
self.__manage_timeouts()
raise luxex("error receiving payload: timeout")
except Exception as e:
self._lock.release()
Expand Down Expand Up @@ -226,51 +247,101 @@ def refresh_calculated(self):


class Luxtronic2(LuxBase):
_parameter = {}
_attribute = {}
_calculated = {}
_parameter = defaultdict(lambda: defaultdict(int))
_attribute = defaultdict(lambda: defaultdict(int))
_calculated = defaultdict(lambda: defaultdict())
_decoded = {}
alive = True

def __init__(self, smarthome, host, port=8888, cycle=300):
LuxBase.__init__(self, host, port)
def __init__(self, smarthome, host, port=8888,
cycle=300, reconnect_cycle=60, max_timeouts=10):
LuxBase.__init__(self, host, port, max_timeouts)
self._sh = smarthome
self._cycle = int(cycle)
self.connect()
self._reconnect_cycle = int(reconnect_cycle)
try:
self.connect()
except Exception as e:
logger.error('luctronic2 connect faild: {}'.format(e))

def run(self):
self.alive = True
self._sh.scheduler.add('Luxtronic2', self._refresh, cycle=self._cycle)
# Letting the scheduler recall the foo by handing it "cycle" could
# fail in this application, because it calles the foo without checking
# if the last call finished. This could clog up the system.
threading.Thread(name='Lux2_cycle', target=self.__refresh).start()
threading.Thread(name='Lux2_recon', target=self._reconnect).start()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are there really cases were the update cylce needs too much time? Maybe using a short timeout for querying the device could help here.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See also my bug report for that. For now, it works with threading, but I could not get it working with the scheduler. I don't think it is about the socket timeout, because I also had problems with the scheduler in the modbus plugin too.


def stop(self):
self.alive = False

def _reconnect(self):
while self.alive:
start_time = time.time()
if not self.is_connected:
try:
logger.debug('lux2 trying reconnect')
self.connect()
except Exception as e:
logger.error('luctronic2 reconnect faild: {}'.format(e))
cycle_time = time.time() - start_time
time_to_sleep = self._reconnect_cycle - cycle_time
while self.alive and time_to_sleep > 0: # make it more easly interuptable
time_to_sleep -= .1
time.sleep(.1)

def __refresh(self):
while self.alive:
start_time = time.time()
self._refresh()
cycle_time = time.time() - start_time
time_to_sleep = self._cycle - cycle_time
while self.alive and time_to_sleep > 0: # make it more easly interuptable
time_to_sleep -= .1
time.sleep(.1)

def _refresh(self):
if not self.is_connected:
return
start = time.time()
if len(self._parameter) > 0:
self.refresh_parameters()
try:
self.refresh_parameters()
except Exception as e:
logger.error('luctronic2 refresh parameters faild: {}'
.format(e))
for p in self._parameter:
val = self.get_parameter(p)
if val:
self._parameter[p](val, 'Luxtronic2')
val = self._parameter[p]['unpack'](val)
self._parameter[p]['item'](val, 'Luxtronic2')
if len(self._attribute) > 0:
self.refresh_attributes()
try:
self.refresh_attributes()
except Exception as e:
logger.error('luctronic2 refresh attributes faild: {}'
.format(e))
for a in self._attribute:
val = self.get_attribute(a)
if val:
self._attribute[a](val, 'Luxtronic2')
val = self._attribute[a]['unpack'](val)
self._attribute[a]['item'](val, 'Luxtronic2')
if len(self._calculated) > 0 or len(self._decoded) > 0:
self.refresh_calculated()
try:
self.refresh_calculated()
except Exception as e:
logger.error('luctronic2 refresh calculated faild: {}'
.format(e))
for c in self._calculated:
val = self.get_calculated(c)
if val is not None:
self._calculated[c](val, 'Luxtronic2')
val = self._calculated[c]['unpack'](val)
self._calculated[c]['item'](val, 'Luxtronic2')
for d in self._decoded:
val = self.get_calculated(d)
if val is not None:
self._decoded[d](self._decode(d, val), 'Luxtronic2')
val = self._decoded[d]['unpack'](val)
self._decoded[d]['item'](self._decode(d, val), 'Luxtronic2')
cycletime = time.time() - start
logger.debug("cycle takes {0} seconds".format(cycletime))

Expand Down Expand Up @@ -328,22 +399,44 @@ def _decode(self, identifier, value):
return value

def parse_item(self, item):
def __reverseListOp(param):
# reversing list building of smarthome.py config parser for the
# binary or opperator |

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think putting the value in quotes will fix this problem too. Usually you should be using quotes around the value to avoid escaping problem.

Other setting may have this problem too, but we should not start to implement such an "unquoting mechanism" for each of these settings. A general solution, which already exists (using quotes), would be more clear and consistent.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did not know that quotes are supported, but if it works it's most definitely a better way to do it. I am going to fix it when there is time for it.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@ohinckel I just check the config parser again and quotes are not supported right now. I also tried it and there is no magic gunk happening somewhere else.

config.py line:89...

attr, __, value = line.partition('=')
...
attr = attr.strip()
...
if '|' in value:
    item[attr] = [strip_quotes(x) for x in value.split('|')]
else:
    item[attr] = strip_quotes(value)

So to allow list operators in combination with quotes, it would have to ignore pipes inside of quotes and split if there are pips outside of them. I did a quick hack, see below. If you have a better solution or if I missed something let me know.

def split_pipe(s):
    output = []
    split_pos = []
    quotes = ['"',"'"]
    pipe = '|'
    in_quoate = False
    start_quote = None
    
    for i in range(0, len(s)):
        if not in_quoate and s[i] == pipe:
            split_pos.append(i)
        
        if not in_quoate and s[i] in quotes:
            start_quote = s[i]
            in_quoate = True  
        elif in_quoate and s[i] == start_quote:
            in_quoate = False
            
    if len(split_pos) > 0:      
        output.append(s[0:split_pos[0]])
        for i in range(0,len(split_pos)):
            if i+1 < len(split_pos):
                output.append(s[split_pos[i]+1:split_pos[i+1]])
            else:
                output.append(s[split_pos[i]+1:])
        output = [strip_quotes(x) for x in output]
        return output
    else:
        return strip_quotes(s)

s = """'123|456'|"789'10'|1112"|131415 """

current = [strip_quotes(x) for x in s.split('|')]
for i in current:
    print(i)
print('-'*10)
x = split_pipe(s)
for i in x:
    print(i)
print('-'*10)
x = split_pipe('1')
print(x)

if isinstance(param, list):
return (' | '.join(param))
else:
return param

def add_pack_unpack(dps):
if 'lux2_pack' in item.conf:
dps['pack'] = eval(__reverseListOp(item.conf['lux2_pack']))
else:
dps['pack'] = lambda x: x
if 'lux2_unpack' in item.conf:
dps['unpack'] = eval(__reverseListOp(item.conf['lux2_unpack']))
else:
dps['unpack'] = lambda x: x

if 'lux2' in item.conf:
d = item.conf['lux2']
d = int(d)
self._decoded[d] = item
self._decoded[d]['item'] = item
add_pack_unpack(self._decoded[d])
if 'lux2_a' in item.conf:
a = item.conf['lux2_a']
a = int(a)
self._attribute[a] = item
self._attribute[a]['item'] = item
add_pack_unpack(self._attribute[a])
if 'lux2_c' in item.conf:
c = item.conf['lux2_c']
c = int(c)
self._calculated[c] = item
self._calculated[c]['item'] = item
add_pack_unpack(self._calculated[c])
if 'lux2_p' in item.conf:
p = item.conf['lux2_p']
p = int(p)
self._parameter[p] = item
self._parameter[p]['item'] = item
add_pack_unpack(self._calculated[p])
return self.update_item

def update_item(self, item, caller=None, source=None, dest=None):
Expand Down
Loading