venv added, updated

This commit is contained in:
Norbert
2024-09-13 09:46:28 +02:00
parent 577596d9f3
commit 82af8c809a
4812 changed files with 640223 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
"""Module entry point."""
from .client import OpenHAB
__all__ = ['OpenHAB']

View File

@@ -0,0 +1,466 @@
"""python library for accessing the openHAB REST API."""
#
# Georges Toth (c) 2016-present <georges@trypill.org>
#
# python-openhab is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# python-openhab is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with python-openhab. If not, see <http://www.gnu.org/licenses/>.
#
import datetime
import logging
import typing
import authlib.integrations.httpx_client
import httpx
import openhab.items
import openhab.rules
from .config import Oauth2Config, Oauth2Token
__author__ = 'Georges Toth <georges@trypill.org>'
__license__ = 'AGPLv3+'
class OpenHAB:
"""openHAB REST API client."""
def __init__(self, base_url: str,
username: typing.Optional[str] = None,
password: typing.Optional[str] = None,
http_auth: typing.Optional[httpx.Auth] = None,
timeout: typing.Optional[float] = None,
oauth2_config: typing.Optional[typing.Dict[str, typing.Any]] = None,
) -> None:
"""Class constructor.
The format of the optional *oauth2_config* dictionary is as follows:
```python
{"client_id": "http://127.0.0.1/auth",
"token_cache": "/<path>/<to>/.oauth2_token",
"token":
{"access_token": "adsafdasfasfsafasfsafasfasfasfsa....",
"expires_in": 3600,
"refresh_token": "312e21e21e32112",
"scope": "admin",
"token_type": "bearer",
"user": {
"name": "admin",
"roles": [
"administrator"
]
}
}
```
Args:
base_url (str): The openHAB REST URL, e.g. http://example.com/rest
username (str, optional): A optional username, used in conjunction with a optional
provided password, in case openHAB requires authentication.
password (str, optional): A optional password, used in conjunction with a optional
provided username, in case openHAB requires authentication.
http_auth (Auth, optional): An alternative to username/password pair, is to
specify a custom http authentication object of type :class:`requests.Auth`.
timeout (float, optional): An optional timeout for REST transactions
oauth2_config: Optional OAuth2 configuration dictionary
Returns:
OpenHAB: openHAB class instance.
"""
self.url_rest = base_url
self.url_base = base_url.rsplit('/', 1)[0]
self.oauth2_config: typing.Optional[Oauth2Config] = None
if oauth2_config is not None:
self.oauth2_config = Oauth2Config(**oauth2_config)
self.session = authlib.integrations.httpx_client.OAuth2Client(client_id=self.oauth2_config.client_id,
token=self.oauth2_config.token.model_dump(),
update_token=self._oauth2_token_updater,
)
self.session.metadata['token_endpoint'] = f'{self.url_rest}/auth/token'
if not self.oauth2_config.token_cache.is_file():
self._oauth2_token_updater(self.oauth2_config.token.model_dump())
else:
self.session = httpx.Client(timeout=timeout)
if http_auth is not None:
self.session.auth = http_auth
elif not (username is None or password is None):
self.session.auth = httpx.BasicAuth(username, password)
self.logger = logging.getLogger(__name__)
self._rules: typing.Optional[openhab.rules.Rules] = None
@property
def rules(self) -> openhab.rules.Rules:
"""Get object for managing rules."""
if self._rules is None:
self._rules = openhab.rules.Rules(self)
return self._rules
@staticmethod
def _check_req_return(req: httpx.Response) -> None:
"""Internal method for checking the return value of a REST HTTP request.
Args:
req (requests.Response): A requests Response object.
Returns:
None: Returns None if no error occurred; else raises an exception.
Raises:
ValueError: Raises a ValueError exception in case of a non-successful
REST request.
"""
if not 200 <= req.status_code < 300:
req.raise_for_status()
def req_get(self, uri_path: str, params: typing.Optional[typing.Union[typing.Dict[str, typing.Any], list, tuple]] = None) -> typing.Any:
"""Helper method for initiating a HTTP GET request.
Besides doing the actual request, it also checks the return value and returns the resulting decoded
JSON data.
Args:
uri_path (str): The path to be used in the GET request.
Returns:
dict: Returns a dict containing the data returned by the OpenHAB REST server.
"""
r = self.session.get(f'{self.url_rest}{uri_path}', params=params)
self._check_req_return(r)
return r.json()
def req_post(self,
uri_path: str,
data: typing.Optional[typing.Union[str, bytes, typing.Mapping[str, typing.Any], typing.Iterable[
typing.Tuple[str, typing.Optional[str]]]]] = None,
) -> None:
"""Helper method for initiating a HTTP POST request.
Besides doing the actual request, it also checks the return value and returns the resulting decoded
JSON data.
Args:
uri_path (str): The path to be used in the POST request.
data (dict, optional): A optional dict with data to be submitted as part of the POST request.
Returns:
None: No data is returned.
"""
headers = self.session.headers
headers['Content-Type'] = 'text/plain'
r = self.session.post(self.url_rest + uri_path, content=data, headers=headers)
self._check_req_return(r)
def req_put(self,
uri_path: str,
data: typing.Optional[dict] = None,
json_data: typing.Optional[dict] = None,
headers: typing.Optional[dict] = None,
) -> None:
"""Helper method for initiating a HTTP PUT request.
Besides doing the actual request, it also checks the return value and returns the resulting decoded
JSON data.
Args:
uri_path (str): The path to be used in the PUT request.
data (dict, optional): A optional dict with data to be submitted as part of the PUT request.
json_data: Data to be submitted as json.
headers: Specify optional custom headers.
Returns:
None: No data is returned.
"""
if headers is None:
headers = {'Content-Type': 'text/plain'}
content = data
data = None
else:
content = None
r = self.session.put(self.url_rest + uri_path, content=content, data=data, json=json_data, headers=headers)
self._check_req_return(r)
# fetch all items
def fetch_all_items(self) -> typing.Dict[str, openhab.items.Item]:
"""Returns all items defined in openHAB.
Returns:
dict: Returns a dict with item names as key and item class instances as value.
"""
items = {} # type: dict
res = self.req_get('/items/')
for i in res:
if i['name'] not in items:
items[i['name']] = self.json_to_item(i)
return items
def get_item(self, name: str) -> openhab.items.Item:
"""Returns an item with its state and type as fetched from openHAB.
Args:
name (str): The name of the item to fetch from openHAB.
Returns:
Item: A corresponding Item class instance with the state of the requested item.
"""
json_data = self.get_item_raw(name)
return self.json_to_item(json_data)
def json_to_item(self, json_data: dict) -> openhab.items.Item:
"""This method takes as argument the RAW (JSON decoded) response for an openHAB item.
It checks of what type the item is and returns a class instance of the
specific item filled with the item's state.
Args:
json_data (dict): The JSON decoded data as returned by the openHAB server.
Returns:
Item: A corresponding Item class instance with the state of the item.
"""
_type = json_data['type']
if _type == 'Group' and 'groupType' in json_data:
_type = json_data['groupType']
if _type == 'Group' and 'groupType' not in json_data:
return openhab.items.GroupItem(self, json_data)
if _type == 'String':
return openhab.items.StringItem(self, json_data)
if _type == 'Switch':
return openhab.items.SwitchItem(self, json_data)
if _type == 'DateTime':
return openhab.items.DateTimeItem(self, json_data)
if _type == 'Contact':
return openhab.items.ContactItem(self, json_data)
if _type.startswith('Number'):
return openhab.items.NumberItem(self, json_data)
if _type == 'Dimmer':
return openhab.items.DimmerItem(self, json_data)
if _type == 'Color':
return openhab.items.ColorItem(self, json_data)
if _type == 'Rollershutter':
return openhab.items.RollershutterItem(self, json_data)
if _type == 'Player':
return openhab.items.PlayerItem(self, json_data)
return openhab.items.Item(self, json_data)
def get_item_raw(self, name: str) -> typing.Any:
"""Private method for fetching a json configuration of an item.
Args:
name (str): The item name to be fetched.
Returns:
dict: A JSON decoded dict.
"""
return self.req_get(f'/items/{name}')
def logout(self) -> bool:
"""OAuth2 session logout method.
Returns:
True or False depending on if the logout did succeed.
"""
if self.oauth2_config is None or not isinstance(self.session, authlib.integrations.httpx_client.OAuth2Client):
raise ValueError('You are trying to logout from a non-OAuth2 session. This is not supported!')
data = {'refresh_token': self.oauth2_config.token.refresh_token,
'id': self.oauth2_config.client_id,
}
url_logout = f'{self.url_rest}/auth/logout'
res = self.session.post(url_logout, data=data)
return res.status_code == 200
def _oauth2_token_updater(self, token: typing.Dict[str, typing.Any],
refresh_token: typing.Any = None,
access_token: typing.Any = None) -> None:
if self.oauth2_config is None:
raise ValueError('OAuth2 configuration is not set; invalid action!')
self.oauth2_config.token = Oauth2Token(**token)
with self.oauth2_config.token_cache.open('w', encoding='utf-8') as fhdl:
fhdl.write(self.oauth2_config.token.model_dump_json())
def create_or_update_item(self,
name: str,
_type: typing.Union[str, typing.Type[openhab.items.Item]],
quantity_type: typing.Optional[str] = None,
label: typing.Optional[str] = None,
category: typing.Optional[str] = None,
tags: typing.Optional[typing.List[str]] = None,
group_names: typing.Optional[typing.List[str]] = None,
group_type: typing.Optional[typing.Union[str, typing.Type[openhab.items.Item]]] = None,
function_name: typing.Optional[str] = None,
function_params: typing.Optional[typing.List[str]] = None,
) -> None:
"""Creates a new item in openHAB if there is no item with name 'name' yet.
If there is an item with 'name' already in openHAB, the item gets updated with the infos provided. be aware that not provided fields will be deleted in openHAB.
Consider to get the existing item via 'getItem' and then read out existing fields to populate the parameters here.
Args:
name: unique name of the item
_type: the data_type used in openHAB (like Group, Number, Contact, DateTime, Rollershutter, Color, Dimmer, Switch, Player)
server.
To create groups use 'GroupItem'!
quantity_type: optional quantity_type ( like Angle, Temperature, Illuminance (see https://www.openhab.org/docs/concepts/units-of-measurement.html))
label: optional openHAB label (see https://www.openhab.org/docs/configuration/items.html#label)
category: optional category. no documentation found
tags: optional list of tags (see https://www.openhab.org/docs/configuration/items.html#tags)
group_names: optional list of groups this item belongs to.
group_type: Optional group_type (e.g. NumberItem, SwitchItem, etc).
function_name: Optional function_name. no documentation found.
Can be one of ['EQUALITY', 'AND', 'OR', 'NAND', 'NOR', 'AVG', 'SUM', 'MAX', 'MIN', 'COUNT', 'LATEST', 'EARLIEST']
function_params: Optional list of function params (no documentation found), depending on function name.
"""
paramdict: typing.Dict[
str, typing.Union[str, typing.List[str], typing.Dict[str, typing.Union[str, typing.List[str]]]]] = {}
if isinstance(_type, type):
if issubclass(_type, openhab.items.Item):
itemtypename = _type.TYPENAME
else:
raise ValueError(
f'_type parameter must be a valid subclass of type *Item* or a string name of such a class; given value is "{str(_type)}"')
else:
itemtypename = _type
if quantity_type is None:
paramdict['type'] = itemtypename
else:
paramdict['type'] = f'{itemtypename}:{quantity_type}'
paramdict['name'] = name
if label is not None:
paramdict['label'] = label
if category is not None:
paramdict['category'] = category
if tags is not None:
paramdict['tags'] = tags
if group_names is not None:
paramdict['groupNames'] = group_names
if group_type is not None:
if isinstance(group_type, type):
if issubclass(group_type, openhab.items.Item):
paramdict['groupType'] = group_type.TYPENAME
else:
raise ValueError(
f'group_type parameter must be a valid subclass of type *Item* or a string name of such a class; given value is "{str(group_type)}"')
else:
paramdict['groupType'] = group_type
if function_name is not None:
if function_name not in (
'EQUALITY', 'AND', 'OR', 'NAND', 'NOR', 'AVG', 'SUM', 'MAX', 'MIN', 'COUNT', 'LATEST', 'EARLIEST'):
raise ValueError(f'Invalid function name "{function_name}')
if function_name in ('AND', 'OR', 'NAND', 'NOR') and (not function_params or len(function_params) != 2):
raise ValueError(f'Group function "{function_name}" requires two arguments')
if function_name == 'COUNT' and (not function_params or len(function_params) != 1):
raise ValueError(f'Group function "{function_name}" requires one arguments')
if function_params:
paramdict['function'] = {'name': function_name, 'params': function_params}
else:
paramdict['function'] = {'name': function_name}
self.logger.debug('About to create item with PUT request:\n%s', str(paramdict))
self.req_put(f'/items/{name}', json_data=paramdict, headers={'Content-Type': 'application/json'})
def get_item_persistence(self,
name: str,
service_id: typing.Optional[str] = None,
start_time: typing.Optional[datetime.datetime] = None,
end_time: typing.Optional[datetime.datetime] = None,
page: int = 0,
page_length: int = 0,
boundary: bool = False,
) -> typing.Iterator[typing.Dict[str, typing.Union[str, int]]]:
"""Method for fetching persistence data for a given item.
Args:
name: The item name persistence data should be fetched for.
service_id: ID of the persistence service. If not provided the default service will be used.
start_time: Start time of the data to return. Will default to 1 day before end_time.
end_time: End time of the data to return. Will default to current time.
page: Page number of data to return. Defaults to 0 if not provided.
page_length: The length of each page. Defaults to 0 which disabled paging.
boundary: Gets one value before and after the requested period.
Returns:
Iterator over dict values containing time and state value, e.g.
{"time": 1695588900122,
"state": "23"
}
"""
params: typing.Dict[str, typing.Any] = {'boundary': str(boundary).lower(),
'page': page,
'pagelength': page_length,
}
if service_id is not None:
params['serviceId'] = service_id
if start_time is not None:
params['starttime'] = start_time.isoformat()
if end_time is not None:
params['endtime'] = end_time.isoformat()
if start_time == end_time:
raise ValueError('start_time must differ from end_time')
res = self.req_get(f'/persistence/items/{name}', params=params)
yield from res['data']
while page_length > 0 and int(res['datapoints']) > 0:
params['page'] += 1
res = self.req_get(f'/persistence/items/{name}', params=params)
yield from res['data']

View File

@@ -0,0 +1,591 @@
"""python library for accessing the openHAB REST API."""
#
# Georges Toth (c) 2016-present <georges@trypill.org>
#
# python-openhab is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# python-openhab is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with python-openhab. If not, see <http://www.gnu.org/licenses/>.
#
# pylint: disable=bad-indentation
import abc
import datetime
import re
import typing
import dateutil.parser
__author__ = 'Georges Toth <georges@trypill.org>'
__license__ = 'AGPLv3+'
class CommandType(metaclass=abc.ABCMeta):
"""Base command type class."""
TYPENAME = ''
SUPPORTED_TYPENAMES: typing.List[str] = []
UNDEF = 'UNDEF'
NULL = 'NULL'
UNDEFINED_STATES = [UNDEF, NULL]
@classmethod
def is_undefined(cls, value: typing.Any) -> bool:
"""Return true if given value is an undefined value in openHAB (i.e. UNDEF/NULL)."""
return value in CommandType.UNDEFINED_STATES
@classmethod
def get_type_for(cls,
typename: str,
parent_cls: typing.Optional[typing.Type['CommandType']] = None,
) -> typing.Union[typing.Type['CommandType'], None]:
"""Get a class type for a given typename."""
if parent_cls is None:
parent_cls = CommandType
for a_type in parent_cls.__subclasses__():
if typename in a_type.SUPPORTED_TYPENAMES:
return a_type
# maybe a subclass of a subclass
result = a_type.get_type_for(typename, a_type)
if result is not None:
return result
return None
@classmethod
@abc.abstractmethod
def parse(cls, value: str) -> typing.Optional[typing.Any]:
"""Parse a given value."""
raise NotImplementedError
@classmethod
@abc.abstractmethod
def validate(cls, value: typing.Any) -> None:
"""Value validation method. As this is the base class which should not be used\
directly, we throw a NotImplementedError exception.
Args:
value (Object): The value to validate. The data_type of the value depends on the item
data_type and is checked accordingly.
Raises:
NotImplementedError: Raises NotImplementedError as the base class should never
be used directly.
"""
raise NotImplementedError
class UndefType(CommandType):
"""Undefined type."""
TYPENAME = 'UnDef'
SUPPORTED_TYPENAMES = [TYPENAME]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in UndefType.UNDEFINED_STATES:
return None
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method."""
class GroupType(CommandType):
"""Group type."""
TYPENAME = 'Group'
SUPPORTED_TYPENAMES = [TYPENAME]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in GroupType.UNDEFINED_STATES:
return None
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method."""
class StringType(CommandType):
"""StringType data_type class."""
TYPENAME = 'String'
SUPPORTED_TYPENAMES = [TYPENAME]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in StringType.UNDEFINED_STATES:
return None
if not isinstance(value, str):
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are any of data_type string.
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
StringType.parse(value)
class OnOffType(StringType):
"""OnOffType data_type class."""
TYPENAME = 'OnOff'
SUPPORTED_TYPENAMES = [TYPENAME]
ON = 'ON'
OFF = 'OFF'
POSSIBLE_VALUES = [ON, OFF]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in OnOffType.UNDEFINED_STATES:
return None
if value not in OnOffType.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``ON`` and ``OFF``.
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
OnOffType.parse(value)
class OpenCloseType(StringType):
"""OpenCloseType data_type class."""
TYPENAME = 'OpenClosed'
SUPPORTED_TYPENAMES = [TYPENAME]
OPEN = 'OPEN'
CLOSED = 'CLOSED'
POSSIBLE_VALUES = [OPEN, CLOSED]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in OpenCloseType.UNDEFINED_STATES:
return None
if value not in OpenCloseType.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``OPEN`` and ``CLOSED``.
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
OpenCloseType.parse(value)
class ColorType(CommandType):
"""ColorType data_type class."""
TYPENAME = 'HSB'
SUPPORTED_TYPENAMES = [TYPENAME]
@classmethod
def parse(cls, value: str) -> typing.Optional[typing.Tuple[float, float, float]]:
"""Parse a given value."""
if value in ColorType.UNDEFINED_STATES:
return None
if not isinstance(value, str):
raise ValueError
str_split = value.split(',')
if len(str_split) != 3:
raise ValueError
hs, ss, bs = value.split(',', 3)
h = float(hs)
s = float(ss)
b = float(bs)
if not ((0 <= h <= 360) and (0 <= s <= 100) and (0 <= b <= 100)):
raise ValueError
return h, s, b
@classmethod
def validate(cls, value: typing.Union[str, typing.Tuple[float, float, float]]) -> None:
"""Value validation method.
Valid values are in format H,S,B.
Value ranges:
H(ue): 0-360
S(aturation): 0-100
B(rightness): 0-100
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
if isinstance(value, str):
str_value = str(value)
elif isinstance(value, tuple) and len(value) == 3:
str_value = f'{value[0]},{value[1]},{value[2]}'
else:
raise ValueError
ColorType.parse(str_value)
class DecimalType(CommandType):
"""DecimalType data_type class."""
TYPENAME = 'Decimal'
SUPPORTED_TYPENAMES = [TYPENAME, 'Quantity']
@classmethod
def parse(cls, value: str) -> typing.Union[None, typing.Tuple[typing.Union[int, float], str]]:
"""Parse a given value."""
if value in DecimalType.UNDEFINED_STATES:
return None
m = re.match(r'(-?[0-9.]+(?:[eE]-?[0-9]+)?)\s?(.*)?$', value)
if m:
value_value = m.group(1)
value_unit_of_measure = m.group(2)
try:
if '.' in value:
return_value: typing.Union[int, float] = float(value_value)
else:
return_value = int(value_value)
except ArithmeticError as exc:
raise ValueError(exc) from exc
return return_value, value_unit_of_measure
raise ValueError
@classmethod
def validate(cls, value: typing.Union[int, float, typing.Tuple[typing.Union[int, float], str], str]) -> None:
"""Value validation method.
Valid values are any of data_type:
- ``int``
- ``float``
- a tuple of (``int`` or ``float``, ``str``) for numeric value, unit of measure
- a ``str`` that can be parsed to one of the above by ``DecimalType.parse``
Args:
value (int, float, tuple, str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
if isinstance(value, str):
DecimalType.parse(value)
elif isinstance(value, tuple) and len(value) == 2:
DecimalType.parse(f'{value[0]} {value[1]}')
elif not isinstance(value, (int, float)):
raise ValueError
class PercentType(CommandType):
"""PercentType data_type class."""
TYPENAME = 'Percent'
SUPPORTED_TYPENAMES = [TYPENAME]
@classmethod
def parse(cls, value: str) -> typing.Optional[float]:
"""Parse a given value."""
if value in PercentType.UNDEFINED_STATES:
return None
try:
f = float(value)
if not 0 <= f <= 100:
raise ValueError
return f
except Exception as e:
raise ValueError(e) from e
@classmethod
def validate(cls, value: typing.Union[float, int]) -> None:
"""Value validation method.
Valid values are any of data_type ``float`` or ``int`` and must be greater of equal to 0
and smaller or equal to 100.
Args:
value (float): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
if not (isinstance(value, (float, int)) and 0 <= value <= 100):
raise ValueError
class IncreaseDecreaseType(StringType):
"""IncreaseDecreaseType data_type class."""
TYPENAME = 'IncreaseDecrease'
SUPPORTED_TYPENAMES = [TYPENAME]
INCREASE = 'INCREASE'
DECREASE = 'DECREASE'
POSSIBLE_VALUES = [INCREASE, DECREASE]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in IncreaseDecreaseType.UNDEFINED_STATES:
return None
if value not in IncreaseDecreaseType.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``INCREASE`` and ``DECREASE``.
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
IncreaseDecreaseType.parse(value)
class DateTimeType(CommandType):
"""DateTimeType data_type class."""
TYPENAME = 'DateTime'
SUPPORTED_TYPENAMES = [TYPENAME]
@classmethod
def parse(cls, value: str) -> typing.Optional[datetime.datetime]:
"""Parse a given value."""
if value in DateTimeType.UNDEFINED_STATES:
return None
return dateutil.parser.parse(value)
@classmethod
def validate(cls, value: datetime.datetime) -> None:
"""Value validation method.
Valid values are any of data_type ``datetime.datetime``.
Args:
value (datetime.datetime): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
if not isinstance(value, datetime.datetime):
raise ValueError
class UpDownType(StringType):
"""UpDownType data_type class."""
TYPENAME = 'UpDown'
SUPPORTED_TYPENAMES = [TYPENAME]
UP = 'UP'
DOWN = 'DOWN'
POSSIBLE_VALUES = [UP, DOWN]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in UpDownType.UNDEFINED_STATES:
return None
if value not in UpDownType.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``UP`` and ``DOWN``.
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
UpDownType.parse(value)
class StopMoveType(StringType):
"""UpDownType data_type class."""
TYPENAME = 'StopMove'
SUPPORTED_TYPENAMES = [TYPENAME]
STOP = 'STOP'
POSSIBLE_VALUES = [STOP]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in StopMoveType.UNDEFINED_STATES:
return None
if value not in StopMoveType.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``UP`` and ``DOWN``.
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
StopMoveType.parse(value)
class PlayPauseType(StringType):
"""PlayPauseType data_type class."""
TYPENAME = 'PlayPause'
SUPPORTED_TYPENAMES = [TYPENAME]
PLAY = 'PLAY'
PAUSE = 'PAUSE'
POSSIBLE_VALUES = [PLAY, PAUSE]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in PlayPauseType.UNDEFINED_STATES:
return None
if value not in PlayPauseType.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``PLAY``, ``PAUSE``
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
PlayPauseType.parse(value)
class NextPrevious(StringType):
"""NextPrevious data_type class."""
TYPENAME = 'NextPrevious'
SUPPORTED_TYPENAMES = [TYPENAME]
NEXT = 'NEXT'
PREVIOUS = 'PREVIOUS'
POSSIBLE_VALUES = [NEXT, PREVIOUS]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in NextPrevious.UNDEFINED_STATES:
return None
if value not in NextPrevious.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``PLAY``, ``PAUSE``
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
NextPrevious.parse(value)
class RewindFastforward(StringType):
"""RewindFastforward data_type class."""
TYPENAME = 'RewindFastforward'
SUPPORTED_TYPENAMES = [TYPENAME]
REWIND = 'REWIND'
FASTFORWARD = 'FASTFORWARD'
POSSIBLE_VALUES = [REWIND, FASTFORWARD]
@classmethod
def parse(cls, value: str) -> typing.Optional[str]:
"""Parse a given value."""
if value in RewindFastforward.UNDEFINED_STATES:
return None
if value not in RewindFastforward.POSSIBLE_VALUES:
raise ValueError
return value
@classmethod
def validate(cls, value: str) -> None:
"""Value validation method.
Valid values are ``REWIND``, ``FASTFORWARD``
Args:
value (str): The value to validate.
Raises:
ValueError: Raises ValueError if an invalid value has been specified.
"""
super().validate(value)
RewindFastforward.parse(value)

View File

@@ -0,0 +1,72 @@
"""python library for accessing the openHAB REST API."""
#
# Georges Toth (c) 2016-present <georges@trypill.org>
#
# python-openhab is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# python-openhab is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with python-openhab. If not, see <http://www.gnu.org/licenses/>.
#
import pathlib
import time
import typing
import pydantic
'''Considering a oauth2 token config is expected to look like the following:
```
{"client_id": "http://127.0.0.1/auth",
"token_cache": "/<path>/<to>/.oauth2_token",
"token": {
"access_token": "adsafdasfasfsafasfsafasfasfasfsa....",
"expires_in": 3600,
"refresh_token": "312e21e21e32112",
"scope": "admin",
"token_type": "bearer",
"user": {
"name": "admin",
"roles": [
"administrator"
]
}
}
}
```
, the following classes model that structure for validation.
'''
class Oauth2User(pydantic.BaseModel):
"""Nested user structure within an oauth2 token."""
name: str
roles: typing.List[str]
class Oauth2Token(pydantic.BaseModel):
"""Structure as returned by openHAB when generating a new oauth2 token."""
access_token: str
expires_in: int
expires_at: float = time.time() - 10
refresh_token: str
scope: typing.Union[str, typing.List[str]] = 'admin'
token_type: str
user: Oauth2User
class Oauth2Config(pydantic.BaseModel):
"""Structure expected for a full oauth2 config."""
client_id: str = 'http://127.0.0.1/auth'
token_cache: pathlib.Path
token: Oauth2Token

View File

@@ -0,0 +1,9 @@
"""python-openhab exceptions."""
class OpenHABException(Exception):
"""Base of all python-openhab exceptions."""
class InvalidReturnException(OpenHABException):
"""The openHAB server returned an invalid or unparsable result."""

View File

@@ -0,0 +1,672 @@
"""python library for accessing the openHAB REST API."""
#
# Georges Toth (c) 2016-present <georges@trypill.org>
#
# python-openhab is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# python-openhab is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with python-openhab. If not, see <http://www.gnu.org/licenses/>.
#
import datetime
import logging
import re
import typing
import dateutil.parser
import openhab.command_types
import openhab.exceptions
__author__ = 'Georges Toth <georges@trypill.org>'
__license__ = 'AGPLv3+'
class Item:
"""Base item class."""
types: typing.Sequence[typing.Type[openhab.command_types.CommandType]] = []
state_types: typing.Sequence[typing.Type[openhab.command_types.CommandType]] = []
command_event_types: typing.Sequence[typing.Type[openhab.command_types.CommandType]] = []
state_event_types: typing.Sequence[typing.Type[openhab.command_types.CommandType]] = []
state_changed_event_types: typing.Sequence[typing.Type[openhab.command_types.CommandType]] = []
TYPENAME = 'unknown'
def __init__(self, openhab_conn: 'openhab.client.OpenHAB', json_data: dict) -> None:
"""Constructor.
Args:
openhab_conn (openhab.OpenHAB): openHAB object.
json_data (dic): A dict converted from the JSON data returned by the openHAB
server.
"""
self.openhab = openhab_conn
self.type_: typing.Optional[str] = None
self.quantityType: typing.Optional[str] = None
self.editable = None
self.label = ''
self.category = ''
self.tags = ''
self.groupNames = ''
self.group = False
self.name = ''
self._state = None # type: typing.Optional[typing.Any]
self._unitOfMeasure = ''
self._raw_state = None # type: typing.Optional[typing.Any] # raw state as returned by the server
self._members = {} # type: typing.Dict[str, typing.Any] # group members (key = item name), for none-group items it's empty
self.function_name: typing.Optional[str] = None
self.function_params: typing.Optional[typing.Sequence[str]] = None
self.logger = logging.getLogger(__name__)
self.init_from_json(json_data)
def init_from_json(self, json_data: dict) -> None:
"""Initialize this object from a json configuration as fetched from openHAB.
Args:
json_data (dict): A dict converted from the JSON data returned by the openHAB
server.
"""
self.name = json_data['name']
if json_data['type'] == 'Group':
self.group = True
if 'groupType' in json_data:
self.type_ = json_data['groupType']
if 'function' in json_data:
self.function_name = json_data['function']['name']
if 'params' in json_data['function']:
self.function_params = json_data['function']['params']
# init members
for i in json_data['members']:
self.members[i['name']] = self.openhab.json_to_item(i)
else:
self.type_ = json_data.get('type', None)
if self.type_ is None:
raise openhab.exceptions.InvalidReturnException('Item did not return a type attribute.')
parts = self.type_.split(':')
if len(parts) == 2:
self.quantityType = parts[1]
if 'editable' in json_data:
self.editable = json_data['editable']
if 'label' in json_data:
self.label = json_data['label']
if 'category' in json_data:
self.category = json_data['category']
if 'tags' in json_data:
self.tags = json_data['tags']
if 'groupNames' in json_data:
self.groupNames = json_data['groupNames']
self._raw_state = json_data['state']
if self.is_undefined(self._raw_state):
self._state = None
else:
self._state, self._unitOfMeasure = self._parse_rest(self._raw_state)
@property
def state(self) -> typing.Any:
"""The state property represents the current state of the item.
The state is automatically refreshed from openHAB on reading it.
Updating the value via this property send an update to the event bus.
"""
json_data = self.openhab.get_item_raw(self.name)
self.init_from_json(json_data)
return self._state
@state.setter
def state(self, value: typing.Any) -> None:
self.update(value)
@property
def unit_of_measure(self) -> str:
"""Return the unit of measure. Returns an empty string if there is none defined."""
return self._unitOfMeasure
@property
def members(self) -> typing.Dict[str, typing.Any]:
"""If item is a type of Group, it will return all member items for this group.
For none group item empty dictionary will be returned.
Returns:
dict: Returns a dict with item names as key and `Item` class instances as value.
"""
return self._members
def _validate_value(self, value: typing.Union[str, typing.Type[openhab.command_types.CommandType]]) -> None:
"""Private method for verifying the new value before modifying the state of the item."""
if self.type_ == 'String':
if not isinstance(value, (str, bytes)):
raise ValueError
elif self.types:
validation = False
for type_ in self.types:
try:
type_.validate(value)
except ValueError:
pass
else:
validation = True
if not validation:
raise ValueError(f'Invalid value "{value}"')
else:
raise ValueError
def _parse_rest(self, value: str) -> typing.Tuple[str, str]:
"""Parse a REST result into a native object."""
return value, ''
def _rest_format(self, value: str) -> typing.Union[str, bytes]:
"""Format a value before submitting to openHAB."""
_value = value # type: typing.Union[str, bytes]
# Only ascii encoding is supported by default. If non-ascii characters were provided, convert them to bytes.
try:
_ = value.encode('ascii')
except UnicodeError:
_value = value.encode('utf-8')
return _value
def is_undefined(self, value: str) -> bool:
"""Check if value is undefined."""
for aStateType in self.state_types:
if not aStateType.is_undefined(value):
return False
return True
def __str__(self) -> str:
"""String representation."""
state = self._state
if self._unitOfMeasure and not isinstance(self._state, tuple):
state = f'{self._state} {self._unitOfMeasure}'
return f'<{self.type_} - {self.name} : {state}>'
def _update(self, value: typing.Any) -> None:
"""Updates the state of an item, input validation is expected to be already done.
Args:
value (object): The value to update the item with. The type of the value depends
on the item type and is checked accordingly.
"""
# noinspection PyTypeChecker
self.openhab.req_put(f'/items/{self.name}/state', data=value)
def update(self, value: typing.Any) -> None:
"""Updates the state of an item.
Args:
value (object): The value to update the item with. The type of the value depends
on the item type and is checked accordingly.
"""
self._validate_value(value)
v = self._rest_format(value)
self._state = value
self._update(v)
def command(self, value: typing.Any) -> None:
"""Sends the given value as command to the event bus.
Args:
value (object): The value to send as command to the event bus. The type of the
value depends on the item type and is checked accordingly.
"""
self._validate_value(value)
v = self._rest_format(value)
self._state = value
self.openhab.req_post(f'/items/{self.name}', data=v)
def update_state_null(self) -> None:
"""Update the state of the item to *NULL*."""
self._update('NULL')
def update_state_undef(self) -> None:
"""Update the state of the item to *UNDEF*."""
self._update('UNDEF')
def is_state_null(self) -> bool:
"""If the item state is None, use this method for checking if the remote value is NULL."""
if self.state is None:
# we need to query the current remote state as else this method will not work correctly if called after
# either update_state method
if self._raw_state is None:
# This should never happen
raise ValueError('Invalid internal (raw) state.')
return self._raw_state == 'NULL'
return False
def is_state_undef(self) -> bool:
"""If the item state is None, use this method for checking if the remote value is UNDEF."""
if self.state is None:
# we need to query the current remote state as else this method will not work correctly if called after
# either update_state method
if self._raw_state is None:
# This should never happen
raise ValueError('Invalid internal (raw) state.')
return self._raw_state == 'UNDEF'
return False
def persistence(self,
service_id: typing.Optional[str] = None,
start_time: typing.Optional[datetime.datetime] = None,
end_time: typing.Optional[datetime.datetime] = None,
page: int = 0,
page_length: int = 0,
boundary: bool = False,
) -> typing.Iterator[typing.Dict[str, typing.Union[str, int]]]:
"""Method for fetching persistence data for a given item.
Args:
service_id: ID of the persistence service. If not provided the default service will be used.
start_time: Start time of the data to return. Will default to 1 day before end_time.
end_time: End time of the data to return. Will default to current time.
page: Page number of data to return. Defaults to 0 if not provided.
page_length: The length of each page. Defaults to 0 which disabled paging.
boundary: Gets one value before and after the requested period.
Returns:
Iterator over dict values containing time and state value, e.g.
{"time": 1695588900122,
"state": "23"
}
"""
yield from self.openhab.get_item_persistence(name=self.name,
service_id=service_id,
start_time=start_time,
end_time=end_time,
page=page,
page_length=page_length,
boundary=boundary,
)
class GroupItem(Item):
"""String item type."""
TYPENAME = 'Group'
types: typing.List[typing.Type[openhab.command_types.CommandType]] = []
state_types: typing.List[typing.Type[openhab.command_types.CommandType]] = []
class StringItem(Item):
"""String item type."""
TYPENAME = 'String'
types = [openhab.command_types.StringType]
state_types = types
class DateTimeItem(Item):
"""DateTime item type."""
TYPENAME = 'DateTime'
types = [openhab.command_types.DateTimeType]
state_types = types
def __gt__(self, other: datetime.datetime) -> bool:
"""Greater than comparison."""
if self._state is None or not isinstance(other, datetime.datetime):
raise NotImplementedError('You can only compare two DateTimeItem objects.')
return self._state > other
def __ge__(self, other: datetime.datetime) -> bool:
"""Greater or equal comparison."""
if self._state is None or not isinstance(other, datetime.datetime):
raise NotImplementedError('You can only compare two DateTimeItem objects.')
return self._state >= other
def __lt__(self, other: object) -> bool:
"""Less than comparison."""
if not isinstance(other, datetime.datetime):
raise NotImplementedError('You can only compare two DateTimeItem objects.')
return not self.__gt__(other)
def __le__(self, other: object) -> bool:
"""Less or equal comparison."""
if self._state is None or not isinstance(other, datetime.datetime):
raise NotImplementedError('You can only compare two DateTimeItem objects.')
return self._state <= other
def __eq__(self, other: object) -> bool:
"""Equality comparison."""
if not isinstance(other, datetime.datetime):
raise NotImplementedError('You can only compare two DateTimeItem objects.')
return self._state == other
def __ne__(self, other: object) -> bool:
"""Not equal comparison."""
if not isinstance(other, datetime.datetime):
raise NotImplementedError('You can only compare two DateTimeItem objects.')
return not self.__eq__(other)
def _parse_rest(self, value: str) -> typing.Tuple[datetime.datetime, str]: # type: ignore[override]
"""Parse a REST result into a native object.
Args:
value (str): A string argument to be converted into a datetime.datetime object.
Returns:
datetime.datetime: The datetime.datetime object as converted from the string
parameter.
"""
return dateutil.parser.parse(value), ''
def _rest_format(self, value: datetime.datetime) -> str: # type: ignore[override]
"""Format a value before submitting to openHAB.
Args:
value (datetime.datetime): A datetime.datetime argument to be converted
into a string.
Returns:
str: The string as converted from the datetime.datetime parameter.
"""
# openHAB supports only up to milliseconds as of this writing
return value.isoformat(timespec='milliseconds')
class PlayerItem(Item):
"""PlayerItem item type."""
TYPENAME = 'Player'
types = [openhab.command_types.PlayPauseType, openhab.command_types.NextPrevious, openhab.command_types.RewindFastforward]
state_types = [openhab.command_types.PlayPauseType, openhab.command_types.RewindFastforward]
def play(self) -> None:
"""Send the command PLAY."""
self.command(openhab.command_types.PlayPauseType.PLAY)
def pause(self) -> None:
"""Send the command PAUSE."""
self.command(openhab.command_types.PlayPauseType.PAUSE)
def next(self) -> None:
"""Send the command NEXT."""
self.command(openhab.command_types.NextPrevious.NEXT)
def previous(self) -> None:
"""Send the command PREVIOUS."""
self.command(openhab.command_types.NextPrevious.PREVIOUS)
def fastforward(self) -> None:
"""Send the command FASTFORWARD."""
self.command(openhab.command_types.RewindFastforward.FASTFORWARD)
def rewind(self) -> None:
"""Send the command REWIND."""
self.command(openhab.command_types.RewindFastforward.REWIND)
class SwitchItem(Item):
"""SwitchItem item type."""
TYPENAME = 'Switch'
types = [openhab.command_types.OnOffType]
state_types = types
def on(self) -> None:
"""Set the state of the switch to ON."""
self.command(openhab.command_types.OnOffType.ON)
def off(self) -> None:
"""Set the state of the switch to OFF."""
self.command(openhab.command_types.OnOffType.OFF)
def toggle(self) -> None:
"""Toggle the state of the switch to OFF to ON and vice versa."""
if self.state == openhab.command_types.OnOffType.ON:
self.off()
elif self.state == openhab.command_types.OnOffType.OFF:
self.on()
class NumberItem(Item):
"""NumberItem item type."""
TYPENAME = 'Number'
types = [openhab.command_types.DecimalType]
state_types = types
def _parse_rest(self, value: str) -> typing.Tuple[typing.Union[float, None], str]: # type: ignore[override]
"""Parse a REST result into a native object.
Args:
value (str): A string argument to be converted into a float object.
Returns:
float: The float object as converted from the string parameter.
str: The unit Of Measure or empty string
"""
if value in ('UNDEF', 'NULL'):
return None, ''
# m = re.match(r'''^(-?[0-9.]+)''', value)
try:
m = re.match(r'(-?[0-9.]+(?:[eE]-?[0-9]+)?)\s?(.*)?$', value)
if m:
value = m.group(1)
unit_of_measure = m.group(2)
return float(value), unit_of_measure
return float(value), ''
except (ArithmeticError, ValueError) as exc:
self.logger.error('Error in parsing new value "%s" for "%s" - "%s"', value, self.name, exc)
raise ValueError(f'{self.__class__}: unable to parse value "{value}"')
def _rest_format(self, value: typing.Union[float, typing.Tuple[float, str], str]) -> typing.Union[str, bytes]:
"""Format a value before submitting to openHAB.
Args:
value: Either a float, a tuple of (float, str), or string; in the first two cases we have to cast it to a string.
Returns:
str or bytes: A string or bytes as converted from the value parameter.
"""
if isinstance(value, tuple) and len(value) == 2:
return super()._rest_format(f'{value[0]:G} {value[1]}')
if not isinstance(value, str):
return super()._rest_format(f'{value:G}')
return super()._rest_format(value)
class ContactItem(Item):
"""Contact item type."""
TYPENAME = 'Contact'
types = [openhab.command_types.OpenCloseType]
state_types = types
def command(self, *args: typing.Any, **kwargs: typing.Any) -> None:
"""This overrides the `Item` command method.
Note: Commands are not accepted for items of type contact.
"""
raise ValueError(f'This item ({self.__class__}) only supports updates, not commands!')
def open(self) -> None:
"""Set the state of the contact item to OPEN."""
self.state = openhab.command_types.OpenCloseType.OPEN
def closed(self) -> None:
"""Set the state of the contact item to CLOSED."""
self.state = openhab.command_types.OpenCloseType.CLOSED
class DimmerItem(Item):
"""DimmerItem item type."""
TYPENAME = 'Dimmer'
types = [openhab.command_types.OnOffType, openhab.command_types.PercentType, openhab.command_types.IncreaseDecreaseType]
state_types = [openhab.command_types.PercentType]
def _parse_rest(self, value: str) -> typing.Tuple[float, str]: # type: ignore[override]
"""Parse a REST result into a native object.
Args:
value (str): A string argument to be converted into a int object.
Returns:
float: The int object as converted from the string parameter.
str: Possible UoM
"""
return float(value), ''
def _rest_format(self, value: typing.Union[str, int]) -> str:
"""Format a value before submitting to OpenHAB.
Args:
value: Either a string or an integer; in the latter case we have to cast it to a string.
Returns:
str: The string as possibly converted from the parameter.
"""
if not isinstance(value, str):
return str(value)
return value
def on(self) -> None:
"""Set the state of the dimmer to ON."""
self.command(openhab.command_types.OnOffType.ON)
def off(self) -> None:
"""Set the state of the dimmer to OFF."""
self.command(openhab.command_types.OnOffType.OFF)
def increase(self) -> None:
"""Increase the state of the dimmer."""
self.command(openhab.command_types.IncreaseDecreaseType.INCREASE)
def decrease(self) -> None:
"""Decrease the state of the dimmer."""
self.command(openhab.command_types.IncreaseDecreaseType.DECREASE)
class ColorItem(DimmerItem):
"""ColorItem item type."""
TYPENAME = 'Color'
types = [openhab.command_types.OnOffType, openhab.command_types.PercentType, openhab.command_types.IncreaseDecreaseType, openhab.command_types.ColorType]
state_types = [openhab.command_types.ColorType]
def _parse_rest(self, value: str) -> typing.Tuple[typing.Optional[typing.Tuple[float, float, float]], str]: # type: ignore[override]
"""Parse a REST result into a native object.
Args:
value (str): A string argument to be converted into a str object.
Returns:
HSB components
Optional UoM
"""
result = openhab.command_types.ColorType.parse(value)
return result, ''
def _rest_format(self, value: typing.Union[typing.Tuple[int, int, float], str, int]) -> str:
"""Format a value before submitting to openHAB.
Args:
value: Either a string, an integer or a tuple of HSB components (int, int, float); in the latter two cases we have to cast it to a string.
Returns:
str: The string as possibly converted from the parameter.
"""
if isinstance(value, tuple):
if len(value) == 3:
return f'{value[0]},{value[1]},{value[2]}'
if not isinstance(value, str):
return str(value)
return value
class RollershutterItem(Item):
"""RollershutterItem item type."""
TYPENAME = 'Rollershutter'
types = [openhab.command_types.UpDownType, openhab.command_types.PercentType, openhab.command_types.StopMoveType]
state_types = [openhab.command_types.PercentType]
def _parse_rest(self, value: str) -> typing.Tuple[int, str]: # type: ignore[override]
"""Parse a REST result into a native object.
Args:
value (str): A string argument to be converted into a int object.
Returns:
int: The int object as converted from the string parameter.
str: Possible UoM
"""
return int(float(value)), ''
def _rest_format(self, value: typing.Union[str, int]) -> str:
"""Format a value before submitting to openHAB.
Args:
value: Either a string or an integer; in the latter case we have to cast it to a string.
Returns:
str: The string as possibly converted from the parameter.
"""
if not isinstance(value, str):
return str(value)
return value
def up(self) -> None:
"""Set the state of the dimmer to ON."""
self.command(openhab.command_types.UpDownType.UP)
def down(self) -> None:
"""Set the state of the dimmer to OFF."""
self.command(openhab.command_types.UpDownType.DOWN)
def stop(self) -> None:
"""Set the state of the dimmer to OFF."""
self.command(openhab.command_types.StopMoveType.STOP)

View File

@@ -0,0 +1,96 @@
"""OAuth2 helper method for generating and fetching an OAuth2 token."""
import typing
import bs4
import httpx
def get_oauth2_token(base_url: str,
username: str,
password: str,
client_id: typing.Optional[str] = None,
redirect_url: typing.Optional[str] = None,
scope: typing.Optional[str] = None,
) -> dict:
"""Method for generating an OAuth2 token.
Args:
base_url: openHAB base URL
username: Admin account username
password: Admin account password
client_id: OAuth2 client ID; does not need to be specified
redirect_url: OAuth2 redirect URL; does not need to be specified
scope: Do not change unless you know what you are doing
Returns:
*dict* with the generated OAuth2 token details
"""
if client_id is not None:
oauth2_client_id = client_id
else:
oauth2_client_id = 'http://127.0.0.1/auth'
if redirect_url is not None:
oauth2_redirect_url = redirect_url
else:
oauth2_redirect_url = 'http://127.0.0.1/auth'
if scope is not None:
oauth2_scope = scope
else:
oauth2_scope = 'admin'
oauth2_auth_endpoint = f'{base_url}/rest/auth/token'
url_generate_token = f'{base_url}/auth?response_type=code&redirect_uri={oauth2_redirect_url}&client_id={oauth2_client_id}&scope={oauth2_scope}'
res = httpx.get(url_generate_token, timeout=30)
res.raise_for_status()
soup = bs4.BeautifulSoup(res.content, 'html.parser')
submit_form = soup.find('form')
action = submit_form.attrs.get('action').lower()
url_submit_generate_token = f'{base_url}{action}'
data = {}
for input_tag in submit_form.find_all('input'):
input_name = input_tag.attrs.get('name')
if input_name is None:
continue
input_value = input_tag.attrs.get('value', '')
data[input_name] = input_value
data['username'] = username
data['password'] = password
res = httpx.post(url_submit_generate_token, data=data, timeout=30)
if not 200 < res.status_code <= 302:
res.raise_for_status()
if 'location' not in res.headers:
raise KeyError('Token generation failed!')
oauth_redirect_location = res.headers['location']
if '?code=' not in oauth_redirect_location:
raise ValueError('Token generation failed!')
oauth2_registration_code = oauth_redirect_location.split('?code=', 1)[1]
data = {'grant_type': 'authorization_code',
'code': oauth2_registration_code,
'redirect_uri': oauth2_redirect_url,
'client_id': oauth2_client_id,
'refresh_token': None,
'code_verifier': None,
}
res = httpx.post(oauth2_auth_endpoint, data=data, timeout=30)
res.raise_for_status()
return res.json()

View File

@@ -0,0 +1,46 @@
"""python library for accessing the openHAB REST API."""
#
# Georges Toth (c) 2016-present <georges@trypill.org>
#
# python-openhab is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# python-openhab is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with python-openhab. If not, see <http://www.gnu.org/licenses/>.
#
# pylint: disable=bad-indentation
import logging
import typing
if typing.TYPE_CHECKING:
import openhab.client
__author__ = 'Georges Toth <georges@trypill.org>'
__license__ = 'AGPLv3+'
class Rules:
"""Base rule class."""
def __init__(self, openhab_conn: 'openhab.client.OpenHAB') -> None:
"""Constructor.
Args:
openhab_conn (openhab.OpenHAB): openHAB object.
"""
self.openhab = openhab_conn
self.logger = logging.getLogger(__name__)
def get(self) -> typing.List[typing.Dict[str, typing.Any]]:
"""Get all rules."""
return self.openhab.req_get('/rules')