diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 00000000..916bd174 --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[report] +exclude_lines = + # need to re-specify the default pragma + pragma: no cover + + def __repr__ + raise AssertionError + raise NotImplementedError diff --git a/CHANGELOG.md b/CHANGELOG.md index ab9bf1b2..eefea99b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Change Log -## [0.10](https://github.com/theolind/pymysensors/tree/0.10) (2017-05-07) +## [0.11.0](https://github.com/theolind/pymysensors/tree/0.11.0) (2017-08-21) +[Full Changelog](https://github.com/theolind/pymysensors/compare/0.10...0.11.0) + +**Merged pull requests:** + +- Add debug timer logging if handle queue is slow [\#105](https://github.com/theolind/pymysensors/pull/105) ([MartinHjelmare](https://github.com/MartinHjelmare)) +- Update gen\_changelog and release procedure [\#104](https://github.com/theolind/pymysensors/pull/104) ([MartinHjelmare](https://github.com/MartinHjelmare)) +- Update type schema and add message tests [\#103](https://github.com/theolind/pymysensors/pull/103) ([MartinHjelmare](https://github.com/MartinHjelmare)) +- Add validation of message and child values [\#102](https://github.com/theolind/pymysensors/pull/102) [[breaking change](https://github.com/theolind/pymysensors/labels/breaking%20change)] ([MartinHjelmare](https://github.com/MartinHjelmare)) +- Upgrade test requirements [\#101](https://github.com/theolind/pymysensors/pull/101) ([MartinHjelmare](https://github.com/MartinHjelmare)) +- Update const for version 1.5 and 2.0 [\#100](https://github.com/theolind/pymysensors/pull/100) ([MartinHjelmare](https://github.com/MartinHjelmare)) +- Fix subscription to topics with nested prefix [\#99](https://github.com/theolind/pymysensors/pull/99) ([MartinHjelmare](https://github.com/MartinHjelmare)) + +## [0.10](https://github.com/theolind/pymysensors/tree/0.10) (2017-05-06) [Full Changelog](https://github.com/theolind/pymysensors/compare/0.9.1...0.10) **Closed issues:** @@ -9,6 +22,7 @@ **Merged pull requests:** +- 0.10 [\#98](https://github.com/theolind/pymysensors/pull/98) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Add release instructions and update setup [\#97](https://github.com/theolind/pymysensors/pull/97) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Add manifest and update setup files [\#96](https://github.com/theolind/pymysensors/pull/96) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Add changelog [\#95](https://github.com/theolind/pymysensors/pull/95) ([MartinHjelmare](https://github.com/MartinHjelmare)) @@ -40,7 +54,7 @@ - Move gateways into separate modules [\#84](https://github.com/theolind/pymysensors/pull/84) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Return local time instead of UTC time from Controller [\#81](https://github.com/theolind/pymysensors/pull/81) ([proddy](https://github.com/proddy)) - Add discover [\#79](https://github.com/theolind/pymysensors/pull/79) ([MartinHjelmare](https://github.com/MartinHjelmare)) -- Event callback extensions [\#78](https://github.com/theolind/pymysensors/pull/78) ([steve-bate](https://github.com/steve-bate)) +- Event callback extensions [\#78](https://github.com/theolind/pymysensors/pull/78) [[breaking change](https://github.com/theolind/pymysensors/labels/breaking%20change)] ([steve-bate](https://github.com/steve-bate)) - tcp\_check to reconnect in case of connection lost [\#67](https://github.com/theolind/pymysensors/pull/67) ([afeno](https://github.com/afeno)) ## [0.8](https://github.com/theolind/pymysensors/tree/0.8) (2016-10-19) @@ -96,7 +110,7 @@ - Fix and add missing set,req and internal api types [\#52](https://github.com/theolind/pymysensors/pull/52) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Update pyserial to version 3.1.1 [\#50](https://github.com/theolind/pymysensors/pull/50) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Add MQTT client Gateway layer [\#49](https://github.com/theolind/pymysensors/pull/49) ([MartinHjelmare](https://github.com/MartinHjelmare)) -- Update handle\_req to return zero values [\#48](https://github.com/theolind/pymysensors/pull/48) ([myallh](https://github.com/myallh)) +- Update handle\_req to return zero values [\#48](https://github.com/theolind/pymysensors/pull/48) ([mch3000](https://github.com/mch3000)) - Handle heartbeat message [\#46](https://github.com/theolind/pymysensors/pull/46) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Add class for testing protocol\_version 1.5 [\#45](https://github.com/theolind/pymysensors/pull/45) ([MartinHjelmare](https://github.com/MartinHjelmare)) - Add const for mysensors 2.0 [\#44](https://github.com/theolind/pymysensors/pull/44) ([MartinHjelmare](https://github.com/MartinHjelmare)) @@ -175,4 +189,4 @@ -\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* +\* *This Change Log was automatically generated by [github_changelog_generator](https://github.com/skywinder/Github-Changelog-Generator)* \ No newline at end of file diff --git a/RELEASE.md b/RELEASE.md index 2259f9d3..0469c803 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -21,9 +21,8 @@ - Create a release branch from dev. - Merge master into the release branch to make the PR mergeable. +- Update version in `mysensors/version.py` to the new version number, eg `'0.2.0'`. - Update `CHANGELOG.md` by running `scripts/gen_changelog`. -- Replace the unreleased header in the changelog with the new release number. -- Update version in `setup.py`. - Commit and push the release branch. - Create a pull request from release branch to master with the upcoming release number as the title. Put the changes for the new release from the updated changelog as the PR message. - Merge the pull request into master, do not squash. @@ -38,3 +37,8 @@ ``` - Stage release: `twine upload -r test dist/*` - Release: `twine upload -r pypi dist/*` +- Fetch and checkout the master branch. +- Fetch and checkout the develop branch. +- Merge master into develop. +- Update version in `mysensors/version.py` to the new develop version number, eg `'0.3.0.dev0'` +- Commit the version bump and push to develop branch. diff --git a/mysensors/__init__.py b/mysensors/__init__.py index e3163814..d9639b4c 100644 --- a/mysensors/__init__.py +++ b/mysensors/__init__.py @@ -7,13 +7,39 @@ import threading import time from collections import deque +# pylint: disable=import-error, no-name-in-module +from distutils.version import LooseVersion as parse_ver from importlib import import_module from queue import Queue +from timeit import default_timer as timer + +import voluptuous as vol from .ota import OTAFirmware +from .version import __version__ # noqa: F401 _LOGGER = logging.getLogger(__name__) +BROADCAST_ID = 255 +LOADED_CONST = {} +SYSTEM_CHILD_ID = 255 + + +def get_const(protocol_version): + """Return the const module for the protocol_version.""" + version = str(protocol_version) + if parse_ver('1.5') <= parse_ver(version) < parse_ver('2.0'): + path = 'mysensors.const_15' + elif parse_ver(version) >= parse_ver('2.0'): + path = 'mysensors.const_20' + else: + path = 'mysensors.const_14' + if path in LOADED_CONST: + return LOADED_CONST[path] + const = import_module(path) + LOADED_CONST[path] = const # Cache the module + return const + class Gateway(object): """Base implementation for a MySensors Gateway.""" @@ -31,21 +57,19 @@ def __init__(self, event_callback=None, persistence=False, self.persistence = persistence # if true - save sensors to disk self.persistence_file = persistence_file # path to persistence file self.persistence_bak = '{}.bak'.format(self.persistence_file) - self.protocol_version = float(protocol_version) - if 1.5 <= self.protocol_version < 2.0: - _const = import_module('mysensors.const_15') - elif self.protocol_version >= 2.0: - _const = import_module('mysensors.const_20') - else: - _const = import_module('mysensors.const_14') - self.const = _const + self.protocol_version = protocol_version + self.const = get_const(self.protocol_version) self.ota = OTAFirmware(self.sensors, self.const) if persistence: self._safe_load_sensors() + def __repr__(self): + """Return the representation.""" + return self.__class__.__name__ + def _handle_presentation(self, msg): """Process a presentation message.""" - if msg.child_id == 255: + if msg.child_id == SYSTEM_CHILD_ID: # this is a presentation of the sensor platform sensorid = self.add_sensor(msg.node_id) if sensorid is None: @@ -82,8 +106,8 @@ def _handle_set(self, msg): # Check if reboot is true if self.sensors[msg.node_id].reboot: return msg.modify( - child_id=255, type=self.const.MessageType.internal, ack=0, - sub_type=self.const.Internal.I_REBOOT, payload='') + child_id=SYSTEM_CHILD_ID, type=self.const.MessageType.internal, + ack=0, sub_type=self.const.Internal.I_REBOOT, payload='') def _handle_req(self, msg): """Process a req message. @@ -166,7 +190,9 @@ def logic(self, data): ret = None try: msg = Message(data, self) - except ValueError: + msg.validate(self.protocol_version) + except (ValueError, vol.Invalid) as exc: + _LOGGER.warning('Not a valid message: %s', exc) return if msg.type == self.const.MessageType.presentation: @@ -205,8 +231,8 @@ def _save_json(self, filename): def _load_json(self, filename): """Load sensors from json file.""" with open(filename, 'r') as file_handle: - self.sensors.update( - json.load(file_handle, cls=MySensorsJSONDecoder)) + self.sensors.update(json.load( + file_handle, cls=MySensorsJSONDecoder)) def _save_sensors(self): """Save sensors to file.""" @@ -292,7 +318,7 @@ def _get_next_id(self): next_id = max(self.sensors.keys()) + 1 else: next_id = 1 - if next_id <= 254: + if next_id <= self.const.MAX_NODE_ID: return next_id def add_sensor(self, sensorid=None): @@ -312,11 +338,11 @@ def is_sensor(self, sensorid, child_id=None): ret = child_id in self.sensors[sensorid].children if not ret: _LOGGER.warning('Child %s is unknown', child_id) - if not ret and self.protocol_version >= 2.0: + if not ret and parse_ver(self.protocol_version) >= parse_ver('2.0'): _LOGGER.info('Requesting new presentation for node %s', sensorid) msg = Message(gateway=self).modify( - node_id=sensorid, child_id=255, + node_id=sensorid, child_id=SYSTEM_CHILD_ID, type=self.const.MessageType.internal, sub_type=self.const.Internal.I_PRESENTATION) if self._route_message(msg): @@ -342,9 +368,15 @@ def handle_queue(self, queue=None): if queue is None: queue = self.queue if not queue.empty(): + start = timer() func, args, kwargs = queue.get() reply = func(*args, **kwargs) queue.task_done() + end = timer() + if end - start > 0.1: + _LOGGER.debug( + 'Handle queue with call %s(%s, %s) took %.3f seconds', + func, args, kwargs, end - start) return reply def fill_queue(self, func, args=None, kwargs=None, queue=None): @@ -400,7 +432,7 @@ def __init__(self, sensor_id): self.sketch_name = None self.sketch_version = None self._battery_level = 0 - self.protocol_version = None + self.protocol_version = '1.4' self.new_state = {} self.queue = deque() self.reboot = False @@ -415,6 +447,11 @@ def __setstate__(self, state): self.queue = deque() self.reboot = False + def __repr__(self): + """Return the representation.""" + return ''.format( + self.sensor_id, self.children) + @property def battery_level(self): """Return battery level.""" @@ -443,7 +480,7 @@ def set_child_value(self, child_id, value_type, value, **kwargs): return msg_type = kwargs.get('msg_type', 1) ack = kwargs.get('ack', 0) - msg = Message(gateway=self).modify( + msg = Message().modify( node_id=self.sensor_id, child_id=child_id, type=msg_type, ack=ack, sub_type=value_type, payload=value) msg_string = msg.encode() @@ -454,11 +491,13 @@ def set_child_value(self, child_id, value_type, value, **kwargs): self.sensor_id, child_id, msg_type, ack, value_type, value) return try: - msg = Message(msg_string) # Validate child values - except (ValueError, AttributeError) as exception: - _LOGGER.error('Error validating child values: %s', exception) + msg = Message(msg_string) + msg.validate(self.protocol_version) + except (ValueError, AttributeError, vol.Invalid) as exc: + _LOGGER.error('Not a valid message: %s', exc) return - children[msg.child_id].values[msg.sub_type] = msg.payload + child = children[msg.child_id] + child.values[msg.sub_type] = msg.payload return msg_string @@ -485,14 +524,23 @@ def __setstate__(self, state): def __repr__(self): """Return the representation.""" - return self.__str__() - - def __str__(self): - """Return the string representation.""" - ret = ('child_id={0!s}, child_type={1!s}, description={2!s}, ' - 'values = {3!s}') + ret = ('') return ret.format(self.id, self.type, self.description, self.values) + def get_schema(self, protocol_version): + """Return the child schema for the correct const version.""" + const = get_const(protocol_version) + return vol.Schema({ + typ.value: const.VALID_SETREQ[typ] + for typ in const.VALID_TYPES[self.type]}) + + def validate(self, protocol_version, values=None): + """Validate child value types and values against protocol_version.""" + if values is None: + values = self.values + return self.get_schema(protocol_version)(values) + class Message(object): """Represent a message from the gateway.""" @@ -509,6 +557,12 @@ def __init__(self, data=None, gateway=None): if data is not None: self.decode(data) + def __repr__(self): + """Return the representation.""" + return ''.format( + self.node_id, self.child_id, self.type, self.ack, self.sub_type, + self.payload) + def copy(self, **kwargs): """Copy a message, optionally replace attributes with kwargs.""" msg = Message(self.encode(), self.gateway) @@ -551,6 +605,51 @@ def encode(self, delimiter=';'): except ValueError: _LOGGER.error('Error encoding message to gateway') + def validate(self, protocol_version): + """Validate message.""" + const = get_const(protocol_version) + valid_node_ids = vol.All(vol.Coerce(int), vol.Range( + min=0, max=BROADCAST_ID, msg='Not valid node_id: {}'.format( + self.node_id))) + valid_child_ids = vol.All(vol.Coerce(int), vol.Range( + min=0, max=SYSTEM_CHILD_ID, msg='Not valid child_id: {}'.format( + self.child_id))) + if self.type in (const.MessageType.internal, const.MessageType.stream): + valid_child_ids = vol.All(vol.Coerce(int), vol.In( + [SYSTEM_CHILD_ID], + msg='When message type is {}, child_id must be {}'.format( + self.type, SYSTEM_CHILD_ID))) + if (self.type == const.MessageType.internal and + self.sub_type in [ + const.Internal.I_ID_REQUEST, + const.Internal.I_ID_RESPONSE]): + valid_child_ids = vol.Coerce(int) + valid_types = vol.All(vol.Coerce(int), vol.In( + [member.value for member in const.VALID_MESSAGE_TYPES], + msg='Not valid message type: {}'.format(self.type))) + if self.child_id == SYSTEM_CHILD_ID: + valid_types = vol.All(vol.Coerce(int), vol.In( + [const.MessageType.presentation.value, + const.MessageType.internal.value, + const.MessageType.stream.value], + msg=( + 'When child_id is {}, {} is not a valid ' + 'message type'.format(SYSTEM_CHILD_ID, self.type)))) + valid_ack = vol.In([0, 1], msg='Not valid ack flag: {}'.format( + self.ack)) + valid_sub_types = vol.In( + [member.value for member + in const.VALID_MESSAGE_TYPES.get(self.type, [])], + msg='Not valid message sub-type: {}'.format(self.sub_type)) + valid_payload = const.VALID_PAYLOADS.get( + self.type, {}).get(self.sub_type, '') + schema = vol.Schema({ + 'node_id': valid_node_ids, 'child_id': valid_child_ids, + 'type': valid_types, 'ack': valid_ack, 'sub_type': valid_sub_types, + 'payload': valid_payload}) + to_validate = {attr: getattr(self, attr) for attr in schema.schema} + return schema(to_validate) + class MySensorsJSONEncoder(json.JSONEncoder): """JSON encoder.""" @@ -595,11 +694,8 @@ def dict_to_object(self, obj): # pylint: disable=no-self-use setattr(sensor, key, val) return sensor elif all(k in obj for k in ['id', 'type', 'values']): - # Handle new optional description attribute - if 'description' in obj: - child = ChildSensor(obj['id'], obj['type'], obj['description']) - else: - child = ChildSensor(obj['id'], obj['type']) + child = ChildSensor( + obj['id'], obj['type'], obj.get('description', '')) child.values = obj['values'] return child elif all(k.isdigit() for k in obj.keys()): diff --git a/mysensors/const_14.py b/mysensors/const_14.py index bd05029a..7b02524b 100644 --- a/mysensors/const_14.py +++ b/mysensors/const_14.py @@ -1,6 +1,8 @@ """MySensors constants for version 1.4 of MySensors.""" from enum import IntEnum +import voluptuous as vol + class MessageType(IntEnum): """MySensors message types.""" @@ -150,6 +152,176 @@ class Stream(IntEnum): ST_IMAGE = 5 # Image +VALID_MESSAGE_TYPES = { + MessageType.presentation: list(Presentation), + MessageType.set: list(SetReq), + MessageType.req: list(SetReq), + MessageType.internal: list(Internal), + MessageType.stream: list(Stream), +} + +VALID_PRESENTATION = { + member: str for member in list(Presentation) +} + +VALID_TYPES = { + Presentation.S_DOOR: [SetReq.V_TRIPPED, SetReq.V_ARMED], + Presentation.S_MOTION: [SetReq.V_TRIPPED, SetReq.V_ARMED], + Presentation.S_SMOKE: [SetReq.V_TRIPPED, SetReq.V_ARMED], + Presentation.S_LIGHT: [SetReq.V_LIGHT, SetReq.V_WATT], + Presentation.S_DIMMER: [SetReq.V_LIGHT, SetReq.V_DIMMER, SetReq.V_WATT], + Presentation.S_COVER: [ + SetReq.V_UP, SetReq.V_DOWN, SetReq.V_STOP, SetReq.V_DIMMER], + Presentation.S_TEMP: [SetReq.V_TEMP], + Presentation.S_HUM: [SetReq.V_HUM], + Presentation.S_BARO: [ + SetReq.V_PRESSURE, SetReq.V_FORECAST], + Presentation.S_WIND: [ + SetReq.V_WIND, SetReq.V_GUST, SetReq.V_DIRECTION], + Presentation.S_RAIN: [ + SetReq.V_RAIN, SetReq.V_RAINRATE], + Presentation.S_UV: [SetReq.V_UV], + Presentation.S_WEIGHT: [ + SetReq.V_WEIGHT, SetReq.V_IMPEDANCE], + Presentation.S_POWER: [SetReq.V_WATT, SetReq.V_KWH], + Presentation.S_HEATER: [ + SetReq.V_HEATER, SetReq.V_HEATER_SW, SetReq.V_TEMP], + Presentation.S_DISTANCE: [SetReq.V_DISTANCE], + Presentation.S_LIGHT_LEVEL: [SetReq.V_LIGHT_LEVEL], + Presentation.S_ARDUINO_NODE: [], + Presentation.S_ARDUINO_RELAY: [], + Presentation.S_LOCK: [SetReq.V_LOCK_STATUS], + Presentation.S_IR: [SetReq.V_IR_SEND, SetReq.V_IR_RECEIVE], + Presentation.S_WATER: [SetReq.V_FLOW, SetReq.V_VOLUME], + Presentation.S_AIR_QUALITY: [SetReq.V_DUST_LEVEL], + Presentation.S_CUSTOM: [ + SetReq.V_VAR1, SetReq.V_VAR2, SetReq.V_VAR3, SetReq.V_VAR4, + SetReq.V_VAR5], + Presentation.S_DUST: [SetReq.V_DUST_LEVEL], + Presentation.S_SCENE_CONTROLLER: [SetReq.V_SCENE_ON, SetReq.V_SCENE_OFF], +} + +LOGICAL_ZERO = '0' +LOGICAL_ONE = '1' +OFF = 'Off' +HEAT_ON = 'HeatOn' +COOL_ON = 'CoolOn' +AUTO_CHANGE_OVER = 'AutoChangeOver' +STABLE = 'stable' +SUNNY = 'sunny' +CLOUDY = 'cloudy' +UNSTABLE = 'unstable' +THUNDERSTORM = 'thunderstorm' +UNKNOWN = 'unknown' +FORECASTS = (STABLE, SUNNY, CLOUDY, UNSTABLE, THUNDERSTORM, UNKNOWN) + +VALID_SETREQ = { + SetReq.V_TEMP: str, + SetReq.V_HUM: str, + SetReq.V_LIGHT: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_DIMMER: vol.All( + vol.Coerce(int), vol.Range(min=0, max=100), vol.Coerce(str), + msg='value must be between {} and {}'.format(0, 100)), + SetReq.V_PRESSURE: str, + SetReq.V_FORECAST: vol.Any(str, vol.In( + FORECASTS, + msg='forecast must be one of: {}, {}, {}, {}, {}, {}'.format( + *FORECASTS))), + SetReq.V_RAIN: str, + SetReq.V_RAINRATE: str, + SetReq.V_WIND: str, + SetReq.V_GUST: str, + SetReq.V_DIRECTION: str, + SetReq.V_UV: str, + SetReq.V_WEIGHT: str, + SetReq.V_DISTANCE: str, + SetReq.V_IMPEDANCE: str, + SetReq.V_ARMED: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_TRIPPED: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_WATT: str, + SetReq.V_KWH: str, + SetReq.V_SCENE_ON: str, + SetReq.V_SCENE_OFF: str, + SetReq.V_HEATER: vol.In( + [OFF, HEAT_ON, COOL_ON, AUTO_CHANGE_OVER], + msg='value must be one of: {}, {}, {} or {}'.format( + OFF, HEAT_ON, COOL_ON, AUTO_CHANGE_OVER)), + SetReq.V_HEATER_SW: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_LIGHT_LEVEL: vol.All( + vol.Coerce(int), vol.Range(min=0, max=100), vol.Coerce(str), + msg='value must be between {} and {}'.format(0, 100)), + SetReq.V_VAR1: str, + SetReq.V_VAR2: str, + SetReq.V_VAR3: str, + SetReq.V_VAR4: str, + SetReq.V_VAR5: str, + SetReq.V_UP: str, + SetReq.V_DOWN: str, + SetReq.V_STOP: str, + SetReq.V_IR_SEND: str, + SetReq.V_IR_RECEIVE: str, + SetReq.V_FLOW: str, + SetReq.V_VOLUME: str, + SetReq.V_LOCK_STATUS: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_DUST_LEVEL: str, + SetReq.V_VOLTAGE: str, + SetReq.V_CURRENT: str, +} + +CONF_METRIC = 'M' +CONF_IMPERIAL = 'I' +MAX_NODE_ID = 254 + +VALID_INTERNAL = { + Internal.I_BATTERY_LEVEL: vol.All( + vol.Coerce(int), vol.Range(min=0, max=100), vol.Coerce(str)), + Internal.I_TIME: vol.Any('', vol.All(vol.Coerce(int), vol.Coerce(str))), + Internal.I_VERSION: str, + Internal.I_ID_REQUEST: '', + Internal.I_ID_RESPONSE: vol.All( + vol.Coerce(int), vol.Range(min=1, max=MAX_NODE_ID), vol.Coerce(str)), + Internal.I_INCLUSION_MODE: vol.In([LOGICAL_ZERO, LOGICAL_ONE]), + Internal.I_CONFIG: vol.Any( + vol.All(vol.Coerce(int), vol.Range(min=0, max=MAX_NODE_ID)), + CONF_METRIC, CONF_IMPERIAL), + Internal.I_FIND_PARENT: '', + Internal.I_FIND_PARENT_RESPONSE: vol.All( + vol.Coerce(int), vol.Range(min=0, max=MAX_NODE_ID), vol.Coerce(str)), + Internal.I_LOG_MESSAGE: str, + Internal.I_CHILDREN: str, + Internal.I_SKETCH_NAME: str, + Internal.I_SKETCH_VERSION: str, + Internal.I_REBOOT: '', + Internal.I_GATEWAY_READY: str, +} + +VALID_STREAM = { + Stream.ST_FIRMWARE_CONFIG_REQUEST: str, + Stream.ST_FIRMWARE_CONFIG_RESPONSE: str, + Stream.ST_FIRMWARE_REQUEST: str, + Stream.ST_FIRMWARE_RESPONSE: str, + Stream.ST_SOUND: str, + Stream.ST_IMAGE: str, +} + +VALID_PAYLOADS = { + MessageType.presentation: VALID_PRESENTATION, + MessageType.set: VALID_SETREQ, + MessageType.req: {member: '' for member in list(SetReq)}, + MessageType.internal: VALID_INTERNAL, + MessageType.stream: VALID_STREAM, +} + HANDLE_INTERNAL = { Internal.I_BATTERY_LEVEL: { 'is_sensor': True, 'setattr': 'battery_level', 'fun': 'alert'}, diff --git a/mysensors/const_15.py b/mysensors/const_15.py index 0b94a352..f845c285 100644 --- a/mysensors/const_15.py +++ b/mysensors/const_15.py @@ -1,7 +1,14 @@ """MySensors constants for version 1.5 of MySensors.""" +import binascii from enum import IntEnum + +import voluptuous as vol + # pylint: disable=unused-import -from mysensors.const_14 import HANDLE_INTERNAL # noqa: F401 +from mysensors.const_14 import HANDLE_INTERNAL, MAX_NODE_ID # noqa: F401 +from mysensors.const_14 import (AUTO_CHANGE_OVER, COOL_ON, FORECASTS, HEAT_ON, + LOGICAL_ONE, LOGICAL_ZERO, OFF, VALID_INTERNAL, + VALID_STREAM) class MessageType(IntEnum): @@ -22,8 +29,8 @@ class Presentation(IntEnum): S_DOOR = 0 # Door and window sensors S_MOTION = 1 # Motion sensors S_SMOKE = 2 # Smoke sensor - S_LIGHT = 3 # Light Actuator (on/off) S_BINARY = 3 # Binary device (on/off), Alias for S_LIGHT + S_LIGHT = 3 # Light Actuator (on/off) S_DIMMER = 4 # Dimmable device of some kind S_COVER = 5 # Window covers or shades S_TEMP = 6 # Temperature sensor @@ -112,6 +119,7 @@ class SetReq(IntEnum): V_VOLUME = 35 # Water volume V_LOCK_STATUS = 36 # Set or get lock status. 1=Locked, 0=Unlocked V_LEVEL = 37 # Used for sending level-value + V_DUST_LEVEL = 37 # Dust level V_VOLTAGE = 38 # Voltage level V_CURRENT = 39 # Current level # RGB value transmitted as ASCII hex string (I.e "ff0000" for red) @@ -128,7 +136,7 @@ class SetReq(IntEnum): V_HVAC_SETPOINT_COOL = 44 # HVAC cold setpoint (Integer between 0-100) V_HVAC_SETPOINT_HEAT = 45 # HVAC/Heater setpoint (Integer between 0-100) # Flow mode for HVAC ("Auto", "ContinuousOn", "PeriodicOn") - V_HVAC_FLOW_MODE = 45 + V_HVAC_FLOW_MODE = 46 class Internal(IntEnum): @@ -189,3 +197,204 @@ class Stream(IntEnum): ST_FIRMWARE_RESPONSE = 3 # Response FW block ST_SOUND = 4 # Sound ST_IMAGE = 5 # Image + + +VALID_MESSAGE_TYPES = { + MessageType.presentation: list(Presentation), + MessageType.set: list(SetReq), + MessageType.req: list(SetReq), + MessageType.internal: list(Internal), + MessageType.stream: list(Stream), +} + +VALID_PRESENTATION = { + member: str for member in list(Presentation) +} + +VALID_TYPES = { + Presentation.S_DOOR: [SetReq.V_TRIPPED, SetReq.V_ARMED], + Presentation.S_MOTION: [SetReq.V_TRIPPED, SetReq.V_ARMED], + Presentation.S_SMOKE: [SetReq.V_TRIPPED, SetReq.V_ARMED], + Presentation.S_BINARY: [SetReq.V_STATUS, SetReq.V_WATT], + Presentation.S_DIMMER: [ + SetReq.V_STATUS, SetReq.V_PERCENTAGE, SetReq.V_WATT], + Presentation.S_COVER: [ + SetReq.V_UP, SetReq.V_DOWN, SetReq.V_STOP, SetReq.V_PERCENTAGE], + Presentation.S_TEMP: [SetReq.V_TEMP, SetReq.V_ID, SetReq.V_UNIT_PREFIX], + Presentation.S_HUM: [SetReq.V_HUM, SetReq.V_UNIT_PREFIX], + Presentation.S_BARO: [ + SetReq.V_PRESSURE, SetReq.V_FORECAST, SetReq.V_UNIT_PREFIX], + Presentation.S_WIND: [ + SetReq.V_WIND, SetReq.V_GUST, SetReq.V_DIRECTION, + SetReq.V_UNIT_PREFIX], + Presentation.S_RAIN: [ + SetReq.V_RAIN, SetReq.V_RAINRATE, SetReq.V_UNIT_PREFIX], + Presentation.S_UV: [SetReq.V_UV, SetReq.V_UNIT_PREFIX], + Presentation.S_WEIGHT: [ + SetReq.V_WEIGHT, SetReq.V_IMPEDANCE, SetReq.V_UNIT_PREFIX], + Presentation.S_POWER: [SetReq.V_WATT, SetReq.V_KWH, SetReq.V_UNIT_PREFIX], + Presentation.S_HEATER: [ + SetReq.V_STATUS, SetReq.V_TEMP, SetReq.V_HVAC_SETPOINT_HEAT, + SetReq.V_HVAC_FLOW_STATE], + Presentation.S_DISTANCE: [SetReq.V_DISTANCE, SetReq.V_UNIT_PREFIX], + Presentation.S_LIGHT_LEVEL: [ + SetReq.V_LIGHT_LEVEL, SetReq.V_LEVEL, SetReq.V_UNIT_PREFIX], + Presentation.S_ARDUINO_NODE: [], + Presentation.S_ARDUINO_REPEATER_NODE: [], + Presentation.S_LOCK: [SetReq.V_LOCK_STATUS], + Presentation.S_IR: [SetReq.V_IR_SEND, SetReq.V_IR_RECEIVE], + Presentation.S_WATER: [ + SetReq.V_FLOW, SetReq.V_VOLUME, SetReq.V_UNIT_PREFIX], + Presentation.S_AIR_QUALITY: [SetReq.V_LEVEL, SetReq.V_UNIT_PREFIX], + Presentation.S_CUSTOM: [ + SetReq.V_VAR1, SetReq.V_VAR2, SetReq.V_VAR3, SetReq.V_VAR4, + SetReq.V_VAR5, SetReq.V_UNIT_PREFIX], + Presentation.S_DUST: [SetReq.V_LEVEL, SetReq.V_UNIT_PREFIX], + Presentation.S_SCENE_CONTROLLER: [SetReq.V_SCENE_ON, SetReq.V_SCENE_OFF], + Presentation.S_RGB_LIGHT: [ + SetReq.V_RGB, SetReq.V_WATT, SetReq.V_PERCENTAGE], + Presentation.S_RGBW_LIGHT: [ + SetReq.V_RGBW, SetReq.V_WATT, SetReq.V_PERCENTAGE], + Presentation.S_COLOR_SENSOR: [SetReq.V_RGB, SetReq.V_UNIT_PREFIX], + Presentation.S_HVAC: [ + SetReq.V_STATUS, SetReq.V_TEMP, SetReq.V_HVAC_SETPOINT_HEAT, + SetReq.V_HVAC_SETPOINT_COOL, SetReq.V_HVAC_FLOW_STATE, + SetReq.V_HVAC_FLOW_MODE, SetReq.V_HVAC_SPEED], + Presentation.S_MULTIMETER: [ + SetReq.V_VOLTAGE, SetReq.V_CURRENT, SetReq.V_IMPEDANCE, + SetReq.V_UNIT_PREFIX], + Presentation.S_SPRINKLER: [SetReq.V_STATUS, SetReq.V_TRIPPED], + Presentation.S_WATER_LEAK: [SetReq.V_TRIPPED, SetReq.V_ARMED], + Presentation.S_SOUND: [ + SetReq.V_LEVEL, SetReq.V_TRIPPED, SetReq.V_ARMED, + SetReq.V_UNIT_PREFIX], + Presentation.S_VIBRATION: [ + SetReq.V_LEVEL, SetReq.V_TRIPPED, SetReq.V_ARMED, + SetReq.V_UNIT_PREFIX], + Presentation.S_MOISTURE: [ + SetReq.V_LEVEL, SetReq.V_TRIPPED, SetReq.V_ARMED, + SetReq.V_UNIT_PREFIX], +} + + +def validate_hex(value): + """Validate that value has hex format.""" + try: + binascii.unhexlify(value) + except Exception: + raise vol.Invalid( + '{} is not of hex format'.format(value)) + return value + + +def validate_v_rgb(value): + """Validate a V_RGB value.""" + if len(value) != 6: + raise vol.Invalid( + '{} is not six characters long'.format(value)) + return validate_hex(value) + + +def validate_v_rgbw(value): + """Validate a V_RGBW value.""" + if len(value) != 8: + raise vol.Invalid( + '{} is not eight characters long'.format(value)) + return validate_hex(value) + + +AUTO = 'Auto' +MAX = 'Max' +MIN = 'Min' +NORMAL = 'Normal' + +# Define this again for version 1.5 to avoid conflicts with version 1.4. +VALID_SETREQ = { + SetReq.V_TEMP: str, + SetReq.V_HUM: str, + SetReq.V_STATUS: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_PERCENTAGE: vol.All( + vol.Coerce(int), vol.Range(min=0, max=100), vol.Coerce(str), + msg='value must be between {} and {}'.format(0, 100)), + SetReq.V_PRESSURE: str, + SetReq.V_FORECAST: vol.Any(str, vol.In( + FORECASTS, + msg='forecast must be one of: {}, {}, {}, {}, {}, {}'.format( + *FORECASTS))), + SetReq.V_RAIN: str, + SetReq.V_RAINRATE: str, + SetReq.V_WIND: str, + SetReq.V_GUST: str, + SetReq.V_DIRECTION: str, + SetReq.V_UV: str, + SetReq.V_WEIGHT: str, + SetReq.V_DISTANCE: str, + SetReq.V_IMPEDANCE: str, + SetReq.V_ARMED: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_TRIPPED: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_WATT: str, + SetReq.V_KWH: str, + SetReq.V_SCENE_ON: str, + SetReq.V_SCENE_OFF: str, + SetReq.V_HVAC_FLOW_STATE: vol.In( + [OFF, HEAT_ON, COOL_ON, AUTO_CHANGE_OVER], + msg='value must be one of: {}, {}, {} or {}'.format( + OFF, HEAT_ON, COOL_ON, AUTO_CHANGE_OVER)), + SetReq.V_HVAC_SPEED: vol.In( + [MIN, NORMAL, MAX, AUTO], + msg='value must be one of: {}, {}, {} or {}'.format( + MIN, NORMAL, MAX, AUTO)), + SetReq.V_LIGHT_LEVEL: vol.All( + vol.Coerce(int), vol.Range(min=0, max=100), vol.Coerce(str), + msg='value must be between {} and {}'.format(0, 100)), + SetReq.V_VAR1: str, + SetReq.V_VAR2: str, + SetReq.V_VAR3: str, + SetReq.V_VAR4: str, + SetReq.V_VAR5: str, + SetReq.V_UP: str, + SetReq.V_DOWN: str, + SetReq.V_STOP: str, + SetReq.V_IR_SEND: str, + SetReq.V_IR_RECEIVE: str, + SetReq.V_FLOW: str, + SetReq.V_VOLUME: str, + SetReq.V_LOCK_STATUS: vol.In( + [LOGICAL_ZERO, LOGICAL_ONE], + msg='value must be either {} or {}'.format(LOGICAL_ZERO, LOGICAL_ONE)), + SetReq.V_LEVEL: str, + SetReq.V_VOLTAGE: str, + SetReq.V_CURRENT: str, + SetReq.V_RGB: vol.All(str, validate_v_rgb), + SetReq.V_RGBW: vol.All(str, validate_v_rgbw), + SetReq.V_ID: str, + SetReq.V_UNIT_PREFIX: str, + SetReq.V_HVAC_SETPOINT_COOL: vol.All( + vol.Coerce(float), vol.Range(min=0.0, max=100.0), vol.Coerce(str), + msg='value must be between {} and {}'.format(0.0, 100.0)), + SetReq.V_HVAC_SETPOINT_HEAT: vol.All( + vol.Coerce(float), vol.Range(min=0.0, max=100.0), vol.Coerce(str), + msg='value must be between {} and {}'.format(0.0, 100.0)), + SetReq.V_HVAC_FLOW_MODE: str, +} + +VALID_INTERNAL = dict(VALID_INTERNAL) +VALID_INTERNAL.update({ + Internal.I_REQUEST_SIGNING: str, + Internal.I_GET_NONCE: str, + Internal.I_GET_NONCE_RESPONSE: str, +}) + +VALID_PAYLOADS = { + MessageType.presentation: VALID_PRESENTATION, + MessageType.set: VALID_SETREQ, + MessageType.req: {member: '' for member in list(SetReq)}, + MessageType.internal: VALID_INTERNAL, + MessageType.stream: VALID_STREAM, +} diff --git a/mysensors/const_20.py b/mysensors/const_20.py index 7b4a1637..c9c72844 100644 --- a/mysensors/const_20.py +++ b/mysensors/const_20.py @@ -1,7 +1,12 @@ """MySensors constants for version 1.5 of MySensors.""" from enum import IntEnum -from mysensors.const_15 import HANDLE_INTERNAL +import voluptuous as vol + +# pylint: disable=unused-import +from mysensors.const_15 import MAX_NODE_ID # noqa: F401 +from mysensors.const_15 import (HANDLE_INTERNAL, VALID_INTERNAL, VALID_SETREQ, + VALID_STREAM, VALID_TYPES) class MessageType(IntEnum): @@ -22,8 +27,8 @@ class Presentation(IntEnum): S_DOOR = 0 # Door and window sensors S_MOTION = 1 # Motion sensors S_SMOKE = 2 # Smoke sensor - S_LIGHT = 3 # Light Actuator (on/off) S_BINARY = 3 # Binary device (on/off), Alias for S_LIGHT + S_LIGHT = 3 # Light Actuator (on/off) S_DIMMER = 4 # Dimmable device of some kind S_COVER = 5 # Window covers or shades S_TEMP = 6 # Temperature sensor @@ -131,6 +136,7 @@ class SetReq(IntEnum): # S_DUST, S_AIR_QUALITY, S_SOUND (dB), S_VIBRATION (hz), # S_LIGHT_LEVEL (lux). V_LEVEL = 37 + V_DUST_LEVEL = 37 # Dust level V_VOLTAGE = 38 # S_MULTIMETER. Voltage level. V_CURRENT = 39 # S_MULTIMETER. Current level. # S_RGB_LIGHT, S_COLOR_SENSOR. @@ -218,10 +224,13 @@ class Internal(IntEnum): I_GATEWAY_READY = 14 # Provides signing related preferences (first byte is preference version). I_SIGNING_PRESENTATION = 15 + I_REQUEST_SIGNING = 15 # alias from version 1.5 # Request for a nonce. I_NONCE_REQUEST = 16 + I_GET_NONCE = 16 # alias from version 1.5 # Payload is nonce data. I_NONCE_RESPONSE = 17 + I_GET_NONCE_RESPONSE = 17 # alias from version 1.5 I_HEARTBEAT = 18 I_PRESENTATION = 19 I_DISCOVER = 20 @@ -250,6 +259,91 @@ class Stream(IntEnum): ST_IMAGE = 5 # Image +VALID_MESSAGE_TYPES = { + MessageType.presentation: list(Presentation), + MessageType.set: list(SetReq), + MessageType.req: list(SetReq), + MessageType.internal: list(Internal), + MessageType.stream: list(Stream), +} + +VALID_PRESENTATION = { + member: str for member in list(Presentation) +} + +VALID_TYPES = dict(VALID_TYPES) +VALID_TYPES.update({ + Presentation.S_POWER: [ + SetReq.V_WATT, SetReq.V_KWH, SetReq.V_VAR, SetReq.V_VA, + SetReq.V_POWER_FACTOR, SetReq.V_UNIT_PREFIX], + Presentation.S_IR: [ + SetReq.V_IR_SEND, SetReq.V_IR_RECEIVE, SetReq.V_IR_RECORD], + Presentation.S_CUSTOM: [ + SetReq.V_VAR1, SetReq.V_VAR2, SetReq.V_VAR3, SetReq.V_VAR4, + SetReq.V_VAR5, SetReq.V_CUSTOM, SetReq.V_UNIT_PREFIX], + Presentation.S_INFO: [SetReq.V_TEXT], + Presentation.S_GAS: [SetReq.V_FLOW, SetReq.V_VOLUME, SetReq.V_UNIT_PREFIX], + Presentation.S_GPS: [SetReq.V_POSITION], + Presentation.S_WATER_QUALITY: [ + SetReq.V_TEMP, SetReq.V_PH, SetReq.V_ORP, SetReq.V_EC, + SetReq.V_STATUS, SetReq.V_UNIT_PREFIX], +}) + + +def validate_gps(value): + """Validate GPS value.""" + try: + latitude, longitude, altitude = value.split(',') + vol.Coerce(float)(latitude) + vol.Coerce(float)(longitude) + vol.Coerce(float)(altitude) + except (TypeError, ValueError, vol.Invalid): + raise vol.Invalid( + 'GPS value should be of format "latitude,longitude,altitude"') + return value + + +VALID_SETREQ = dict(VALID_SETREQ) +VALID_SETREQ.update({ + SetReq.V_TEXT: str, + SetReq.V_CUSTOM: str, + SetReq.V_POSITION: vol.All(str, validate_gps), + SetReq.V_IR_RECORD: str, + SetReq.V_PH: str, + SetReq.V_ORP: str, + SetReq.V_EC: str, + SetReq.V_VAR: str, + SetReq.V_VA: str, + SetReq.V_POWER_FACTOR: vol.All( + vol.Coerce(float), vol.Range(min=-1.0, max=1.0), vol.Coerce(str), + msg='value should be between -1.0 and 1.0'), +}) + +VALID_INTERNAL = dict(VALID_INTERNAL) +VALID_INTERNAL.update({ + Internal.I_HEARTBEAT: '', + Internal.I_PRESENTATION: '', + Internal.I_DISCOVER: '', + Internal.I_DISCOVER_RESPONSE: vol.All( + vol.Coerce(int), vol.Range(min=0, max=MAX_NODE_ID), vol.Coerce(str)), + Internal.I_HEARTBEAT_RESPONSE: str, + Internal.I_LOCKED: str, + Internal.I_PING: vol.All(vol.Coerce(int), vol.Coerce(str)), + Internal.I_PONG: vol.All(vol.Coerce(int), vol.Coerce(str)), + Internal.I_REGISTRATION_REQUEST: str, + Internal.I_REGISTRATION_RESPONSE: str, + Internal.I_DEBUG: str, +}) + +VALID_PAYLOADS = { + MessageType.presentation: VALID_PRESENTATION, + MessageType.set: VALID_SETREQ, + MessageType.req: {member: '' for member in list(SetReq)}, + MessageType.internal: VALID_INTERNAL, + MessageType.stream: VALID_STREAM, +} + +HANDLE_INTERNAL = dict(HANDLE_INTERNAL) HANDLE_INTERNAL.update({ Internal.I_GATEWAY_READY: { 'log': 'info', 'msg': { diff --git a/mysensors/gateway_mqtt.py b/mysensors/gateway_mqtt.py index 362fc0a1..a2a255c2 100644 --- a/mysensors/gateway_mqtt.py +++ b/mysensors/gateway_mqtt.py @@ -39,11 +39,11 @@ def _handle_subscription(self, topics): for topic in topics: topic_levels = topic.split('/') try: - qos = int(topic_levels[4]) + qos = int(topic_levels[-2]) except ValueError: qos = 0 try: - _LOGGER.debug('Subscribing to: %s', topic) + _LOGGER.debug('Subscribing to: %s, qos: %s', topic, qos) self._sub_callback(topic, self.recv, qos) except Exception as exception: # pylint: disable=broad-except _LOGGER.exception( @@ -64,7 +64,9 @@ def _parse_mqtt_to_message(self, topic, payload, qos): Return a mysensors command string. """ topic_levels = topic.split('/') - prefix = topic_levels.pop(0) + topic_levels = not_prefix = topic_levels[-5:] + prefix_end_idx = topic.find('/'.join(not_prefix)) - 1 + prefix = topic[:prefix_end_idx] if prefix != self._in_prefix: return if qos and qos > 0: diff --git a/mysensors/version.py b/mysensors/version.py new file mode 100644 index 00000000..45b0979b --- /dev/null +++ b/mysensors/version.py @@ -0,0 +1,5 @@ +"""Store version constants.""" +MAJOR_VERSION = 0 +MINOR_VERSION = 11 +PATCH_VERSION = 0 +__version__ = '{}.{}.{}'.format(MAJOR_VERSION, MINOR_VERSION, PATCH_VERSION) diff --git a/requirements.txt b/requirements.txt index 0c484132..5a24046b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyserial==3.1.1 IntelHex==2.1 crcmod==1.7 +voluptuous==0.10.5 diff --git a/requirements_test.txt b/requirements_test.txt index 2ef4d2d8..7a562c3d 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,6 @@ -flake8==2.5.1 -pylint==1.5.3 -pytest==2.8.0 -pytest-cov==2.2.0 -pytest-timeout==1.0.0 -pydocstyle==1.0.0 +flake8==3.4.1 +pylint==1.7.2 +pytest==3.2.0 +pytest-cov==2.5.1 +pytest-timeout==1.2.0 +pydocstyle==2.0.0 diff --git a/scripts/gen_changelog b/scripts/gen_changelog index 0981e506..c782ce01 100755 --- a/scripts/gen_changelog +++ b/scripts/gen_changelog @@ -1,7 +1,50 @@ #!/bin/sh -if ! gem list github_changelog_generator -i; then +if ! gem list github_changelog_generator -i > /dev/null; then echo "github_changelog_generator must be installed before running this \ -script.\nInstall with (requires Ruby):\ngem install github_changelog_generator"; -else - github_changelog_generator -u theolind -p pymysensors +script.\nInstall with (requires Ruby):\ngem install \ +github_changelog_generator\n\nMake sure you set a github access token \ +to avoid the GitHub API rate limit."; + exit 1 fi + +cd "$(dirname "$0")/.." + +head -n 4 mysensors/version.py | tail -n 1 | grep PATCH_VERSION > /dev/null +if [ $? -eq 1 ] +then + echo "Patch version not found on mysensors/version.py line 4" + exit 1 +fi + +head -n 4 mysensors/version.py | tail -n 1 | grep dev > /dev/null +if [ $? -eq 0 ] +then + echo "Release version should not contain dev tag" + exit 1 +fi + +PATCH_VERSION=$(head -n 4 mysensors/version.py | tail -n 1 \ + | grep PATCH_VERSION | sed 's/.*=.//' | sed 's/[^0-9]*//g') + +head -n 3 mysensors/version.py | tail -n 1 | grep MINOR_VERSION > /dev/null +if [ $? -eq 1 ] +then + echo "Minor version not found on mysensors/version.py line 3" + exit 1 +fi +MINOR_VERSION=$(head -n 3 mysensors/version.py | tail -n 1 \ + | grep MINOR_VERSION | sed 's/.*=.//') + +head -n 2 mysensors/version.py | tail -n 1 | grep MAJOR_VERSION > /dev/null +if [ $? -eq 1 ] +then + echo "Major version not found on mysensors/version.py line 2" + exit 1 +fi +MAJOR_VERSION=$(head -n 2 mysensors/version.py | tail -n 1 \ + | grep MAJOR_VERSION | sed 's/.*=.//') + +VERSION="$MAJOR_VERSION.$MINOR_VERSION.$PATCH_VERSION" + +github_changelog_generator -u theolind -p pymysensors \ + --issue-line-labels "breaking change" --future-release $VERSION diff --git a/setup.py b/setup.py index 025a45c2..4452fe51 100644 --- a/setup.py +++ b/setup.py @@ -2,21 +2,27 @@ import os from setuptools import setup, find_packages +exec(open('mysensors/version.py').read()) + if os.path.exists('README.rst'): README = open('README.rst').read() else: README = '' +REQUIRES = [ + 'pyserial>=3.1.1', 'crcmod>=1.7', 'IntelHex>=2.1', 'voluptuous>=0.10.5', +] + setup( name='pymysensors', - version='0.10.0', + version=__version__, description='Python API for talking to a MySensors gateway', long_description=README, url='https://github.com/theolind/pymysensors', author='Theodor Lindquist', author_email='theodor.lindquist@gmail.com', license='MIT License', - install_requires=['pyserial>=3.1.1', 'crcmod>=1.7', 'IntelHex>=2.1'], + install_requires=REQUIRES, packages=find_packages(exclude=['tests', 'tests.*']), keywords=['sensor', 'actuator', 'IoT', 'DYI'], zip_safe=True, diff --git a/tests/test_gateway_mqtt.py b/tests/test_gateway_mqtt.py index 1fda9ad0..7906f187 100644 --- a/tests/test_gateway_mqtt.py +++ b/tests/test_gateway_mqtt.py @@ -160,5 +160,40 @@ def test_mqtt_load_persistence(self): self.mock_sub.assert_has_calls(calls) +class TestMQTTGatewayCustomPrefix(TestCase): + """Test the MQTT Gateway with custom topic prefix.""" + + def setUp(self): + """Set up test.""" + self.mock_pub = mock.Mock() + self.mock_sub = mock.Mock() + self.gateway = None + + def _setup(self, in_prefix, out_prefix): + """Set up gateway.""" + self.gateway = MQTTGateway( + self.mock_pub, self.mock_sub, in_prefix=in_prefix, + out_prefix=out_prefix) + + def _add_sensor(self, sensorid): + """Add sensor node. Return sensor node instance.""" + self.gateway.sensors[sensorid] = Sensor(sensorid) + return self.gateway.sensors[sensorid] + + def test_nested_prefix(self): + """Test recv method with nested topic prefix.""" + self._setup('test/test-in', 'test/test-out') + sensor = self._add_sensor(1) + sensor.children[1] = ChildSensor( + 1, self.gateway.const.Presentation.S_HUM) + sensor.children[1].values[self.gateway.const.SetReq.V_HUM] = '20' + self.gateway.recv('test/test-in/1/1/2/0/1', '', 0) + ret = self.gateway.handle_queue() + self.assertEqual(ret, '1;1;1;0;1;20\n') + self.gateway.recv('test/test-in/1/1/2/0/1', '', 1) + ret = self.gateway.handle_queue() + self.assertEqual(ret, '1;1;1;1;1;20\n') + + if __name__ == '__main__': main() diff --git a/tests/test_message.py b/tests/test_message.py new file mode 100644 index 00000000..99f91af0 --- /dev/null +++ b/tests/test_message.py @@ -0,0 +1,194 @@ +"""Test mysensors messages.""" +from unittest import TestCase + +from mysensors import get_const, Message +from mysensors.const_14 import Internal, MessageType + +SET_FIXTURES_14 = { + 'V_TEMP': '20.0', + 'V_HUM': '30', + 'V_LIGHT': '1', + 'V_DIMMER': '99', + 'V_PRESSURE': '101325', + 'V_FORECAST': 'stable', + 'V_RAIN': '30', + 'V_RAINRATE': '2', + 'V_WIND': '10', + 'V_GUST': '20', + 'V_DIRECTION': '270', + 'V_UV': '7', + 'V_WEIGHT': '10', + 'V_DISTANCE': '100', + 'V_IMPEDANCE': '10', + 'V_ARMED': '1', + 'V_TRIPPED': '1', + 'V_WATT': '1000', + 'V_KWH': '20', + 'V_SCENE_ON': 'scene_3', + 'V_SCENE_OFF': 'scene_4', + 'V_HEATER': 'AutoChangeOver', + 'V_HEATER_SW': '1', + 'V_LIGHT_LEVEL': '99', + 'V_VAR1': 'test1', + 'V_VAR2': 'test2', + 'V_VAR3': 'test3', + 'V_VAR4': 'test4', + 'V_VAR5': 'test5', + 'V_UP': '', + 'V_DOWN': '', + 'V_STOP': '', + 'V_IR_SEND': 'code', + 'V_IR_RECEIVE': 'code', + 'V_FLOW': '1.5', + 'V_VOLUME': '3.0', + 'V_LOCK_STATUS': '1', + 'V_DUST_LEVEL': '80', + 'V_VOLTAGE': '3.3', + 'V_CURRENT': '1.2', +} + +SET_FIXTURES_15 = dict(SET_FIXTURES_14) +SET_FIXTURES_15.update({ + 'V_STATUS': '1', + 'V_PERCENTAGE': '99', + 'V_HVAC_FLOW_STATE': 'AutoChangeOver', + 'V_HVAC_SPEED': 'Auto', + 'V_LEVEL': '89', + 'V_RGB': 'ffffff', + 'V_RGBW': 'ffffffff', + 'V_ID': '1', + 'V_UNIT_PREFIX': 'mV', + 'V_HVAC_SETPOINT_COOL': '24.0', + 'V_HVAC_SETPOINT_HEAT': '20.0', + 'V_HVAC_FLOW_MODE': 'Auto', +}) +SET_FIXTURES_15.pop('V_HEATER') +SET_FIXTURES_15.pop('V_HEATER_SW') + +SET_FIXTURES_20 = dict(SET_FIXTURES_15) +SET_FIXTURES_20.update({ + 'V_TEXT': 'test text', + 'V_CUSTOM': 'test custom', + 'V_POSITION': '10.0,10.0,10.0', + 'V_IR_RECORD': 'code_id_to_store', + 'V_PH': '7.0', + 'V_ORP': '300', + 'V_EC': '5.5', + 'V_VAR': '100', + 'V_VA': '500', + 'V_POWER_FACTOR': '0.9', +}) + +INTERNAL_FIXTURES_14 = { + 'I_BATTERY_LEVEL': '99', + 'I_TIME': '1500000000', + 'I_VERSION': '1.4.1', + 'I_ID_REQUEST': '', + 'I_ID_RESPONSE': '254', + 'I_INCLUSION_MODE': '1', + 'I_CONFIG': 'M', + 'I_FIND_PARENT': '', + 'I_FIND_PARENT_RESPONSE': '254', + 'I_LOG_MESSAGE': 'test log message', + 'I_CHILDREN': 'C', # clear routing data for the node + 'I_SKETCH_NAME': 'test sketch name', + 'I_SKETCH_VERSION': '1.0.0', + 'I_REBOOT': '', + 'I_GATEWAY_READY': 'Gateway startup complete.', +} + +INTERNAL_FIXTURES_15 = dict(INTERNAL_FIXTURES_14) +INTERNAL_FIXTURES_15.update({ + 'I_REQUEST_SIGNING': 'test signing request', + 'I_GET_NONCE': 'test get nonce', + 'I_GET_NONCE_RESPONSE': 'test get nonce response', +}) + +INTERNAL_FIXTURES_20 = dict(INTERNAL_FIXTURES_15) +INTERNAL_FIXTURES_20.update({ + 'I_HEARTBEAT': '', + 'I_PRESENTATION': '', + 'I_DISCOVER': '', + 'I_DISCOVER_RESPONSE': '254', + 'I_HEARTBEAT_RESPONSE': '123465', + 'I_LOCKED': 'TMFV', + 'I_PING': '123456', + 'I_PONG': '123456', + 'I_REGISTRATION_REQUEST': '2.0.0', + 'I_REGISTRATION_RESPONSE': '1', + 'I_DEBUG': 'test debug', +}) + + +class TestMessage(TestCase): + """Test the Message class and it's encode/decode functions.""" + + def test_encode(self): + """Test encode of message.""" + msg = Message() + cmd = msg.encode() + self.assertEqual(cmd, '0;0;0;0;0;\n') + + msg.node_id = 1 + msg.child_id = 255 + msg.type = MessageType.internal + msg.sub_type = Internal.I_BATTERY_LEVEL + msg.ack = 0 + msg.payload = 57 + + cmd = msg.encode() + self.assertEqual(cmd, '1;255;3;0;0;57\n') + + def test_encode_bad_message(self): + """Test encode of bad message.""" + msg = Message() + msg.sub_type = 'bad' + cmd = msg.encode() + self.assertEqual(cmd, None) + + def test_decode(self): + """Test decode of message.""" + msg = Message('1;255;3;0;0;57\n') + self.assertEqual(msg.node_id, 1) + self.assertEqual(msg.child_id, 255) + self.assertEqual(msg.type, MessageType.internal) + self.assertEqual(msg.sub_type, Internal.I_BATTERY_LEVEL) + self.assertEqual(msg.ack, 0) + self.assertEqual(msg.payload, '57') + + def test_decode_bad_message(self): + """Test decode of bad message.""" + with self.assertRaises(ValueError): + Message('bad;bad;bad;bad;bad;bad\n') + + +def test_validate_set(): + """Test Set messages.""" + versions = [ + ('1.4', SET_FIXTURES_14), ('1.5', SET_FIXTURES_15), + ('2.0', SET_FIXTURES_20)] + for protocol_version, fixture in versions: + const = get_const(protocol_version) + for name, payload in fixture.items(): + sub_type = const.SetReq[name] + msg = Message('1;0;1;0;{};{}\n'.format(sub_type, payload)) + valid = msg.validate(protocol_version) + assert valid == { + 'node_id': 1, 'child_id': 0, 'type': 1, 'ack': 0, + 'sub_type': sub_type, 'payload': payload} + + +def test_validate_internal(): + """Test Internal messages.""" + versions = [ + ('1.4', INTERNAL_FIXTURES_14), ('1.5', INTERNAL_FIXTURES_15), + ('2.0', INTERNAL_FIXTURES_20)] + for protocol_version, fixture in versions: + const = get_const(protocol_version) + for name, payload in fixture.items(): + sub_type = const.Internal[name] + msg = Message('1;255;3;0;{};{}\n'.format(sub_type, payload)) + valid = msg.validate(protocol_version) + assert valid == { + 'node_id': 1, 'child_id': 255, 'type': 3, 'ack': 0, + 'sub_type': sub_type, 'payload': payload} diff --git a/tests/test_mysensors.py b/tests/test_mysensors.py index a40b481e..92771958 100644 --- a/tests/test_mysensors.py +++ b/tests/test_mysensors.py @@ -6,9 +6,10 @@ from collections import deque from unittest import TestCase, main, mock +import voluptuous as vol + from mysensors import (ChildSensor, Gateway, Message, MySensorsJSONEncoder, Sensor) -from mysensors.const_14 import Internal, MessageType class TestGateway(TestCase): @@ -17,7 +18,7 @@ class TestGateway(TestCase): # pylint: disable=too-many-public-methods def setUp(self): - """Setup gateway.""" + """Set up gateway.""" self.gateway = Gateway() def _add_sensor(self, sensorid): @@ -519,20 +520,87 @@ def test_set_child_value_value_type(self): self.assertEqual(child_values, sensor.children[0].values) self.assertEqual(ret, '1;0;1;0;2;1\n') + def test_child_validate(self): + """Test child validate method.""" + sensor = self._add_sensor(1) + sensor.children[0] = ChildSensor( + 0, self.gateway.const.Presentation.S_LIGHT_LEVEL) + sensor.children[0].values[ + self.gateway.const.SetReq.V_LIGHT_LEVEL] = '43' + sensor.children[0].validate(self.gateway.protocol_version) + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_LIGHT_LEVEL], + '43') + sensor.children[0].values[self.gateway.const.SetReq.V_TRIPPED] = '1' + with self.assertRaises(vol.Invalid): + sensor.children[0].validate(self.gateway.protocol_version) + + def test_set_forecast(self): + """Test set of V_FORECAST.""" + sensor = self._add_sensor(1) + sensor.children[0] = ChildSensor( + 0, self.gateway.const.Presentation.S_BARO) + self.gateway.logic('1;0;1;0;5;sunny\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_FORECAST], + 'sunny') + self.gateway.logic('1;0;1;0;5;rainy\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_FORECAST], + 'rainy') + class TestGateway15(TestGateway): """Use protocol_version 1.5.""" def setUp(self): - """Setup gateway.""" + """Set up gateway.""" self.gateway = Gateway(protocol_version='1.5') + def test_set_rgb(self): + """Test set of V_RGB.""" + sensor = self._add_sensor(1) + sensor.protocol_version = '1.5' + sensor.children[0] = ChildSensor( + 0, self.gateway.const.Presentation.S_RGB_LIGHT) + self.gateway.logic('1;0;1;0;40;ffffff\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_RGB], + 'ffffff') + self.gateway.logic('1;0;1;0;40;ffffff00\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_RGB], + 'ffffff') + self.gateway.logic('1;0;1;0;40;nothex\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_RGB], + 'ffffff') + + def test_set_rgbw(self): + """Test set of V_RGBW.""" + sensor = self._add_sensor(1) + sensor.protocol_version = '1.5' + sensor.children[0] = ChildSensor( + 0, self.gateway.const.Presentation.S_RGBW_LIGHT) + self.gateway.logic('1;0;1;0;41;ffffffff\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_RGBW], + 'ffffffff') + self.gateway.logic('1;0;1;0;41;ffffffff00\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_RGBW], + 'ffffffff') + self.gateway.logic('1;0;1;0;41;nothexxx\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_RGBW], + 'ffffffff') + class TestGateway20(TestGateway): """Use protocol_version 2.0.""" def setUp(self): - """Setup gateway.""" + """Set up gateway.""" self.gateway = Gateway(protocol_version='2.0') def test_non_presented_sensor(self): @@ -567,7 +635,7 @@ def test_non_presented_child(self): ret = self.gateway.handle_queue() self.assertEqual(ret, '1;255;3;0;19;\n') - self.gateway.logic('1;1;2;0;1;75\n') + self.gateway.logic('1;1;2;0;1;\n') self.assertNotIn(1, self.gateway.sensors[1].children) ret = self.gateway.handle_queue() self.assertEqual(ret, '1;255;3;0;19;\n') @@ -665,47 +733,24 @@ def test_discover_response_known(self, mock_is_sensor): self.gateway.logic('1;255;3;0;21;0') assert mock_is_sensor.called - -class TestMessage(TestCase): - """Test the Message class and it's encode/decode functions.""" - - def test_encode(self): - """Test encode of message.""" - msg = Message() - cmd = msg.encode() - self.assertEqual(cmd, '0;0;0;0;0;\n') - - msg.node_id = 255 - msg.child_id = 255 - msg.type = MessageType.internal - msg.sub_type = Internal.I_BATTERY_LEVEL - msg.ack = 0 - msg.payload = 57 - - cmd = msg.encode() - self.assertEqual(cmd, '255;255;3;0;0;57\n') - - def test_encode_bad_message(self): - """Test encode of bad message.""" - msg = Message() - msg.sub_type = 'bad' - cmd = msg.encode() - self.assertEqual(cmd, None) - - def test_decode(self): - """Test decode of message.""" - msg = Message('255;255;3;0;0;57\n') - self.assertEqual(msg.node_id, 255) - self.assertEqual(msg.child_id, 255) - self.assertEqual(msg.type, MessageType.internal) - self.assertEqual(msg.sub_type, Internal.I_BATTERY_LEVEL) - self.assertEqual(msg.ack, 0) - self.assertEqual(msg.payload, '57') - - def test_decode_bad_message(self): - """Test decode of bad message.""" - with self.assertRaises(ValueError): - Message('bad;bad;bad;bad;bad;bad\n') + def test_set_position(self): + """Test set of V_POSITION.""" + sensor = self._add_sensor(1) + sensor.protocol_version = '2.0' + sensor.children[0] = ChildSensor( + 0, self.gateway.const.Presentation.S_GPS) + self.gateway.logic('1;0;1;0;49;10.0,10.0,10.0\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_POSITION], + '10.0,10.0,10.0') + self.gateway.logic('1;0;1;0;49;bad,format\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_POSITION], + '10.0,10.0,10.0') + self.gateway.logic('1;0;1;0;41;bad,bad,bad\n') + self.assertEqual( + sensor.children[0].values[self.gateway.const.SetReq.V_POSITION], + '10.0,10.0,10.0') class MySensorsJSONEncoderTestUpgrade(MySensorsJSONEncoder):