Skip to content

Commit

Permalink
0.18.1 (#192)
Browse files Browse the repository at this point in the history
- items can use ``timedelta`` in ``watch_change`` and ``watch_update``
- Added possibility to specify references in thing config
- ``MqttItem`` does no longer inherit from ``Item``
- Added ``MqttPairItem``:
  An item that consolidates a topic that reports states from a device and a topic that is used to write to a device.
- Updated documentation (big thanks to yfaway)
- Removed skipping of ``set_value`` and ``post_value`` in docs
  • Loading branch information
spacemanspiff2007 authored Dec 29, 2020
1 parent fd39a2a commit 42da4ad
Show file tree
Hide file tree
Showing 22 changed files with 358 additions and 76 deletions.
2 changes: 1 addition & 1 deletion HABApp/__version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = '0.18.0'
__version__ = '0.18.1'
10 changes: 7 additions & 3 deletions HABApp/core/items/base_item.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from pytz import utc

import HABApp
from .base_item_times import BaseWatch, ChangedTime, UpdatedTime
from .base_item_times import ItemNoChangeWatch, ItemNoUpdateWatch, ChangedTime, UpdatedTime
from .tmp_data import add_tmp_data as _add_tmp_data
from .tmp_data import restore_tmp_data as _restore_tmp_data

Expand Down Expand Up @@ -62,13 +62,15 @@ def __repr__(self):
ret += f'{", " if ret else ""}{k}: {getattr(self, k)}'
return f'<{self.__class__.__name__} {ret:s}>'

def watch_change(self, secs: typing.Union[int, float]) -> BaseWatch:
def watch_change(self, secs: typing.Union[int, float, datetime.timedelta]) -> ItemNoChangeWatch:
"""Generate an event if the item does not change for a certain period of time.
Has to be called from inside a rule function.
:param secs: secs after which the event will occur, max 1 decimal digit for floats
:return: The watch obj which can be used to cancel the watch
"""
if isinstance(secs, datetime.timedelta):
secs = secs.total_seconds()
if isinstance(secs, float):
secs = round(secs, 1)
else:
Expand All @@ -78,13 +80,15 @@ def watch_change(self, secs: typing.Union[int, float]) -> BaseWatch:
HABApp.rule.get_parent_rule().register_cancel_obj(w)
return w

def watch_update(self, secs: typing.Union[int, float]) -> BaseWatch:
def watch_update(self, secs: typing.Union[int, float, datetime.timedelta]) -> ItemNoUpdateWatch:
"""Generate an event if the item does not receive and update for a certain period of time.
Has to be called from inside a rule function.
:param secs: secs after which the event will occur, max 1 decimal digit for floats
:return: The watch obj which can be used to cancel the watch
"""
if isinstance(secs, datetime.timedelta):
secs = secs.total_seconds()
if isinstance(secs, float):
secs = round(secs, 1)
else:
Expand Down
1 change: 1 addition & 0 deletions HABApp/core/items/base_item_watch.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ def cancel(self):
asyncio.run_coroutine_threadsafe(self.__cancel_watch(), loop)

def listen_event(self, callback: typing.Callable[[typing.Any], typing.Any]) -> 'HABApp.core.EventBusListener':
"""Listen to (only) the event that is emitted by this watcher"""
rule = HABApp.rule.get_parent_rule()
cb = HABApp.core.WrappedFunction(callback, name=rule._get_cb_name(callback))
listener = HABApp.core.EventBusListener(
Expand Down
7 changes: 4 additions & 3 deletions HABApp/core/items/base_valueitem.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,23 @@ def set_value(self, new_value) -> bool:
self.value = new_value
return state_changed

def post_value(self, new_value):
def post_value(self, new_value) -> bool:
"""Set a new value and post appropriate events on the HABApp event bus
(``ValueUpdateEvent``, ``ValueChangeEvent``)
:param new_value: new value of the item
:return: True if state has changed
"""
old_value = self.value
self.set_value(new_value)
state_changed = self.set_value(new_value)

# create events
HABApp.core.EventBus.post_event(self._name, HABApp.core.events.ValueUpdateEvent(self._name, self.value))
if old_value != self.value:
HABApp.core.EventBus.post_event(
self._name, HABApp.core.events.ValueChangeEvent(self._name, value=self.value, old_value=old_value)
)
return None
return state_changed

def get_value(self, default_value=None) -> typing.Any:
"""Return the value of the item.
Expand Down
3 changes: 2 additions & 1 deletion HABApp/mqtt/items/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
from .mqtt_item import MqttItem
from .mqtt_item import MqttItem, MqttBaseItem
from .mqtt_pair_item import MqttPairItem
28 changes: 26 additions & 2 deletions HABApp/mqtt/items/mqtt_item.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,32 @@
import HABApp.mqtt.mqtt_interface
from HABApp.core.items import Item
from HABApp.core.items import BaseValueItem


class MqttItem(Item):
class MqttBaseItem(BaseValueItem):
pass


class MqttItem(MqttBaseItem):
"""A simple item that represents a topic and a value"""

@classmethod
def get_create_item(cls, name: str, initial_value=None) -> 'MqttItem':
"""Creates a new item in HABApp and returns it or returns the already existing one with the given name
:param name: item name
:param initial_value: state the item will have if it gets created
:return: item
"""
assert isinstance(name, str), type(name)

try:
item = HABApp.core.Items.get_item(name)
except HABApp.core.Items.ItemNotFoundException:
item = cls(name, initial_value)
HABApp.core.Items.add_item(item)

assert isinstance(item, cls), f'{cls} != {type(item)}'
return item

def publish(self, payload, qos: int = None, retain: bool = None):
"""
Expand Down
59 changes: 59 additions & 0 deletions HABApp/mqtt/items/mqtt_pair_item.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
from typing import Optional

import HABApp.mqtt.mqtt_interface
from . import MqttBaseItem


def build_write_topic(read_topic: str) -> Optional[str]:
parts = read_topic.split('/')
if parts[0] == 'zigbee2mqtt':
parts.insert(-1, 'set')
return '/'.join(parts)

raise ValueError(f'Can not build write topic for "{read_topic}"')


class MqttPairItem(MqttBaseItem):
"""An item that represents both a topic that is read only and a corresponding topic that is used to write values"""

@classmethod
def get_create_item(cls, name: str, write_topic: Optional[str] = None, initial_value=None) -> 'MqttPairItem':
"""Creates a new item in HABApp and returns it or returns the already existing one with the given name.
HABApp tries to automatically derive the write topic from the item name. In cases where this does not
work it can be specified manually.
:param name: item name (topic that reports the state)
:param write_topic: topic that is used to write values or ``None`` (default) to build it automatically
:param initial_value: state the item will have if it gets created
:return: item
"""
assert isinstance(name, str), type(name)

# try to build write topic
if write_topic is None:
write_topic = build_write_topic(name)

try:
item = HABApp.core.Items.get_item(name)
except HABApp.core.Items.ItemNotFoundException:
item = cls(name, write_topic=write_topic, initial_value=initial_value)
HABApp.core.Items.add_item(item)

assert isinstance(item, cls), f'{cls} != {type(item)}'
return item

def publish(self, payload, qos: int = None, retain: bool = None):
"""
Publish the payload under the write topic from the item.
:param payload: MQTT Payload
:param qos: QoS, can be ``0``, ``1`` or ``2``. If not specified value from configuration file will be used.
:param retain: retain message. If not specified value from configuration file will be used.
:return: 0 if successful
"""

return HABApp.mqtt.mqtt_interface.MQTT_INTERFACE.publish(self.write_topic, payload, qos=qos, retain=retain)

def __init__(self, name: str, initial_value=None, write_topic: Optional[str] = None):
super().__init__(name, initial_value)
self.write_topic: Optional[str] = write_topic
10 changes: 6 additions & 4 deletions HABApp/mqtt/mqtt_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,13 @@ def process_msg(self, client, userdata, message: mqtt.MQTTMessage):
if log_msg.isEnabledFor(logging.DEBUG):
log_msg._log(logging.DEBUG, f'{topic} ({message.qos}): {payload[:20]}...', [])

# try to get the mqtt item or create a MqttItem as a default
try:
_item = HABApp.core.Items.get_item(topic)
except HABApp.core.Items.ItemNotFoundException:
_item = HABApp.core.Items.create_item(topic, HABApp.mqtt.items.MqttItem)

# get the mqtt item
_item = HABApp.mqtt.items.MqttItem.get_create_item(topic)

# remeber state and update item before doing callbacks
# remember state and update item before doing callbacks
_old_state = _item.value
_item.set_value(payload)

Expand Down
34 changes: 30 additions & 4 deletions HABApp/openhab/connection_logic/plugin_things/thing_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ def ensure_same_types(a, b, key: str):
raise ValueError(f"Datatype of parameter '{key}' must be {_a} but is {_b}!")


re_ref = re.compile(r'\$(\w+)')


class ThingConfigChanger:
zw_param = re.compile(r'config_(?P<param>\d+)_(?P<width>\d+)(?P<bitmask>_\w+)?')
zw_group = re.compile(r'group_(\d+)')
Expand Down Expand Up @@ -62,17 +65,40 @@ def __init__(self, uid: str):
def __getitem__(self, key):
return self.org[self.alias.get(key, key)]

def __setitem__(self, o_key, item):
def __setitem__(self, o_key, value):
key = self.alias.get(o_key, o_key)
if key not in self.org:
raise KeyError(f'Parameter "{o_key}" does not exist for {self.uid}!')

# Make it possible to substitue refs with $1
if isinstance(value, str) and '$' in value:
o_value = value
refs = re_ref.findall(o_value)
# Since we use str.replace we need to start with the longest refs!
for ref in sorted(refs, key=len, reverse=True):
try:
_ref_key = int(ref)
except ValueError:
_ref_key = ref
_ref_key = self.alias.get(_ref_key, _ref_key)

try:
_ref_val = self.new.get(_ref_key, self.org[_ref_key])
except KeyError:
raise KeyError(f'Reference "{ref}" in "{o_value}" does not exist for {self.uid}!') from None

value = value.replace(f'${ref}', str(_ref_val))

log.debug(f'Evaluating "{value}"')
value = eval(value, {}, {})
log.debug(f' -> "{value}"')

org = self.org[key]
ensure_same_types(item, org, o_key)
ensure_same_types(value, org, o_key)

if item == org:
if value == org:
return None
self.new[key] = item
self.new[key] = value

def __contains__(self, key):
return self.alias.get(key, key) in self.org
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ async def update_thing_cfg(target_cfg, things, test: bool):
for c in cfgs:
try:
c[param] = value
except KeyError as e:
except Exception as e:
errs.add_exception(e)
skip_cfg.add(c)

Expand Down
2 changes: 1 addition & 1 deletion _doc/_plugins/sphinx_execute_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ def builder_ready(app):
else:
assert isinstance(folder, Path)
if not folder.is_dir():
log.error( f'Configuration execute_code_working_dir does not point to a directory: {folder}')
log.error(f'Configuration execute_code_working_dir does not point to a directory: {folder}')
WORKING_DIR = folder

# Search for a python package and print a warning if we find none
Expand Down
85 changes: 85 additions & 0 deletions _doc/advanced_usage.rst
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,91 @@ It will automatically update and always reflect the latest changes of ``MyInputI
.. autoclass:: HABApp.core.items.AggregationItem
:members:

Invoking OpenHAB actions
------------------------
The openhab REST interface does not expose _actions: https://www.openhab.org/docs/configuration/actions.html,
and thus there is no way to trigger them from HABApp. If it is not possible to create and OpenHAB item that
directly triggers the action there is a way to work around it with additional items within openhab.
An additional OpenHAB (note not HABapp) rule listens to changes on those items and invokes the appropriate
openhab actions.
On the HABApp side these actions are indirectly executed by setting the values for those items.

Below is an example how to invoke the openhab Audio and Voice actions.

First, define couple items to accept values from HABApp, and place them in /etc/openhab2/items/habapp-bridge.items:

.. code-block:: text
String AudioVoiceSinkName
String TextToSpeechMessage
String AudioFileLocation
String AudioStreamUrl
Second, create the JSR223 script to invoke the actions upon changes in the values of the items above.

.. code-block:: python
from core import osgi
from core.jsr223 import scope
from core.rules import rule
from core.triggers import when
from org.eclipse.smarthome.model.script.actions import Audio
from org.eclipse.smarthome.model.script.actions import Voice
SINK_ITEM_NAME = 'AudioVoiceSinkName'
@rule("Play voice TTS message")
@when("Item TextToSpeechMessage changed")
def onTextToSpeechMessageChanged(event):
ttl = scope.items[event.itemName].toString()
if ttl is not None and ttl != '':
Voice.say(ttl, None, scope.items[SINK_ITEM_NAME].toString())
# reset the item to wait for the next message.
scope.events.sendCommand(event.itemName, '')
@rule("Play audio stream URL")
@when("Item AudioStreamUrl changed")
def onTextToSpeechMessageChanged(event):
stream_url = scope.items[event.itemName].toString()
if stream_url is not None and stream_url != '':
Audio.playStream(scope.items[SINK_ITEM_NAME].toString(), stream_url)
# reset the item to wait for the next message.
scope.events.sendCommand(event.itemName, '')
@rule("Play local audio file")
@when("Item AudioFileLocation changed")
def onTextToSpeechMessageChanged(event):
file_location = scope.items[event.itemName].toString()
if file_location is not None and file_location != '':
Audio.playSound(scope.items[SINK_ITEM_NAME].toString(), file_location)
# reset the item to wait for the next message.
scope.events.sendCommand(event.itemName, '')
Finally, define the HABApp functions to indirectly invoke the actions:

.. code-block:: python
def play_local_audio_file(sink_name: str, file_location: str):
""" Plays a local audio file on the given audio sink. """
HABApp.openhab.interface.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name)
HABApp.openhab.interface.send_command(ACTION_AUDIO_LOCAL_FILE_LOCATION_ITEM_NAME, file_location)
def play_stream_url(sink_name: str, url: str):
""" Plays a stream URL on the given audio sink. """
HABApp.openhab.interface.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name)
HABApp.openhab.interface.send_command(ACTION_AUDIO_STREAM_URL_ITEM_NAME, url)
def play_text_to_speech_message(sink_name: str, tts: str):
""" Plays a text to speech message on the given audio sink. """
HABApp.openhab.interface.send_command(ACTION_AUDIO_SINK_ITEM_NAME, sink_name)
HABApp.openhab.interface.send_command(ACTION_TEXT_TO_SPEECH_MESSAGE_ITEM_NAME, tts)
Mocking OpenHAB items and events for tests
--------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions _doc/class_reference.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@

Additional class reference
==================================

Reference for returned classes from some functions.
These are not intended to be created by the user.

ItemNoUpdateWatch
---------------------------------

.. autoclass:: HABApp.core.items.base_item_watch.ItemNoUpdateWatch
:members:
:inherited-members:
:member-order: groupwise

ItemNoChangeWatch
---------------------------------

.. autoclass:: HABApp.core.items.base_item_watch.ItemNoChangeWatch
:members:
:inherited-members:
:member-order: groupwise
Loading

0 comments on commit 42da4ad

Please sign in to comment.