Rename to controlpi_plugins (naming conventions).
authorBenjamin Braatz <bb@bbraatz.eu>
Sun, 21 Mar 2021 15:05:41 +0000 (16:05 +0100)
committerBenjamin Braatz <bb@bbraatz.eu>
Sun, 21 Mar 2021 15:05:41 +0000 (16:05 +0100)
controlpi-plugins/state.py [deleted file]
controlpi-plugins/util.py [deleted file]
controlpi-plugins/wait.py [deleted file]
controlpi/__init__.py
controlpi_plugins/state.py [new file with mode: 0644]
controlpi_plugins/util.py [new file with mode: 0644]
controlpi_plugins/wait.py [new file with mode: 0644]

diff --git a/controlpi-plugins/state.py b/controlpi-plugins/state.py
deleted file mode 100644 (file)
index bd9d128..0000000
+++ /dev/null
@@ -1,580 +0,0 @@
-"""Provide state plugins for all kinds of systems.
-
-- State represents a Boolean state.
-- StateAlias translates to another state-like client.
-- AndState combines several state clients by conjunction.
-- OrState combines several state clients by disjunction.
-
-All these plugins use the following conventions:
-
-- If their state changes they send a message containing "event": "changed"
-  and "state": <new state>.
-- If their state is reported due to a message, but did not change they send
-  a message containing just "state": <current state>.
-- If they receive a message containing "target": <name> and
-  "command": "get state" they report their current state.
-- If State (or any other settable state using these conventions) receives
-  a message containing "target": <name>, "command": "set state" and
-  "new state": <state to be set> it changes the state accordingly. If this
-  was really a change the corresponding event is sent. If it was already in
-  this state a report message without "event": "changed" is sent.
-- AndState and OrState instances cannot be set.
-- AndState and OrState can combine any message bus clients using these
-  conventions, not just State instances. They only use the "get state"
-  command (to initialise their own internal state) and react to messages
-  containing "state" information.
-
->>> import asyncio
->>> import controlpi
->>> asyncio.run(controlpi.test(
-...     {"Test State": {"plugin": "State"},
-...      "Test State 2": {"plugin": "State"},
-...      "Test StateAlias": {"plugin": "StateAlias",
-...                          "alias for": "Test State 2"},
-...      "Test AndState": {"plugin": "AndState",
-...                        "states": ["Test State", "Test StateAlias"]},
-...      "Test OrState": {"plugin": "OrState",
-...                       "states": ["Test State", "Test StateAlias"]}},
-...     [{"target": "Test AndState",
-...       "command": "get state"},
-...      {"target": "Test OrState",
-...       "command": "get state"},
-...      {"target": "Test State",
-...       "command": "set state", "new state": True},
-...      {"target": "Test StateAlias",
-...       "command": "set state", "new state": True},
-...      {"target": "Test State",
-...       "command": "set state", "new state": False}]))
-... # doctest: +NORMALIZE_WHITESPACE
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test State', 'plugin': 'State',
-         'sends': [{'event': {'const': 'changed'},
-                    'state': {'type': 'boolean'}},
-                   {'state': {'type': 'boolean'}}],
-         'receives': [{'target': {'const': 'Test State'},
-                       'command': {'const': 'get state'}},
-                      {'target': {'const': 'Test State'},
-                       'command': {'const': 'set state'},
-                       'new state': {'type': 'boolean'}}]}
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test State 2', 'plugin': 'State',
-         'sends': [{'event': {'const': 'changed'},
-                    'state': {'type': 'boolean'}},
-                   {'state': {'type': 'boolean'}}],
-         'receives': [{'target': {'const': 'Test State 2'},
-                       'command': {'const': 'get state'}},
-                      {'target': {'const': 'Test State 2'},
-                       'command': {'const': 'set state'},
-                       'new state': {'type': 'boolean'}}]}
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test StateAlias', 'plugin': 'StateAlias',
-         'sends': [{'event': {'const': 'changed'},
-                    'state': {'type': 'boolean'}},
-                   {'state': {'type': 'boolean'}},
-                   {'target': {'const': 'Test State 2'},
-                    'command': {'const': 'get state'}},
-                   {'target': {'const': 'Test State 2'},
-                    'command': {'const': 'set state'},
-                    'new state': {'type': 'boolean'}}],
-         'receives': [{'target': {'const': 'Test StateAlias'},
-                       'command': {'const': 'get state'}},
-                      {'target': {'const': 'Test StateAlias'},
-                       'command': {'const': 'set state'},
-                       'new state': {'type': 'boolean'}},
-                      {'sender': {'const': 'Test State 2'},
-                       'event': {'const': 'changed'},
-                       'state': {'type': 'boolean'}},
-                      {'sender': {'const': 'Test State 2'},
-                       'state': {'type': 'boolean'}}]}
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test AndState', 'plugin': 'AndState',
-         'sends': [{'event': {'const': 'changed'},
-                    'state': {'type': 'boolean'}},
-                   {'state': {'type': 'boolean'}}],
-         'receives': [{'target': {'const': 'Test AndState'},
-                       'command': {'const': 'get state'}},
-                      {'sender': {'const': 'Test State'},
-                       'state': {'type': 'boolean'}},
-                      {'sender': {'const': 'Test StateAlias'},
-                       'state': {'type': 'boolean'}}]}
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test OrState', 'plugin': 'OrState',
-         'sends': [{'event': {'const': 'changed'},
-                    'state': {'type': 'boolean'}},
-                   {'state': {'type': 'boolean'}}],
-         'receives': [{'target': {'const': 'Test OrState'},
-                       'command': {'const': 'get state'}},
-                      {'sender': {'const': 'Test State'},
-                       'state': {'type': 'boolean'}},
-                      {'sender': {'const': 'Test StateAlias'},
-                       'state': {'type': 'boolean'}}]}
-test(): {'sender': 'test()', 'target': 'Test AndState',
-         'command': 'get state'}
-test(): {'sender': 'Test AndState', 'state': False}
-test(): {'sender': 'test()', 'target': 'Test OrState',
-         'command': 'get state'}
-test(): {'sender': 'Test OrState', 'state': False}
-test(): {'sender': 'test()', 'target': 'Test State',
-         'command': 'set state', 'new state': True}
-test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
-test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
-test(): {'sender': 'test()', 'target': 'Test StateAlias',
-         'command': 'set state', 'new state': True}
-test(): {'sender': 'Test StateAlias', 'target': 'Test State 2',
-         'command': 'set state', 'new state': True}
-test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
-test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
-test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
-test(): {'sender': 'test()', 'target': 'Test State',
-         'command': 'set state', 'new state': False}
-test(): {'sender': 'Test State', 'event': 'changed', 'state': False}
-test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
-"""
-from controlpi import BasePlugin, Message, MessageTemplate
-
-from typing import Dict
-
-
-class State(BasePlugin):
-    """Provide a Boolean state.
-
-    The state of a State plugin instance can be queried with the "get state"
-    command and set with the "set state" command to the new state given by
-    the "new state" key:
-    >>> import asyncio
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test State": {"plugin": "State"}},
-    ...     [{"target": "Test State", "command": "get state"},
-    ...      {"target": "Test State", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test State", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test State", "command": "get state"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test State', 'plugin': 'State',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test State'},
-                           'command': {'const': 'get state'}},
-                          {'target': {'const': 'Test State'},
-                           'command': {'const': 'set state'},
-                           'new state': {'type': 'boolean'}}]}
-    test(): {'sender': 'test()', 'target': 'Test State',
-             'command': 'get state'}
-    test(): {'sender': 'Test State', 'state': False}
-    test(): {'sender': 'test()', 'target': 'Test State',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test State',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test State',
-             'command': 'get state'}
-    test(): {'sender': 'Test State', 'state': True}
-    """
-
-    CONF_SCHEMA = True
-    """Schema for State plugin configuration.
-
-    There are no required or optional configuration keys.
-    """
-
-    async def receive(self, message: Message) -> None:
-        """Process commands to get/set state."""
-        if message['command'] == 'get state':
-            await self.bus.send(Message(self.name, {'state': self.state}))
-        elif message['command'] == 'set state':
-            if self.state != message['new state']:
-                assert isinstance(message['new state'], bool)
-                self.state: bool = message['new state']
-                await self.bus.send(Message(self.name,
-                                            {'event': 'changed',
-                                             'state': self.state}))
-            else:
-                await self.bus.send(Message(self.name,
-                                            {'state': self.state}))
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        self.state = False
-        sends = [MessageTemplate({'event': {'const': 'changed'},
-                                  'state': {'type': 'boolean'}}),
-                 MessageTemplate({'state': {'type': 'boolean'}})]
-        receives = [MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'get state'}}),
-                    MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'set state'},
-                                     'new state': {'type': 'boolean'}})]
-        self.bus.register(self.name, 'State', sends, receives, self.receive)
-
-    async def run(self) -> None:
-        """Run no code proactively."""
-        pass
-
-
-class StateAlias(BasePlugin):
-    """Define an alias for another state.
-
-    The "alias for" configuration key gets the name for the other state that
-    is aliased by the StateAlias plugin instance.
-
-    The "get state" and "set state" commands are forwarded to and the
-    "changed" events and "state" messages are forwarded from this other
-    state:
-    >>> import asyncio
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test State": {"plugin": "State"},
-    ...      "Test StateAlias": {"plugin": "StateAlias",
-    ...                          "alias for": "Test State"}},
-    ...     [{"target": "Test State", "command": "get state"},
-    ...      {"target": "Test StateAlias", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test State", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test StateAlias", "command": "get state"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test State', 'plugin': 'State',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test State'},
-                           'command': {'const': 'get state'}},
-                          {'target': {'const': 'Test State'},
-                           'command': {'const': 'set state'},
-                           'new state': {'type': 'boolean'}}]}
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test StateAlias', 'plugin': 'StateAlias',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}},
-                       {'target': {'const': 'Test State'},
-                        'command': {'const': 'get state'}},
-                       {'target': {'const': 'Test State'},
-                        'command': {'const': 'set state'},
-                        'new state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test StateAlias'},
-                           'command': {'const': 'get state'}},
-                          {'target': {'const': 'Test StateAlias'},
-                           'command': {'const': 'set state'},
-                           'new state': {'type': 'boolean'}},
-                          {'sender': {'const': 'Test State'},
-                           'event': {'const': 'changed'},
-                           'state': {'type': 'boolean'}},
-                          {'sender': {'const': 'Test State'},
-                           'state': {'type': 'boolean'}}]}
-    test(): {'sender': 'test()', 'target': 'Test State',
-             'command': 'get state'}
-    test(): {'sender': 'Test State', 'state': False}
-    test(): {'sender': 'Test StateAlias', 'state': False}
-    test(): {'sender': 'test()', 'target': 'Test StateAlias',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
-    test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test State',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State', 'state': True}
-    test(): {'sender': 'Test StateAlias', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test StateAlias',
-             'command': 'get state'}
-    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
-             'command': 'get state'}
-    test(): {'sender': 'Test State', 'state': True}
-    test(): {'sender': 'Test StateAlias', 'state': True}
-    """
-
-    CONF_SCHEMA = {'properties': {'alias for': {'type': 'string'}},
-                   'required': ['alias for']}
-    """Schema for StateAlias plugin configuration.
-
-    Required configuration key:
-
-    - 'alias for': name of aliased state.
-    """
-
-    async def receive(self, message: Message) -> None:
-        """Translate states from and commands to aliased state."""
-        alias_message = Message(self.name)
-        if ('target' in message and message['target'] == self.name and
-                'command' in message):
-            alias_message['target'] = self.conf['alias for']
-            if message['command'] == 'get state':
-                alias_message['command'] = 'get state'
-                await self.bus.send(alias_message)
-            elif (message['command'] == 'set state' and
-                    'new state' in message):
-                alias_message['command'] = 'set state'
-                alias_message['new state'] = message['new state']
-                await self.bus.send(alias_message)
-        if (message['sender'] == self.conf['alias for'] and
-                'state' in message):
-            if 'event' in message and message['event'] == 'changed':
-                alias_message['event'] = 'changed'
-            alias_message['state'] = message['state']
-            await self.bus.send(alias_message)
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        sends = [MessageTemplate({'target': {'const': self.conf['alias for']},
-                                  'command': {'const': 'get state'}}),
-                 MessageTemplate({'target': {'const': self.conf['alias for']},
-                                  'command': {'const': 'set state'},
-                                  'new state': {'type': 'boolean'}}),
-                 MessageTemplate({'event': {'const': 'changed'},
-                                  'state': {'type': 'boolean'}}),
-                 MessageTemplate({'state': {'type': 'boolean'}})]
-        receives = [MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'get state'}}),
-                    MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'set state'},
-                                     'new state': {'type': 'boolean'}}),
-                    MessageTemplate({'sender':
-                                     {'const': self.conf['alias for']},
-                                     'event': {'const': 'changed'},
-                                     'state': {'type': 'boolean'}}),
-                    MessageTemplate({'sender':
-                                     {'const': self.conf['alias for']},
-                                     'state': {'type': 'boolean'}})]
-        self.bus.register(self.name, 'StateAlias',
-                          sends, receives, self.receive)
-
-    async def run(self) -> None:
-        """Run no code proactively."""
-        pass
-
-
-class AndState(BasePlugin):
-    """Implement conjunction of states.
-
-    The "states" configuration key gets an array of states to be combined.
-    An AndState plugin client reacts to "get state" commands and sends
-    "changed" events when a change in one of the combined states leads to
-    a change for the conjunction:
-    >>> import asyncio
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test State 1": {"plugin": "State"},
-    ...      "Test State 2": {"plugin": "State"},
-    ...      "Test AndState": {"plugin": "AndState",
-    ...                        "states": ["Test State 1", "Test State 2"]}},
-    ...     [{"target": "Test State 1", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test State 2", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test State 1", "command": "set state",
-    ...       "new state": False},
-    ...      {"target": "Test AndState", "command": "get state"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test State 1', 'plugin': 'State',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test State 1'},
-                           'command': {'const': 'get state'}},
-                          {'target': {'const': 'Test State 1'},
-                           'command': {'const': 'set state'},
-                           'new state': {'type': 'boolean'}}]}
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test State 2', 'plugin': 'State',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test State 2'},
-                           'command': {'const': 'get state'}},
-                          {'target': {'const': 'Test State 2'},
-                           'command': {'const': 'set state'},
-                           'new state': {'type': 'boolean'}}]}
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test AndState', 'plugin': 'AndState',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test AndState'},
-                           'command': {'const': 'get state'}},
-                          {'sender': {'const': 'Test State 1'},
-                           'state': {'type': 'boolean'}},
-                          {'sender': {'const': 'Test State 2'},
-                           'state': {'type': 'boolean'}}]}
-    test(): {'sender': 'test()', 'target': 'Test State 1',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test State 2',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
-    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test State 1',
-             'command': 'set state', 'new state': False}
-    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
-    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
-    test(): {'sender': 'test()', 'target': 'Test AndState',
-             'command': 'get state'}
-    test(): {'sender': 'Test AndState', 'state': False}
-    """
-
-    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
-                                             'items': {'type': 'string'}}},
-                   'required': ['states']}
-    """Schema for AndState plugin configuration.
-
-    Required configuration key:
-
-    - 'states': list of names of combined states.
-    """
-
-    async def receive(self, message: Message) -> None:
-        """."""
-        if ('target' in message and message['target'] == self.name and
-                'command' in message and message['command'] == 'get state'):
-            await self.bus.send(Message(self.name, {'state': self.state}))
-        if 'state' in message and message['sender'] in self.conf['states']:
-            assert isinstance(message['sender'], str)
-            assert isinstance(message['state'], bool)
-            self.states[message['sender']] = message['state']
-            new_state = all(self.states.values())
-            if self.state != new_state:
-                self.state: bool = new_state
-                await self.bus.send(Message(self.name,
-                                            {'event': 'changed',
-                                             'state': self.state}))
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        sends = [MessageTemplate({'event': {'const': 'changed'},
-                                  'state': {'type': 'boolean'}}),
-                 MessageTemplate({'state': {'type': 'boolean'}})]
-        receives = [MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'get state'}})]
-        self.states: Dict[str, bool] = {}
-        for state in self.conf['states']:
-            receives.append(MessageTemplate({'sender': {'const': state},
-                                             'state': {'type': 'boolean'}}))
-            self.states[state] = False
-        self.state = all(self.states.values())
-        self.bus.register(self.name, 'AndState',
-                          sends, receives, self.receive)
-
-    async def run(self) -> None:
-        """Run no code proactively."""
-        pass
-
-
-class OrState(BasePlugin):
-    """Implement disjunction of states.
-
-    The "states" configuration key gets an array of states to be combined.
-    An OrState plugin client reacts to "get state" commands and sends
-    "changed" events when a change in one of the combined states leads to
-    a change for the disjunction:
-    >>> import asyncio
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test State 1": {"plugin": "State"},
-    ...      "Test State 2": {"plugin": "State"},
-    ...      "Test OrState": {"plugin": "OrState",
-    ...                       "states": ["Test State 1", "Test State 2"]}},
-    ...     [{"target": "Test State 1", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test State 2", "command": "set state",
-    ...       "new state": True},
-    ...      {"target": "Test State 1", "command": "set state",
-    ...       "new state": False},
-    ...      {"target": "Test OrState", "command": "get state"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test State 1', 'plugin': 'State',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test State 1'},
-                           'command': {'const': 'get state'}},
-                          {'target': {'const': 'Test State 1'},
-                           'command': {'const': 'set state'},
-                           'new state': {'type': 'boolean'}}]}
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test State 2', 'plugin': 'State',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test State 2'},
-                           'command': {'const': 'get state'}},
-                          {'target': {'const': 'Test State 2'},
-                           'command': {'const': 'set state'},
-                           'new state': {'type': 'boolean'}}]}
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test OrState', 'plugin': 'OrState',
-             'sends': [{'event': {'const': 'changed'},
-                        'state': {'type': 'boolean'}},
-                       {'state': {'type': 'boolean'}}],
-             'receives': [{'target': {'const': 'Test OrState'},
-                           'command': {'const': 'get state'}},
-                          {'sender': {'const': 'Test State 1'},
-                           'state': {'type': 'boolean'}},
-                          {'sender': {'const': 'Test State 2'},
-                           'state': {'type': 'boolean'}}]}
-    test(): {'sender': 'test()', 'target': 'Test State 1',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
-    test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test State 2',
-             'command': 'set state', 'new state': True}
-    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
-    test(): {'sender': 'test()', 'target': 'Test State 1',
-             'command': 'set state', 'new state': False}
-    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
-    test(): {'sender': 'test()', 'target': 'Test OrState',
-             'command': 'get state'}
-    test(): {'sender': 'Test OrState', 'state': True}
-    """
-
-    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
-                                             'items': {'type': 'string'}}},
-                   'required': ['states']}
-    """Schema for OrState plugin configuration.
-
-    Required configuration key:
-
-    - 'states': list of names of combined states.
-    """
-
-    async def receive(self, message: Message) -> None:
-        """."""
-        if ('target' in message and message['target'] == self.name and
-                'command' in message and message['command'] == 'get state'):
-            await self.bus.send(Message(self.name, {'state': self.state}))
-        if 'state' in message and message['sender'] in self.conf['states']:
-            assert isinstance(message['sender'], str)
-            assert isinstance(message['state'], bool)
-            self.states[message['sender']] = message['state']
-            new_state = any(self.states.values())
-            if self.state != new_state:
-                self.state: bool = new_state
-                await self.bus.send(Message(self.name,
-                                            {'event': 'changed',
-                                             'state': self.state}))
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        sends = [MessageTemplate({'event': {'const': 'changed'},
-                                  'state': {'type': 'boolean'}}),
-                 MessageTemplate({'state': {'type': 'boolean'}})]
-        receives = [MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'get state'}})]
-        self.states: Dict[str, bool] = {}
-        for state in self.conf['states']:
-            receives.append(MessageTemplate({'sender': {'const': state},
-                                             'state': {'type': 'boolean'}}))
-            self.states[state] = False
-        self.state = any(self.states.values())
-        self.bus.register(self.name, 'OrState',
-                          sends, receives, self.receive)
-
-    async def run(self) -> None:
-        """Run no code proactively."""
-        pass
diff --git a/controlpi-plugins/util.py b/controlpi-plugins/util.py
deleted file mode 100644 (file)
index 313e50b..0000000
+++ /dev/null
@@ -1,397 +0,0 @@
-"""Provide utility plugins for all kinds of systems.
-
-- Log logs messages on stdout.
-- Init sends list of messages on startup and on demand.
-- Alias translates messages to an alias.
-
->>> import controlpi
->>> asyncio.run(controlpi.test(
-...     {"Test Log": {"plugin": "Log",
-...                   "filter": [{"sender": {"const": "Test Alias"}}]},
-...      "Test Init": {"plugin": "Init",
-...                    "messages": [{"id": 42, "content": "Test Message"}]},
-...      "Test Alias": {"plugin": "Alias",
-...                     "from": {"sender": {"const": "Test Init"},
-...                              "id": {"const": 42}},
-...                     "to": {"id": "translated"}}}, []))
-... # doctest: +NORMALIZE_WHITESPACE
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test Log', 'plugin': 'Log',
-         'sends': [], 'receives': [{'sender': {'const': 'Test Alias'}}]}
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test Init', 'plugin': 'Init',
-         'sends': [{'id': {'const': 42},
-                    'content': {'const': 'Test Message'}}],
-         'receives': [{'target': {'const': 'Test Init'},
-                       'command': {'const': 'execute'}}]}
-test(): {'sender': '', 'event': 'registered',
-         'client': 'Test Alias', 'plugin': 'Alias',
-         'sends': [{'id': {'const': 'translated'}}],
-         'receives': [{'sender': {'const': 'Test Init'},
-                       'id': {'const': 42}}]}
-test(): {'sender': 'Test Init', 'id': 42,
-         'content': 'Test Message'}
-test(): {'sender': 'Test Alias', 'id': 'translated',
-         'content': 'Test Message'}
-Test Log: {'sender': 'Test Alias', 'id': 'translated',
-           'content': 'Test Message'}
-"""
-import asyncio
-
-from controlpi import BasePlugin, Message, MessageTemplate
-
-
-class Log(BasePlugin):
-    """Log messages on stdout.
-
-    The "filter" configuration key gets a list of message templates defining
-    the messages that should be logged by the plugin instance.
-
-    In the following example the first and third message match the given
-    template and are logged by the instance "Test Log", while the second
-    message does not match and is only logged by the test, but not by the
-    Log instance:
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Log": {"plugin": "Log",
-    ...                   "filter": [{"id": {"const": 42}}]}},
-    ...     [{"id": 42, "message": "Test Message"},
-    ...      {"id": 42.42, "message": "Second Message"},
-    ...      {"id": 42, "message": "Third Message"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test Log', 'plugin': 'Log',
-             'sends': [], 'receives': [{'id': {'const': 42}}]}
-    test(): {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
-    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
-    test(): {'sender': 'test()', 'id': 42.42, 'message': 'Second Message'}
-    test(): {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
-    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
-
-    The "filter" key is required:
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Log": {"plugin": "Log"}}, []))
-    'filter' is a required property
-    <BLANKLINE>
-    Failed validating 'required' in schema:
-        {'properties': {'filter': {'items': {'type': 'object'},
-                                   'type': 'array'}},
-         'required': ['filter']}
-    <BLANKLINE>
-    On instance:
-        {'plugin': 'Log'}
-    Configuration for 'Test Log' is not valid.
-
-    The "filter" key has to contain a list of message templates, i.e.,
-    JSON objects:
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Log": {"plugin": "Log",
-    ...                   "filter": [42]}}, []))
-    42 is not of type 'object'
-    <BLANKLINE>
-    Failed validating 'type' in schema['properties']['filter']['items']:
-        {'type': 'object'}
-    <BLANKLINE>
-    On instance['filter'][0]:
-        42
-    Configuration for 'Test Log' is not valid.
-    """
-
-    CONF_SCHEMA = {'properties': {'filter': {'type': 'array',
-                                             'items': {'type': 'object'}}},
-                   'required': ['filter']}
-    """Schema for Log plugin configuration.
-
-    Required configuration key:
-
-    - 'filter': list of message templates to be logged.
-    """
-
-    async def log(self, message: Message) -> None:
-        """Log received message on stdout using own name as prefix."""
-        print(f"{self.name}: {message}")
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        self.bus.register(self.name, 'Log', [], self.conf['filter'], self.log)
-
-    async def run(self) -> None:
-        """Run no code proactively."""
-        pass
-
-
-class Init(BasePlugin):
-    """Send list of messages on startup and on demand.
-
-    The "messages" configuration key gets a list of messages to be sent on
-    startup. The same list is sent in reaction to a message with
-    "target": <name> and "command": "execute".
-
-    In the example, the two configured messages are sent twice, once at
-    startup and a second time in reaction to the "execute" command sent by
-    the test:
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Init": {"plugin": "Init",
-    ...                    "messages": [{"id": 42,
-    ...                                  "content": "Test Message"},
-    ...                                 {"id": 42.42,
-    ...                                  "content": "Second Message"}]}},
-    ...     [{"target": "Test Init", "command": "execute"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test Init', 'plugin': 'Init',
-             'sends': [{'id': {'const': 42},
-                        'content': {'const': 'Test Message'}},
-                       {'id': {'const': 42.42},
-                        'content': {'const': 'Second Message'}}],
-             'receives': [{'target': {'const': 'Test Init'},
-                           'command': {'const': 'execute'}}]}
-    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
-    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
-    test(): {'sender': 'test()', 'target': 'Test Init', 'command': 'execute'}
-    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
-    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
-
-    The "messages" key is required:
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Init": {"plugin": "Init"}}, []))
-    'messages' is a required property
-    <BLANKLINE>
-    Failed validating 'required' in schema:
-        {'properties': {'messages': {'items': {'type': 'object'},
-                                     'type': 'array'}},
-         'required': ['messages']}
-    <BLANKLINE>
-    On instance:
-        {'plugin': 'Init'}
-    Configuration for 'Test Init' is not valid.
-
-    The "messages" key has to contain a list of (partial) messages, i.e.,
-    JSON objects:
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Init": {"plugin": "Init",
-    ...                    "messages": [42]}}, []))
-    42 is not of type 'object'
-    <BLANKLINE>
-    Failed validating 'type' in schema['properties']['messages']['items']:
-        {'type': 'object'}
-    <BLANKLINE>
-    On instance['messages'][0]:
-        42
-    Configuration for 'Test Init' is not valid.
-    """
-
-    CONF_SCHEMA = {'properties': {'messages': {'type': 'array',
-                                               'items': {'type': 'object'}}},
-                   'required': ['messages']}
-    """Schema for Init plugin configuration.
-
-    Required configuration key:
-
-    - 'messages': list of messages to be sent.
-    """
-
-    async def execute(self, message: Message) -> None:
-        """Send configured messages."""
-        for message in self.conf['messages']:
-            await self.bus.send(Message(self.name, message))
-            # Give immediate reactions to messages opportunity to happen:
-            await asyncio.sleep(0)
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        receives = [MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'execute'}})]
-        sends = [MessageTemplate.from_message(message)
-                 for message in self.conf['messages']]
-        self.bus.register(self.name, 'Init', sends, receives, self.execute)
-
-    async def run(self) -> None:
-        """Send configured messages on startup."""
-        for message in self.conf['messages']:
-            await self.bus.send(Message(self.name, message))
-
-
-class Execute(BasePlugin):
-    """Send configurable list of messages on demand.
-
-    An Execute plugin instance receives two kinds of commands.
-    The "set messages" command has a "messages" key with a list of (partial)
-    messages, which are sent by the Execute instance in reaction to an
-    "execute" command.
-
-    In the example, the first command sent by the test sets two messages,
-    which are then sent in reaction to the second command sent by the test:
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Execute": {"plugin": "Execute"}},
-    ...     [{"target": "Test Execute", "command": "set messages",
-    ...       "messages": [{"id": 42, "content": "Test Message"},
-    ...                    {"id": 42.42, "content": "Second Message"}]},
-    ...      {"target": "Test Execute", "command": "execute"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test Execute', 'plugin': 'Execute',
-             'sends': [{}],
-             'receives': [{'target': {'const': 'Test Execute'},
-                           'command': {'const': 'set messages'},
-                           'messages': {'type': 'array',
-                                        'items': {'type': 'object'}}},
-                          {'target': {'const': 'Test Execute'},
-                           'command': {'const': 'execute'}}]}
-    test(): {'sender': 'test()', 'target': 'Test Execute',
-             'command': 'set messages',
-             'messages': [{'id': 42, 'content': 'Test Message'},
-                          {'id': 42.42, 'content': 'Second Message'}]}
-    test(): {'sender': 'test()', 'target': 'Test Execute',
-             'command': 'execute'}
-    test(): {'sender': 'Test Execute', 'id': 42,
-             'content': 'Test Message'}
-    test(): {'sender': 'Test Execute', 'id': 42.42,
-             'content': 'Second Message'}
-    """
-
-    CONF_SCHEMA = True
-    """Schema for Execute plugin configuration.
-
-    There are no required or optional configuration keys.
-    """
-
-    async def execute(self, message: Message) -> None:
-        """Set or send configured messages."""
-        if message['command'] == 'set messages':
-            assert isinstance(message['messages'], list)
-            self.messages = list(message['messages'])
-        elif message['command'] == 'execute':
-            for message in self.messages:
-                await self.bus.send(Message(self.name, message))
-                # Give immediate reactions to messages opportunity to happen:
-                await asyncio.sleep(0)
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        self.messages = []
-        receives = [MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'set messages'},
-                                     'messages':
-                                     {'type': 'array',
-                                      'items': {'type': 'object'}}}),
-                    MessageTemplate({'target': {'const': self.name},
-                                     'command': {'const': 'execute'}})]
-        sends = [MessageTemplate()]
-        self.bus.register(self.name, 'Execute', sends, receives, self.execute)
-
-    async def run(self) -> None:
-        """Run no code proactively."""
-        pass
-
-
-class Alias(BasePlugin):
-    """Translate messages to an alias.
-
-    The "from" configuration key gets a message template and the
-    configuration key "to" a (partial) message. All messages matching the
-    template are received by the Alias instance and a message translated by
-    removing the keys of the "from" template and adding the keys and values
-    of the "to" message is sent. Keys that are neither in "from" nor in "to"
-    are retained.
-
-    In the example, the two messages sent by the test are translated by the
-    Alias instance and the translated messages are sent by it preserving
-    the "content" keys:
-    >>> import controlpi
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Alias": {"plugin": "Alias",
-    ...                     "from": {"id": {"const": 42}},
-    ...                     "to": {"id": "translated"}}},
-    ...     [{"id": 42, "content": "Test Message"},
-    ...      {"id": 42, "content": "Second Message"}]))
-    ... # doctest: +NORMALIZE_WHITESPACE
-    test(): {'sender': '', 'event': 'registered',
-             'client': 'Test Alias', 'plugin': 'Alias',
-             'sends': [{'id': {'const': 'translated'}}],
-             'receives': [{'id': {'const': 42}}]}
-    test(): {'sender': 'test()', 'id': 42,
-             'content': 'Test Message'}
-    test(): {'sender': 'Test Alias', 'id': 'translated',
-             'content': 'Test Message'}
-    test(): {'sender': 'test()', 'id': 42,
-             'content': 'Second Message'}
-    test(): {'sender': 'Test Alias', 'id': 'translated',
-             'content': 'Second Message'}
-
-    The "from" and "to" keys are required:
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Alias": {"plugin": "Alias"}}, []))
-    'from' is a required property
-    <BLANKLINE>
-    Failed validating 'required' in schema:
-        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
-         'required': ['from', 'to']}
-    <BLANKLINE>
-    On instance:
-        {'plugin': 'Alias'}
-    'to' is a required property
-    <BLANKLINE>
-    Failed validating 'required' in schema:
-        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
-         'required': ['from', 'to']}
-    <BLANKLINE>
-    On instance:
-        {'plugin': 'Alias'}
-    Configuration for 'Test Alias' is not valid.
-
-    The "from" key has to contain a message template and the "to" key a
-    (partial) message, i.e., both have to be JSON objects:
-    >>> asyncio.run(controlpi.test(
-    ...     {"Test Alias": {"plugin": "Alias",
-    ...                     "from": 42,
-    ...                     "to": 42}}, []))
-    42 is not of type 'object'
-    <BLANKLINE>
-    Failed validating 'type' in schema['properties']['from']:
-        {'type': 'object'}
-    <BLANKLINE>
-    On instance['from']:
-        42
-    42 is not of type 'object'
-    <BLANKLINE>
-    Failed validating 'type' in schema['properties']['to']:
-        {'type': 'object'}
-    <BLANKLINE>
-    On instance['to']:
-        42
-    Configuration for 'Test Alias' is not valid.
-    """
-
-    CONF_SCHEMA = {'properties': {'from': {'type': 'object'},
-                                  'to': {'type': 'object'}},
-                   'required': ['from', 'to']}
-    """Schema for Alias plugin configuration.
-
-    Required configuration keys:
-
-    - 'from': template of messages to be translated.
-    - 'to': translated message to be sent.
-    """
-
-    async def alias(self, message: Message) -> None:
-        """Translate and send message."""
-        alias_message = Message(self.name)
-        alias_message.update(self.conf['to'])
-        for key in message:
-            if key != 'sender' and key not in self.conf['from']:
-                alias_message[key] = message[key]
-        await self.bus.send(alias_message)
-
-    def process_conf(self) -> None:
-        """Register plugin as bus client."""
-        self.bus.register(self.name, 'Alias',
-                          [MessageTemplate.from_message(self.conf['to'])],
-                          [self.conf['from']],
-                          self.alias)
-
-    async def run(self) -> None:
-        """Run no code proactively."""
-        pass
diff --git a/controlpi-plugins/wait.py b/controlpi-plugins/wait.py
deleted file mode 100644 (file)
index afe5431..0000000
+++ /dev/null
@@ -1,44 +0,0 @@
-"""Provide waiting/sleeping plugins for all kinds of systems.
-
-TODO: documentation, doctests
-"""
-import asyncio
-
-from controlpi import BasePlugin, Message
-
-
-class Wait(BasePlugin):
-    CONF_SCHEMA = {'properties': {'seconds': {'type': 'number'}},
-                   'required': ['seconds']}
-
-    async def wait(self, message: Message) -> None:
-        await asyncio.sleep(self.conf['seconds'])
-        await self.bus.send({'sender': self.name, 'event': 'finished'})
-
-    def process_conf(self) -> None:
-        receives = [{'target': {'const': self.name},
-                     'command': {'const': 'wait'}}]
-        sends = [{'event': {'const': 'finished'}}]
-        self.bus.register(self.name, 'Wait', sends, receives, self.wait)
-
-    async def run(self) -> None:
-        pass
-
-
-class GenericWait(BasePlugin):
-    CONF_SCHEMA = True
-
-    async def wait(self, message: Message) -> None:
-        await asyncio.sleep(message['seconds'])
-        await self.bus.send(Message(self.name, {'id': message['id']}))
-
-    def process_conf(self) -> None:
-        receives = [{'target': {'const': self.name},
-                     'command': {'const': 'wait'},
-                     'seconds': {'type': 'number'},
-                     'id': {'type': 'string'}}]
-        sends = [{'id': {'type': 'string'}}]
-        self.bus.register(self.name, 'GenericWait', sends, receives, self.wait)
-
-    async def run(self) -> None:
-        pass
index e3e5d5676e997965737bdc1c46846ee0d0181912..658b7754684fe3e1e476f123899c5db4a45c7042 100644 (file)
@@ -34,7 +34,7 @@ def _process_conf(message_bus: MessageBus,
         valid = False
     if not valid:
         return []
-    plugins = PluginRegistry('controlpi-plugins', BasePlugin)
+    plugins = PluginRegistry('controlpi_plugins', BasePlugin)
     coroutines = [message_bus.run()]
     for instance_name in conf:
         instance_conf = conf[instance_name]
diff --git a/controlpi_plugins/state.py b/controlpi_plugins/state.py
new file mode 100644 (file)
index 0000000..bd9d128
--- /dev/null
@@ -0,0 +1,580 @@
+"""Provide state plugins for all kinds of systems.
+
+- State represents a Boolean state.
+- StateAlias translates to another state-like client.
+- AndState combines several state clients by conjunction.
+- OrState combines several state clients by disjunction.
+
+All these plugins use the following conventions:
+
+- If their state changes they send a message containing "event": "changed"
+  and "state": <new state>.
+- If their state is reported due to a message, but did not change they send
+  a message containing just "state": <current state>.
+- If they receive a message containing "target": <name> and
+  "command": "get state" they report their current state.
+- If State (or any other settable state using these conventions) receives
+  a message containing "target": <name>, "command": "set state" and
+  "new state": <state to be set> it changes the state accordingly. If this
+  was really a change the corresponding event is sent. If it was already in
+  this state a report message without "event": "changed" is sent.
+- AndState and OrState instances cannot be set.
+- AndState and OrState can combine any message bus clients using these
+  conventions, not just State instances. They only use the "get state"
+  command (to initialise their own internal state) and react to messages
+  containing "state" information.
+
+>>> import asyncio
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test State": {"plugin": "State"},
+...      "Test State 2": {"plugin": "State"},
+...      "Test StateAlias": {"plugin": "StateAlias",
+...                          "alias for": "Test State 2"},
+...      "Test AndState": {"plugin": "AndState",
+...                        "states": ["Test State", "Test StateAlias"]},
+...      "Test OrState": {"plugin": "OrState",
+...                       "states": ["Test State", "Test StateAlias"]}},
+...     [{"target": "Test AndState",
+...       "command": "get state"},
+...      {"target": "Test OrState",
+...       "command": "get state"},
+...      {"target": "Test State",
+...       "command": "set state", "new state": True},
+...      {"target": "Test StateAlias",
+...       "command": "set state", "new state": True},
+...      {"target": "Test State",
+...       "command": "set state", "new state": False}]))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test State 2', 'plugin': 'State',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test State 2'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test State 2'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test StateAlias', 'plugin': 'StateAlias',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}},
+                   {'target': {'const': 'Test State 2'},
+                    'command': {'const': 'get state'}},
+                   {'target': {'const': 'Test State 2'},
+                    'command': {'const': 'set state'},
+                    'new state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'get state'}},
+                      {'target': {'const': 'Test StateAlias'},
+                       'command': {'const': 'set state'},
+                       'new state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'event': {'const': 'changed'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test State 2'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test AndState', 'plugin': 'AndState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test AndState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test StateAlias'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test OrState', 'plugin': 'OrState',
+         'sends': [{'event': {'const': 'changed'},
+                    'state': {'type': 'boolean'}},
+                   {'state': {'type': 'boolean'}}],
+         'receives': [{'target': {'const': 'Test OrState'},
+                       'command': {'const': 'get state'}},
+                      {'sender': {'const': 'Test State'},
+                       'state': {'type': 'boolean'}},
+                      {'sender': {'const': 'Test StateAlias'},
+                       'state': {'type': 'boolean'}}]}
+test(): {'sender': 'test()', 'target': 'Test AndState',
+         'command': 'get state'}
+test(): {'sender': 'Test AndState', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test OrState',
+         'command': 'get state'}
+test(): {'sender': 'Test OrState', 'state': False}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test StateAlias',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test StateAlias', 'target': 'Test State 2',
+         'command': 'set state', 'new state': True}
+test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
+test(): {'sender': 'test()', 'target': 'Test State',
+         'command': 'set state', 'new state': False}
+test(): {'sender': 'Test State', 'event': 'changed', 'state': False}
+test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
+"""
+from controlpi import BasePlugin, Message, MessageTemplate
+
+from typing import Dict
+
+
+class State(BasePlugin):
+    """Provide a Boolean state.
+
+    The state of a State plugin instance can be queried with the "get state"
+    command and set with the "set state" command to the new state given by
+    the "new state" key:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State": {"plugin": "State"}},
+    ...     [{"target": "Test State", "command": "get state"},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': True}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for State plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Process commands to get/set state."""
+        if message['command'] == 'get state':
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        elif message['command'] == 'set state':
+            if self.state != message['new state']:
+                assert isinstance(message['new state'], bool)
+                self.state: bool = message['new state']
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+            else:
+                await self.bus.send(Message(self.name,
+                                            {'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.state = False
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set state'},
+                                     'new state': {'type': 'boolean'}})]
+        self.bus.register(self.name, 'State', sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class StateAlias(BasePlugin):
+    """Define an alias for another state.
+
+    The "alias for" configuration key gets the name for the other state that
+    is aliased by the StateAlias plugin instance.
+
+    The "get state" and "set state" commands are forwarded to and the
+    "changed" events and "state" messages are forwarded from this other
+    state:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State": {"plugin": "State"},
+    ...      "Test StateAlias": {"plugin": "StateAlias",
+    ...                          "alias for": "Test State"}},
+    ...     [{"target": "Test State", "command": "get state"},
+    ...      {"target": "Test StateAlias", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test StateAlias", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test StateAlias', 'plugin': 'StateAlias',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}},
+                       {'target': {'const': 'Test State'},
+                        'command': {'const': 'get state'}},
+                       {'target': {'const': 'Test State'},
+                        'command': {'const': 'set state'},
+                        'new state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test StateAlias'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test StateAlias'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State'},
+                           'event': {'const': 'changed'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': False}
+    test(): {'sender': 'Test StateAlias', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test StateAlias',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test StateAlias',
+             'command': 'get state'}
+    test(): {'sender': 'Test StateAlias', 'target': 'Test State',
+             'command': 'get state'}
+    test(): {'sender': 'Test State', 'state': True}
+    test(): {'sender': 'Test StateAlias', 'state': True}
+    """
+
+    CONF_SCHEMA = {'properties': {'alias for': {'type': 'string'}},
+                   'required': ['alias for']}
+    """Schema for StateAlias plugin configuration.
+
+    Required configuration key:
+
+    - 'alias for': name of aliased state.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """Translate states from and commands to aliased state."""
+        alias_message = Message(self.name)
+        if ('target' in message and message['target'] == self.name and
+                'command' in message):
+            alias_message['target'] = self.conf['alias for']
+            if message['command'] == 'get state':
+                alias_message['command'] = 'get state'
+                await self.bus.send(alias_message)
+            elif (message['command'] == 'set state' and
+                    'new state' in message):
+                alias_message['command'] = 'set state'
+                alias_message['new state'] = message['new state']
+                await self.bus.send(alias_message)
+        if (message['sender'] == self.conf['alias for'] and
+                'state' in message):
+            if 'event' in message and message['event'] == 'changed':
+                alias_message['event'] = 'changed'
+            alias_message['state'] = message['state']
+            await self.bus.send(alias_message)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'target': {'const': self.conf['alias for']},
+                                  'command': {'const': 'get state'}}),
+                 MessageTemplate({'target': {'const': self.conf['alias for']},
+                                  'command': {'const': 'set state'},
+                                  'new state': {'type': 'boolean'}}),
+                 MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set state'},
+                                     'new state': {'type': 'boolean'}}),
+                    MessageTemplate({'sender':
+                                     {'const': self.conf['alias for']},
+                                     'event': {'const': 'changed'},
+                                     'state': {'type': 'boolean'}}),
+                    MessageTemplate({'sender':
+                                     {'const': self.conf['alias for']},
+                                     'state': {'type': 'boolean'}})]
+        self.bus.register(self.name, 'StateAlias',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class AndState(BasePlugin):
+    """Implement conjunction of states.
+
+    The "states" configuration key gets an array of states to be combined.
+    An AndState plugin client reacts to "get state" commands and sends
+    "changed" events when a change in one of the combined states leads to
+    a change for the conjunction:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State 1": {"plugin": "State"},
+    ...      "Test State 2": {"plugin": "State"},
+    ...      "Test AndState": {"plugin": "AndState",
+    ...                        "states": ["Test State 1", "Test State 2"]}},
+    ...     [{"target": "Test State 1", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 2", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 1", "command": "set state",
+    ...       "new state": False},
+    ...      {"target": "Test AndState", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 1', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 1'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 1'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 2', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 2'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 2'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test AndState', 'plugin': 'AndState',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test AndState'},
+                           'command': {'const': 'get state'}},
+                          {'sender': {'const': 'Test State 1'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State 2'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 2',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': False}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+    test(): {'sender': 'Test AndState', 'event': 'changed', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test AndState',
+             'command': 'get state'}
+    test(): {'sender': 'Test AndState', 'state': False}
+    """
+
+    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
+                                             'items': {'type': 'string'}}},
+                   'required': ['states']}
+    """Schema for AndState plugin configuration.
+
+    Required configuration key:
+
+    - 'states': list of names of combined states.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """."""
+        if ('target' in message and message['target'] == self.name and
+                'command' in message and message['command'] == 'get state'):
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        if 'state' in message and message['sender'] in self.conf['states']:
+            assert isinstance(message['sender'], str)
+            assert isinstance(message['state'], bool)
+            self.states[message['sender']] = message['state']
+            new_state = all(self.states.values())
+            if self.state != new_state:
+                self.state: bool = new_state
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}})]
+        self.states: Dict[str, bool] = {}
+        for state in self.conf['states']:
+            receives.append(MessageTemplate({'sender': {'const': state},
+                                             'state': {'type': 'boolean'}}))
+            self.states[state] = False
+        self.state = all(self.states.values())
+        self.bus.register(self.name, 'AndState',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class OrState(BasePlugin):
+    """Implement disjunction of states.
+
+    The "states" configuration key gets an array of states to be combined.
+    An OrState plugin client reacts to "get state" commands and sends
+    "changed" events when a change in one of the combined states leads to
+    a change for the disjunction:
+    >>> import asyncio
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test State 1": {"plugin": "State"},
+    ...      "Test State 2": {"plugin": "State"},
+    ...      "Test OrState": {"plugin": "OrState",
+    ...                       "states": ["Test State 1", "Test State 2"]}},
+    ...     [{"target": "Test State 1", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 2", "command": "set state",
+    ...       "new state": True},
+    ...      {"target": "Test State 1", "command": "set state",
+    ...       "new state": False},
+    ...      {"target": "Test OrState", "command": "get state"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 1', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 1'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 1'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test State 2', 'plugin': 'State',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test State 2'},
+                           'command': {'const': 'get state'}},
+                          {'target': {'const': 'Test State 2'},
+                           'command': {'const': 'set state'},
+                           'new state': {'type': 'boolean'}}]}
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test OrState', 'plugin': 'OrState',
+             'sends': [{'event': {'const': 'changed'},
+                        'state': {'type': 'boolean'}},
+                       {'state': {'type': 'boolean'}}],
+             'receives': [{'target': {'const': 'Test OrState'},
+                           'command': {'const': 'get state'}},
+                          {'sender': {'const': 'Test State 1'},
+                           'state': {'type': 'boolean'}},
+                          {'sender': {'const': 'Test State 2'},
+                           'state': {'type': 'boolean'}}]}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': True}
+    test(): {'sender': 'Test OrState', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 2',
+             'command': 'set state', 'new state': True}
+    test(): {'sender': 'Test State 2', 'event': 'changed', 'state': True}
+    test(): {'sender': 'test()', 'target': 'Test State 1',
+             'command': 'set state', 'new state': False}
+    test(): {'sender': 'Test State 1', 'event': 'changed', 'state': False}
+    test(): {'sender': 'test()', 'target': 'Test OrState',
+             'command': 'get state'}
+    test(): {'sender': 'Test OrState', 'state': True}
+    """
+
+    CONF_SCHEMA = {'properties': {'states': {'type': 'array',
+                                             'items': {'type': 'string'}}},
+                   'required': ['states']}
+    """Schema for OrState plugin configuration.
+
+    Required configuration key:
+
+    - 'states': list of names of combined states.
+    """
+
+    async def receive(self, message: Message) -> None:
+        """."""
+        if ('target' in message and message['target'] == self.name and
+                'command' in message and message['command'] == 'get state'):
+            await self.bus.send(Message(self.name, {'state': self.state}))
+        if 'state' in message and message['sender'] in self.conf['states']:
+            assert isinstance(message['sender'], str)
+            assert isinstance(message['state'], bool)
+            self.states[message['sender']] = message['state']
+            new_state = any(self.states.values())
+            if self.state != new_state:
+                self.state: bool = new_state
+                await self.bus.send(Message(self.name,
+                                            {'event': 'changed',
+                                             'state': self.state}))
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        sends = [MessageTemplate({'event': {'const': 'changed'},
+                                  'state': {'type': 'boolean'}}),
+                 MessageTemplate({'state': {'type': 'boolean'}})]
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'get state'}})]
+        self.states: Dict[str, bool] = {}
+        for state in self.conf['states']:
+            receives.append(MessageTemplate({'sender': {'const': state},
+                                             'state': {'type': 'boolean'}}))
+            self.states[state] = False
+        self.state = any(self.states.values())
+        self.bus.register(self.name, 'OrState',
+                          sends, receives, self.receive)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
diff --git a/controlpi_plugins/util.py b/controlpi_plugins/util.py
new file mode 100644 (file)
index 0000000..313e50b
--- /dev/null
@@ -0,0 +1,397 @@
+"""Provide utility plugins for all kinds of systems.
+
+- Log logs messages on stdout.
+- Init sends list of messages on startup and on demand.
+- Alias translates messages to an alias.
+
+>>> import controlpi
+>>> asyncio.run(controlpi.test(
+...     {"Test Log": {"plugin": "Log",
+...                   "filter": [{"sender": {"const": "Test Alias"}}]},
+...      "Test Init": {"plugin": "Init",
+...                    "messages": [{"id": 42, "content": "Test Message"}]},
+...      "Test Alias": {"plugin": "Alias",
+...                     "from": {"sender": {"const": "Test Init"},
+...                              "id": {"const": 42}},
+...                     "to": {"id": "translated"}}}, []))
+... # doctest: +NORMALIZE_WHITESPACE
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Log', 'plugin': 'Log',
+         'sends': [], 'receives': [{'sender': {'const': 'Test Alias'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Init', 'plugin': 'Init',
+         'sends': [{'id': {'const': 42},
+                    'content': {'const': 'Test Message'}}],
+         'receives': [{'target': {'const': 'Test Init'},
+                       'command': {'const': 'execute'}}]}
+test(): {'sender': '', 'event': 'registered',
+         'client': 'Test Alias', 'plugin': 'Alias',
+         'sends': [{'id': {'const': 'translated'}}],
+         'receives': [{'sender': {'const': 'Test Init'},
+                       'id': {'const': 42}}]}
+test(): {'sender': 'Test Init', 'id': 42,
+         'content': 'Test Message'}
+test(): {'sender': 'Test Alias', 'id': 'translated',
+         'content': 'Test Message'}
+Test Log: {'sender': 'Test Alias', 'id': 'translated',
+           'content': 'Test Message'}
+"""
+import asyncio
+
+from controlpi import BasePlugin, Message, MessageTemplate
+
+
+class Log(BasePlugin):
+    """Log messages on stdout.
+
+    The "filter" configuration key gets a list of message templates defining
+    the messages that should be logged by the plugin instance.
+
+    In the following example the first and third message match the given
+    template and are logged by the instance "Test Log", while the second
+    message does not match and is only logged by the test, but not by the
+    Log instance:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log",
+    ...                   "filter": [{"id": {"const": 42}}]}},
+    ...     [{"id": 42, "message": "Test Message"},
+    ...      {"id": 42.42, "message": "Second Message"},
+    ...      {"id": 42, "message": "Third Message"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Log', 'plugin': 'Log',
+             'sends': [], 'receives': [{'id': {'const': 42}}]}
+    test(): {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Test Message'}
+    test(): {'sender': 'test()', 'id': 42.42, 'message': 'Second Message'}
+    test(): {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+    Test Log: {'sender': 'test()', 'id': 42, 'message': 'Third Message'}
+
+    The "filter" key is required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log"}}, []))
+    'filter' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'filter': {'items': {'type': 'object'},
+                                   'type': 'array'}},
+         'required': ['filter']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Log'}
+    Configuration for 'Test Log' is not valid.
+
+    The "filter" key has to contain a list of message templates, i.e.,
+    JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Log": {"plugin": "Log",
+    ...                   "filter": [42]}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['filter']['items']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['filter'][0]:
+        42
+    Configuration for 'Test Log' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'filter': {'type': 'array',
+                                             'items': {'type': 'object'}}},
+                   'required': ['filter']}
+    """Schema for Log plugin configuration.
+
+    Required configuration key:
+
+    - 'filter': list of message templates to be logged.
+    """
+
+    async def log(self, message: Message) -> None:
+        """Log received message on stdout using own name as prefix."""
+        print(f"{self.name}: {message}")
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.bus.register(self.name, 'Log', [], self.conf['filter'], self.log)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class Init(BasePlugin):
+    """Send list of messages on startup and on demand.
+
+    The "messages" configuration key gets a list of messages to be sent on
+    startup. The same list is sent in reaction to a message with
+    "target": <name> and "command": "execute".
+
+    In the example, the two configured messages are sent twice, once at
+    startup and a second time in reaction to the "execute" command sent by
+    the test:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init",
+    ...                    "messages": [{"id": 42,
+    ...                                  "content": "Test Message"},
+    ...                                 {"id": 42.42,
+    ...                                  "content": "Second Message"}]}},
+    ...     [{"target": "Test Init", "command": "execute"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Init', 'plugin': 'Init',
+             'sends': [{'id': {'const': 42},
+                        'content': {'const': 'Test Message'}},
+                       {'id': {'const': 42.42},
+                        'content': {'const': 'Second Message'}}],
+             'receives': [{'target': {'const': 'Test Init'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+    test(): {'sender': 'test()', 'target': 'Test Init', 'command': 'execute'}
+    test(): {'sender': 'Test Init', 'id': 42, 'content': 'Test Message'}
+    test(): {'sender': 'Test Init', 'id': 42.42, 'content': 'Second Message'}
+
+    The "messages" key is required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init"}}, []))
+    'messages' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'messages': {'items': {'type': 'object'},
+                                     'type': 'array'}},
+         'required': ['messages']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Init'}
+    Configuration for 'Test Init' is not valid.
+
+    The "messages" key has to contain a list of (partial) messages, i.e.,
+    JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Init": {"plugin": "Init",
+    ...                    "messages": [42]}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['messages']['items']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['messages'][0]:
+        42
+    Configuration for 'Test Init' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'messages': {'type': 'array',
+                                               'items': {'type': 'object'}}},
+                   'required': ['messages']}
+    """Schema for Init plugin configuration.
+
+    Required configuration key:
+
+    - 'messages': list of messages to be sent.
+    """
+
+    async def execute(self, message: Message) -> None:
+        """Send configured messages."""
+        for message in self.conf['messages']:
+            await self.bus.send(Message(self.name, message))
+            # Give immediate reactions to messages opportunity to happen:
+            await asyncio.sleep(0)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'execute'}})]
+        sends = [MessageTemplate.from_message(message)
+                 for message in self.conf['messages']]
+        self.bus.register(self.name, 'Init', sends, receives, self.execute)
+
+    async def run(self) -> None:
+        """Send configured messages on startup."""
+        for message in self.conf['messages']:
+            await self.bus.send(Message(self.name, message))
+
+
+class Execute(BasePlugin):
+    """Send configurable list of messages on demand.
+
+    An Execute plugin instance receives two kinds of commands.
+    The "set messages" command has a "messages" key with a list of (partial)
+    messages, which are sent by the Execute instance in reaction to an
+    "execute" command.
+
+    In the example, the first command sent by the test sets two messages,
+    which are then sent in reaction to the second command sent by the test:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Execute": {"plugin": "Execute"}},
+    ...     [{"target": "Test Execute", "command": "set messages",
+    ...       "messages": [{"id": 42, "content": "Test Message"},
+    ...                    {"id": 42.42, "content": "Second Message"}]},
+    ...      {"target": "Test Execute", "command": "execute"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Execute', 'plugin': 'Execute',
+             'sends': [{}],
+             'receives': [{'target': {'const': 'Test Execute'},
+                           'command': {'const': 'set messages'},
+                           'messages': {'type': 'array',
+                                        'items': {'type': 'object'}}},
+                          {'target': {'const': 'Test Execute'},
+                           'command': {'const': 'execute'}}]}
+    test(): {'sender': 'test()', 'target': 'Test Execute',
+             'command': 'set messages',
+             'messages': [{'id': 42, 'content': 'Test Message'},
+                          {'id': 42.42, 'content': 'Second Message'}]}
+    test(): {'sender': 'test()', 'target': 'Test Execute',
+             'command': 'execute'}
+    test(): {'sender': 'Test Execute', 'id': 42,
+             'content': 'Test Message'}
+    test(): {'sender': 'Test Execute', 'id': 42.42,
+             'content': 'Second Message'}
+    """
+
+    CONF_SCHEMA = True
+    """Schema for Execute plugin configuration.
+
+    There are no required or optional configuration keys.
+    """
+
+    async def execute(self, message: Message) -> None:
+        """Set or send configured messages."""
+        if message['command'] == 'set messages':
+            assert isinstance(message['messages'], list)
+            self.messages = list(message['messages'])
+        elif message['command'] == 'execute':
+            for message in self.messages:
+                await self.bus.send(Message(self.name, message))
+                # Give immediate reactions to messages opportunity to happen:
+                await asyncio.sleep(0)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.messages = []
+        receives = [MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'set messages'},
+                                     'messages':
+                                     {'type': 'array',
+                                      'items': {'type': 'object'}}}),
+                    MessageTemplate({'target': {'const': self.name},
+                                     'command': {'const': 'execute'}})]
+        sends = [MessageTemplate()]
+        self.bus.register(self.name, 'Execute', sends, receives, self.execute)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
+
+
+class Alias(BasePlugin):
+    """Translate messages to an alias.
+
+    The "from" configuration key gets a message template and the
+    configuration key "to" a (partial) message. All messages matching the
+    template are received by the Alias instance and a message translated by
+    removing the keys of the "from" template and adding the keys and values
+    of the "to" message is sent. Keys that are neither in "from" nor in "to"
+    are retained.
+
+    In the example, the two messages sent by the test are translated by the
+    Alias instance and the translated messages are sent by it preserving
+    the "content" keys:
+    >>> import controlpi
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias",
+    ...                     "from": {"id": {"const": 42}},
+    ...                     "to": {"id": "translated"}}},
+    ...     [{"id": 42, "content": "Test Message"},
+    ...      {"id": 42, "content": "Second Message"}]))
+    ... # doctest: +NORMALIZE_WHITESPACE
+    test(): {'sender': '', 'event': 'registered',
+             'client': 'Test Alias', 'plugin': 'Alias',
+             'sends': [{'id': {'const': 'translated'}}],
+             'receives': [{'id': {'const': 42}}]}
+    test(): {'sender': 'test()', 'id': 42,
+             'content': 'Test Message'}
+    test(): {'sender': 'Test Alias', 'id': 'translated',
+             'content': 'Test Message'}
+    test(): {'sender': 'test()', 'id': 42,
+             'content': 'Second Message'}
+    test(): {'sender': 'Test Alias', 'id': 'translated',
+             'content': 'Second Message'}
+
+    The "from" and "to" keys are required:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias"}}, []))
+    'from' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+         'required': ['from', 'to']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Alias'}
+    'to' is a required property
+    <BLANKLINE>
+    Failed validating 'required' in schema:
+        {'properties': {'from': {'type': 'object'}, 'to': {'type': 'object'}},
+         'required': ['from', 'to']}
+    <BLANKLINE>
+    On instance:
+        {'plugin': 'Alias'}
+    Configuration for 'Test Alias' is not valid.
+
+    The "from" key has to contain a message template and the "to" key a
+    (partial) message, i.e., both have to be JSON objects:
+    >>> asyncio.run(controlpi.test(
+    ...     {"Test Alias": {"plugin": "Alias",
+    ...                     "from": 42,
+    ...                     "to": 42}}, []))
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['from']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['from']:
+        42
+    42 is not of type 'object'
+    <BLANKLINE>
+    Failed validating 'type' in schema['properties']['to']:
+        {'type': 'object'}
+    <BLANKLINE>
+    On instance['to']:
+        42
+    Configuration for 'Test Alias' is not valid.
+    """
+
+    CONF_SCHEMA = {'properties': {'from': {'type': 'object'},
+                                  'to': {'type': 'object'}},
+                   'required': ['from', 'to']}
+    """Schema for Alias plugin configuration.
+
+    Required configuration keys:
+
+    - 'from': template of messages to be translated.
+    - 'to': translated message to be sent.
+    """
+
+    async def alias(self, message: Message) -> None:
+        """Translate and send message."""
+        alias_message = Message(self.name)
+        alias_message.update(self.conf['to'])
+        for key in message:
+            if key != 'sender' and key not in self.conf['from']:
+                alias_message[key] = message[key]
+        await self.bus.send(alias_message)
+
+    def process_conf(self) -> None:
+        """Register plugin as bus client."""
+        self.bus.register(self.name, 'Alias',
+                          [MessageTemplate.from_message(self.conf['to'])],
+                          [self.conf['from']],
+                          self.alias)
+
+    async def run(self) -> None:
+        """Run no code proactively."""
+        pass
diff --git a/controlpi_plugins/wait.py b/controlpi_plugins/wait.py
new file mode 100644 (file)
index 0000000..afe5431
--- /dev/null
@@ -0,0 +1,44 @@
+"""Provide waiting/sleeping plugins for all kinds of systems.
+
+TODO: documentation, doctests
+"""
+import asyncio
+
+from controlpi import BasePlugin, Message
+
+
+class Wait(BasePlugin):
+    CONF_SCHEMA = {'properties': {'seconds': {'type': 'number'}},
+                   'required': ['seconds']}
+
+    async def wait(self, message: Message) -> None:
+        await asyncio.sleep(self.conf['seconds'])
+        await self.bus.send({'sender': self.name, 'event': 'finished'})
+
+    def process_conf(self) -> None:
+        receives = [{'target': {'const': self.name},
+                     'command': {'const': 'wait'}}]
+        sends = [{'event': {'const': 'finished'}}]
+        self.bus.register(self.name, 'Wait', sends, receives, self.wait)
+
+    async def run(self) -> None:
+        pass
+
+
+class GenericWait(BasePlugin):
+    CONF_SCHEMA = True
+
+    async def wait(self, message: Message) -> None:
+        await asyncio.sleep(message['seconds'])
+        await self.bus.send(Message(self.name, {'id': message['id']}))
+
+    def process_conf(self) -> None:
+        receives = [{'target': {'const': self.name},
+                     'command': {'const': 'wait'},
+                     'seconds': {'type': 'number'},
+                     'id': {'type': 'string'}}]
+        sends = [{'id': {'type': 'string'}}]
+        self.bus.register(self.name, 'GenericWait', sends, receives, self.wait)
+
+    async def run(self) -> None:
+        pass