예제 #1
0
class Discovery(Dispatcher):
    """Listen for cameras using zeroconf
    """

    procam_infos: tp.Dict[str, ServiceInfo] = DictProperty()
    """Container for discovered devices as instances of
    :class:`zeroconf.ServiceInfo`. The service names (fqdn) are used as keys
    """
    def on_service_added(self, name: str, info: ServiceInfo):
        """Fired when a new device is discovered
        """

    def on_service_updated(self, name: str, info: ServiceInfo,
                           old: ServiceInfo):
        """Fired when an service is updated.

        The pre-existing info is passed for comparison
        """

    def on_service_removed(self, name: str, info: ServiceInfo):
        """Fired when an existing service is no longer available
        """

    _events_ = ['on_service_added', 'on_service_updated', 'on_service_removed']

    def __init__(self):
        self.running = False

    async def open(self):
        """Open the zeroconf browser and begin listening
        """
        if self.running:
            return
        loop = self.loop = asyncio.get_event_loop()
        azc = self.async_zeroconf = AsyncZeroconf()
        self.zeroconf = azc.zeroconf
        fqdn = PROCAM_FQDN
        listener = self.listener = ProcamListener(discovery=self)
        await azc.async_add_service_listener(fqdn, listener)
        self.running = True

    async def close(self):
        """Stop listening and close all connections
        """
        if not self.running:
            return
        await self.async_zeroconf.async_close()
        self.running = False
예제 #2
0
    class A(Dispatcher):
        test_prop = Property()
        test_dict = DictProperty()
        test_list = ListProperty()

        def __init__(self):
            self.received = []
            self.bind(
                test_prop=self.on_test_prop,
                test_dict=self.on_test_dict,
                test_list=self.on_test_list,
            )

        def on_test_prop(self, *args, **kwargs):
            self.received.append('test_prop')

        def on_test_dict(self, *args, **kwargs):
            self.received.append('test_dict')

        def on_test_list(self, *args, **kwargs):
            self.received.append('test_list')
예제 #3
0
class Engine(Dispatcher):
    """Top level component to handle config, discovery and device control
    """

    devices: tp.Dict[str, Device] = DictProperty()
    """Mapping of :class:`~.device.Device` instances using their
    :attr:`~.device.Device.id` as keys
    """

    discovered_devices = DictProperty()
    running = Property(False)
    auto_add_devices = Property(True)
    """If ``True``, devices will be added automatically
    when discovered on the network. Otherwise, they must be added manually
    using :meth:`add_device_from_conf`
    """

    midi_io = Property()
    interfaces: tp.Dict[
        str, 'jvconnected.interfaces.base.Interface'] = DictProperty()
    """Container for :class:`~.interfaces.base.Interface` instances
    """

    _events_ = [
        'on_config_device_added',
        'on_device_discovered',
        'on_device_added',
        'on_device_removed',
    ]
    config: Config
    """The :class:`~.config.Config` instance"""

    discovery: Discovery
    """The :class:`~.discovery.Discovery` instance"""

    connection_status: tp.Dict[str, ReconnectStatus]
    """Mapping of :class:`ReconnectStatus` instances using the associated
    :attr:`device_id <.config.DeviceConfig.id>` as keys
    """
    def on_config_device_added(self, conf_device: DeviceConfig):
        """Fired when an instance of :class:`~.config.DeviceConfig` is added
        """

    def on_device_discovered(self, conf_device: DeviceConfig):
        """Fired when a device is detected on the network. An instance of
        :class:`~.config.DeviceConfig` is found (or created)
        and passed as the argument
        """

    def on_device_added(self, device: Device):
        """Fired when an instance of :class:`~.device.Device` is
        added to :attr:`devices`
        """

    def on_device_removed(self, device: Device, reason: RemovalReason):
        """Fired when an instance of :class:`~.device.Device` is removed

        Arguments:
            device: The device that was removed
            reason: Reason for removal
        """

    _device_reconnect_timeout = 5
    _device_reconnect_max_attempts = 100

    def __init__(self, **kwargs):
        self.auto_add_devices = kwargs.get('auto_add_devices', True)
        self.loop = asyncio.get_event_loop()
        self.config = Config()
        self.discovery = Discovery()
        self.device_reconnect_queue = asyncio.Queue()
        self._device_reconnect_main_task = None
        self._run_pending = False
        self.connection_status = {}
        for name, cls in interfaces.registry:
            obj = cls()
            self.interfaces[name] = obj
            if name == 'midi':
                self.midi_io = obj
        interfaces.registry.bind_async(
            self.loop,
            interface_added=self.on_interface_registered,
        )

    async def on_interface_registered(self, name, cls, **kwargs):
        if name not in self.interfaces:
            obj = cls()
            self.interfaces[name] = obj
            await obj.set_engine(self)

    def run_forever(self):
        """Convenience method to open and run until interrupted
        """
        self.loop.run_until_complete(self.open())
        try:
            self.loop.run_forever()
        except KeyboardInterrupt:
            self.loop.run_until_complete(self.close())
        finally:
            self.loop.run_until_complete(self.close())

    async def open(self):
        """Open all communication methods
        """
        if self.running:
            return
        self._run_pending = True
        t = asyncio.create_task(self._reconnect_devices())
        self._device_reconnect_main_task = t
        for obj in self.interfaces.values():
            await obj.set_engine(self)
        self.config.bind_async(
            self.loop,
            on_device_added=self._on_config_device_added,
        )
        self.discovery.bind_async(
            self.loop,
            on_service_added=self.on_discovery_service_added,
            on_service_updated=self.on_discovery_service_updated,
            on_service_removed=self.on_discovery_service_removed,
        )
        self.running = True
        self._run_pending = False
        await self.add_always_connected_devices()
        await self.discovery.open()
        logger.success('Engine open')

    async def add_always_connected_devices(self):
        """Create and open any devices with
        :attr:`~jvconnected.config.DeviceConfig.always_connect` set to True
        """
        coros = []
        for device_conf in self.config.devices.values():
            if not device_conf.always_connect:
                continue
            assert device_conf.id not in self.discovered_devices
            info = device_conf.build_service_info()
            coros.append(self.on_discovery_service_added(info.name, info=info))
        if len(coros):
            await asyncio.sleep(.01)
            await asyncio.gather(*coros)
            await asyncio.sleep(.01)

    async def close(self):
        """Close the discovery engine and any running device clients
        """
        if not self.running:
            return
        self.running = False
        self.discovery.unbind(self)
        await self.discovery.close()

        t = self._device_reconnect_main_task
        self._device_reconnect_main_task = None
        await self.device_reconnect_queue.put(None)
        await t
        for status in self.connection_status.values():
            t = status.task
            if t is None or t.done():
                continue
            t.cancel()
            try:
                await t
            except asyncio.CancelledError:
                pass
        self.connection_status.clear()

        for conf_device in self.discovered_devices.values():
            conf_device.active = False
            conf_device.online = False

        await asyncio.sleep(0)

        async def close_device(device):
            try:
                await device.close()
            finally:
                del self.devices[device.id]
                self.emit('on_device_removed', device, RemovalReason.SHUTDOWN)

        coros = []
        for device in self.devices.values():
            coros.append(close_device(device))
        await asyncio.gather(*coros)
        logger.success('Engine closed')

    async def add_device_from_conf(
            self, device_conf: 'jvconnected.config.DeviceConfig'):
        """Add a client :class:`~jvconnected.device.Device` instance from the given
        :class:`~jvconnected.config.DeviceConfig` and attempt to connect.

        If auth information is incorrect or does not exist, display the error
        and remove the newly added device.

        """
        status = self.connection_status.get(device_conf.id)
        if status is None:
            status = ReconnectStatus(device_id=device_conf.id)
            self.connection_status[device_conf.id] = status
        if status.state == ConnectionState.ATTEMPTING:
            task = status.task
            if task is not None and not task.done():
                await task
                if status.state == ConnectionState.CONNECTED:
                    return

        status.state = ConnectionState.ATTEMPTING
        logger.debug(f'add_device_from_conf: {device_conf}')
        device = Device(
            device_conf.hostaddr,
            device_conf.auth_user,
            device_conf.auth_pass,
            device_conf.id,
            device_conf.hostport,
        )
        device.device_index = device_conf.device_index
        self.devices[device_conf.id] = device
        self.emit('on_device_added', device)
        async with status:
            try:
                await device.open()
            except ClientError as exc:
                await asyncio.sleep(0)
                await self.on_device_client_error(device,
                                                  exc,
                                                  skip_status_lock=True)
                return
            status.state = ConnectionState.CONNECTED
            status.reason = RemovalReason.UNKNOWN
            status.num_attempts = 0
            device_conf.active = True
        device.bind_async(self.loop,
                          on_client_error=self.on_device_client_error)

    @logger.catch
    async def on_device_client_error(self, device, exc, **kwargs):
        skip_status_lock = kwargs.get('skip_status_lock', False)
        if not self.running:
            return
        if isinstance(exc, ClientNetworkError):
            reason = RemovalReason.TIMEOUT
        elif isinstance(exc, ClientAuthError):
            reason = RemovalReason.AUTH
            logger.warning(f'Authentication failed for device_id: {device.id}')
        else:
            reason = RemovalReason.UNKNOWN
        # logger.debug(f'device client error: device={device}, reason={reason}, exc={exc}')
        device_conf = self.discovered_devices[device.id]
        device_conf.active = False
        status = self.connection_status[device.id]

        async def handle_state():
            try:
                await device.close()
            finally:
                status.state = ConnectionState.FAILED
                if device.id in self.devices:
                    del self.devices[device.id]
                if reason == RemovalReason.TIMEOUT and status.reason != RemovalReason.OFFLINE:
                    await self.device_reconnect_queue.put((device.id, reason))

        if skip_status_lock:
            await handle_state()
        else:
            async with status:
                await handle_state()

        self.emit('on_device_removed', device, reason)

    @logger.catch
    async def on_discovery_service_added(self, name, **kwargs):
        logger.debug(f'on_discovery_service_added: {name}, {kwargs}')
        info = kwargs['info']
        device_id = DeviceConfig.get_id_for_service_info(info)
        device_conf = self.discovered_devices.get(device_id)

        if device_id in self.config.devices:
            if device_conf is not None:
                dev = self.config.add_device(device_conf)
                assert dev is device_conf
                device_conf.update_from_service_info(info)
            else:
                device_conf = self.config.add_discovered_device(info)
                self.discovered_devices[device_id] = device_conf
        elif device_conf is None:
            device_conf = self.add_discovered_device(info)

        device_conf.online = True
        self.emit('on_device_discovered', device_conf)
        if self.auto_add_devices:
            if device_conf.id not in self.devices:
                await self.add_device_from_conf(device_conf)

    async def on_discovery_service_updated(self, name, **kwargs):
        logger.debug(f'on_discovery_service_updated: "{name}", {kwargs}')
        info = kwargs['info']
        old = kwargs['old']
        device_id = DeviceConfig.get_id_for_service_info(old)
        status = self.connection_status.get(device_id)
        if status.task is not None and not status.task.done():
            await status.task
        await self.on_discovery_service_removed(name, info=old)
        await self.on_discovery_service_added(name, info=info)

    async def on_discovery_service_removed(self, name, **kwargs):
        logger.debug(f'on_discovery_service_removed: {name}, {kwargs}')
        info = kwargs['info']
        device_id = DeviceConfig.get_id_for_service_info(info)
        device_conf = self.discovered_devices.get(device_id)
        if device_conf is not None:
            device_conf.online = False
            if device_conf.always_connect:
                return
            device_conf.active = False
        status = self.connection_status[device_id]
        async with status:
            status.state = ConnectionState.FAILED
            status.reason = RemovalReason.OFFLINE
            device = self.devices.get(device_id)
            if device is not None:
                try:
                    await device.close()
                finally:
                    del self.devices[device_id]
        self.emit('on_device_removed', device, RemovalReason.OFFLINE)

    def add_discovered_device(self,
                              info: 'zeroconf.ServiceInfo') -> DeviceConfig:
        """Create a :class:`~jvconnected.config.DeviceConfig` and add it to
        :attr:`discovered_devices`
        """
        device_id = DeviceConfig.get_id_for_service_info(info)
        if device_id in self.discovered_devices:
            device_conf = self.discovered_devices[device_id]
        else:
            device_conf = DeviceConfig.from_service_info(info)
            self.discovered_devices[device_conf.id] = device_conf
        return device_conf

    @logger.catch
    async def _reconnect_devices(self):
        q = self.device_reconnect_queue

        async def do_reconnect(status: ReconnectStatus):
            status.state = ConnectionState.SLEEPING
            await asyncio.sleep(self._device_reconnect_timeout)
            async with status:
                if status.state != ConnectionState.SLEEPING:
                    return
                if not self.running:
                    return
                disco_conf = self.discovered_devices.get(status.device_id)
                if disco_conf is None:
                    return
                if not disco_conf.online:
                    return
                logger.debug(f'reconnect to {disco_conf}')
                status.num_attempts += 1
            await self.add_device_from_conf(disco_conf)

        while self.running or self._run_pending:
            item = await q.get()
            if item is None or not self.running:
                q.task_done()
                break
            device_id, reason = item
            status = self.connection_status[device_id]
            valid = True
            async with status:
                if status.state != ConnectionState.FAILED:
                    valid = False
                elif status.num_attempts >= self._device_reconnect_max_attempts:
                    logger.debug(f'max attempts reached for "{device_id}"')
                    valid = False
                elif status.task is not None and not status.task.done():
                    logger.error(f'Active reconnect task exists for {status}')
                    valid = False
                elif reason == RemovalReason.TIMEOUT and status.reason == RemovalReason.OFFLINE:
                    valid = False

                if valid:
                    status.reason = reason
                    status.state = ConnectionState.SCHEDULING
                    logger.debug(
                        f'scheduling reconnect for {device_id}, num_attempts={status.num_attempts}'
                    )
                    status.task = asyncio.create_task(do_reconnect(status))
            q.task_done()

    async def _on_config_device_added(self, conf_device, **kwargs):
        conf_device.bind(device_index=self._on_config_device_index_changed)
        self.emit('on_config_device_added', conf_device)

    def _on_config_device_index_changed(self, instance, value, **kwargs):
        device_id = instance.id
        device = self.devices.get(device_id)
        if device is None:
            return
        device.device_index = value
예제 #4
0
class Config(Dispatcher):
    """Configuration storage

    This object provides a dict-like interface and stores the configuration data
    automatically when it changes. The stored config data is read on initialization.

    Arguments:
        filename (:class:`pathlib.Path`, optional): The configuration filename. If not provided,
            the :attr:`DEFAULT_FILENAME` is used
    """
    data: tp.Dict[str, tp.Any] = DictProperty()
    DEFAULT_FILENAME: Path = get_config_dir('jvconnected') / 'config.json'
    """Platform-dependent default filename
    (``<config_dir>/jvconnected/config.json``).
    Where ``<config_dir>`` is chosen in :func:`get_config_dir`
    """

    indexed_devices: IndexedDict
    """An instance of :class:`jvconnected.utils.IndexedDict` to handle
    device indexing
    """
    _events_ = ['on_device_added']

    def on_device_added(self, device: 'DeviceConfig'):
        """Triggered when a device is added to the config
        """

    def __init__(self, filename: tp.Optional['pathlib.Path'] = None):
        if filename is None:
            filename = self.DEFAULT_FILENAME
        logger.info(f'Config using filename: {filename}')
        self._read_complete = False
        self._device_reindexing = ContextLock()
        self.indexed_devices = IndexedDict()
        self.indexed_devices.bind(
            on_item_index_changed=self.on_device_dict_index_changed,
        )
        self.filename = filename
        self._setitem_lock = DumbLock()
        self.read()
        self._read_complete = True
        self.bind(data=self.on_data_changed)

    @property
    def devices(self) -> tp.Dict[str, 'DeviceConfig']:
        """Mapping of :class:`DeviceConfig` using their :attr:`~DeviceConfig.id`
        as keys
        """
        if 'devices' not in self:
            self['devices'] = {}
        return self['devices']

    def __setitem__(self, key, item):
        with self._setitem_lock:
            self.data[key] = item
            self.write()

    def __getitem__(self, key):
        return self.data[key]

    def __contains__(self, key):
        return key in self.data

    def keys(self): return self.data.keys()
    def values(self): return self.data.values()
    def items(self): return self.data.items()

    def get(self, key, default=None):
        return self.data.get(key, default)

    def update(self, other: tp.Dict):
        """Update from another :class:`dict`
        """
        other = other.copy()
        with self._setitem_lock:
            oth_devices = other.get('devices', {})
            if 'devices' in other:
                del other['devices']
            self.data.update(other)
            for device in oth_devices.values():
                self.add_device(device)
        self.write()

    def add_device(self, device: 'DeviceConfig') -> 'DeviceConfig':
        """Add a :class:`DeviceConfig` instance

        If a device config already exists, it will be updated with the info
        provided using :meth:`DeviceConfig.update_from_other`

        If its :attr:`~DeviceConfig.device_index` is set, it will be added
        to :attr:`indexed_devices`.

        """
        key = device.id
        if key in self.devices:
            with self._setitem_lock:
                ix = self.devices[key].device_index
                self.devices[key].update_from_other(device)
                if not self._read_complete:
                    if ix is not None and ix != -1:
                        assert self.devices[key].device_index == ix
            self.write()
        else:
            assert not self._device_reindexing.locked()
            ix = device.device_index
            if ix is not None:
                with self._setitem_lock:
                    with self._device_reindexing.set(device):
                        new_index = self.indexed_devices.add(key, device, ix)
                        if not self._read_complete and ix != -1:
                            assert ix == new_index
                        device.device_index = new_index
            self.devices[key] = device
            device.stored_in_config = True
            device.bind(
                device_index=self.on_device_index,
                on_change=self.on_device_prop_change,
            )
            # self.indexed_devices.compact_indices()
            self.write()
            self.emit('on_device_added', device)
        return self.devices[key]

    def add_discovered_device(self, info: 'zeroconf.ServiceInfo') -> 'DeviceConfig':
        """Add a :class:`DeviceConfig` from zeroconf data
        """
        device = DeviceConfig.from_service_info(info)
        return self.add_device(device)

    def read(self):
        if not self.filename.exists():
            return
        with self._setitem_lock:
            data = jsonfactory.loads(self.filename.read_text())
            self.update(data)

    def write(self):
        if not self._read_complete:
            return
        p = self.filename.parent
        if not p.exists():
            p.mkdir(mode=0o700, parents=True)
        self.filename.write_text(jsonfactory.dumps(self.data, indent=2))

    def on_data_changed(self, instance, value, **kwargs):
        if self._setitem_lock.locked():
            return
        self.write()

    def on_device_dict_index_changed(self, **kwargs):
        key = kwargs['key']
        device = kwargs['item']
        old_index = kwargs['old_index']
        new_index = kwargs['new_index']
        if self._device_reindexing.locked():
            device.device_index = new_index
        else:
            with self._device_reindexing.set(device):
                with self._setitem_lock:
                    device.device_index = new_index
                self.write()

    def on_device_index(self, instance, value, **kwargs):
        if self._device_reindexing.locked():
            return
        if self._setitem_lock.locked():
            return
        key = instance.id
        old = kwargs['old']
        if value is None:
            assert isinstance(old, int)
            if key in self.indexed_devices:
                self.indexed_devices.remove(key)
        else:
            with self._device_reindexing.set(instance):
                with self._setitem_lock:
                    key = instance.id
                    if old is None:
                        new_index = self.indexed_devices.add(key, instance, value)
                        if value != -1:
                            assert value == new_index
                        else:
                            assert new_index >= 0
                        instance.device_index = new_index
                    else:
                        self.indexed_devices.change_item_index(key, value)
                    # self.indexed_devices.compact_indices()
                self.write()

    def on_device_prop_change(self, instance, prop_name, value, **kwargs):
        if prop_name == 'device_index':
            return
        self.write()
예제 #5
0
class Device(Dispatcher):
    """A Connected Cam device

    Arguments:
        hostaddr (str): The network host address
        auth_user (str): Api username
        auth_pass (str): Api password
        id_ (str): Unique string id
        hostport (int, optional): The network host port
    """
    model_name: str | None = Property()
    """Model name of the device"""

    serial_number: str | None = Property()
    """The device serial number"""

    resolution: str | None = Property()
    """Current output resolution in string format"""

    api_version: str | None = Property()
    """Api version supported by the device"""

    device_index: int = Property(0)
    """The device index"""

    connected: bool = Property(False)
    """Connection state"""

    error: bool = Property(False)
    """Becomes ``True`` when a communication error occurs"""

    parameter_groups: tp.Dict[str, 'ParameterGroup'] = DictProperty()
    """Container for :class:`ParameterGroup` instances"""
    def on_client_error(self, instance: 'Device', exc: Exception):
        """Fired when an error is caught by the http client.

        Arguments:
            instance: The device instance
            exc: The :class:`Exception` that was raised
        """

    _events_ = ['on_client_error']

    def __init__(self,
                 hostaddr: str,
                 auth_user: str,
                 auth_pass: str,
                 id_: str,
                 hostport: int = 80):
        self.hostaddr = hostaddr
        self.hostport = hostport
        self.auth_user = auth_user
        self.auth_pass = auth_pass
        self._devicepreview = None
        self.__id = id_
        self.client = Client(hostaddr, auth_user, auth_pass, hostport)
        self._poll_fut = None
        self._poll_enabled = False
        self._is_open = False
        for cls in PARAMETER_GROUP_CLS:
            self._add_param_group(cls)
        attrs = ['model_name', 'serial_number', 'resolution', 'api_version']
        self.bind(**{attr: self.on_attr for attr in attrs})
        self.request_queue = NamedQueue(maxsize=16)

    @property
    def id(self):
        return self.__id

    @property
    def devicepreview(self) -> JpegSource:
        """Instance of :class:`jvconnected.devicepreview.JpegSource` to
        acquire real-time jpeg images
        """
        pv = self._devicepreview
        if pv is None:
            pv = self._devicepreview = JpegSource(self)
        return pv

    def _add_param_group(self, cls, **kwargs):
        pg = cls(self, **kwargs)
        assert pg.name not in self.parameter_groups
        self.parameter_groups[pg.name] = pg
        return pg

    def __getattr__(self, key):
        if hasattr(self, 'parameter_groups') and key in self.parameter_groups:
            return self.parameter_groups[key]
        raise AttributeError(key)

    async def open(self):
        """Begin communication with the device
        """
        if self._is_open:
            return
        await self.client.open()
        await self._get_system_info()
        self._poll_enabled = True
        self._poll_fut = asyncio.ensure_future(self._poll_loop())
        self._is_open = True
        self.connected = True

    async def close(self):
        """Stop communication and close all connections
        """
        if not self._is_open:
            return
        self._is_open = False
        self._poll_enabled = False
        logger.debug(f'{self} closing...')
        pv = self._devicepreview
        if pv is not None and pv.encoding:
            await pv.release()

        await self._poll_fut
        for pg in self.parameter_groups.values():
            await pg.close()
        await self.client.close()
        logger.debug(f'{self} closed')
        self.connected = False

    async def _get_system_info(self):
        """Request basic device info
        """
        resp = await self.client.request('GetSystemInfo')
        data = resp['Data']
        self.model_name = data['Model']
        self.api_version = data['ApiVersion']
        self.serial_number = data['Serial']

    @logger.catch
    async def _poll_loop(self):
        """Periodically request status updates
        """
        async def get_queue_item(timeout=.5):
            try:
                item = await asyncio.wait_for(self.request_queue.get(),
                                              timeout)
            except asyncio.TimeoutError:
                item = None
            return item

        async def do_poll(item):
            if item is not None:
                command, params = item.item
                logger.debug(f'tx: {command}, {params}')
                await self.client.request(command, params)
                await self._request_cam_status(short=True)
                self.request_queue.task_done()
            else:
                await self._request_cam_status(short=False)

        while self._poll_enabled:
            item = await get_queue_item(timeout=.5)
            try:
                await do_poll(item)
            except ClientError as exc:
                asyncio.ensure_future(self._handle_client_error(exc))
                break

    async def _request_cam_status(self, short=True):
        """Request all available camera parameters

        Called by :meth:`_poll_loop`. The response data is used to update the
        :class:`ParameterGroup` instances in :attr:`parameter_groups`.
        """
        resp = await self.client.request('GetCamStatus')
        data = resp['Data']
        # coros = []
        for pg in self.parameter_groups.values():
            # coros.append(pg.parse_status_response(data))
            try:
                pg.parse_status_response(data)
            except Exception as exc:
                import json
                jsdata = json.dumps(data, indent=2)
                logger.debug(f'data: {jsdata}')
                logger.error(exc)
                raise

    @logger.catch
    async def _handle_client_error(self, exc: Exception):
        logger.warning(f'caught client error: {exc}')
        self.error = True
        self.emit('on_client_error', self, exc)

    async def send_web_button(self, kind: str, value: str):
        await self.queue_request('SetWebButtonEvent', {
            'Kind': kind,
            'Button': value
        })

    async def queue_request(self, command: str, params=None):
        """Enqueue a command to be sent in the :meth:`_poll_loop`
        """
        item = self.request_queue.create_item(command, (command, params))
        await self.request_queue.put(item)

    def on_attr(self, instance, value, **kwargs):
        prop = kwargs['property']
        logger.info(f'{prop.name} = {value}')

    def __repr__(self):
        return f'<{self.__class__.__name__}: "{self}">'

    def __str__(self):
        return f'{self.model_name} ({self.hostaddr})'
예제 #6
0
class UmdIo(Interface):
    """Main UMD interface
    """

    hostaddr: str = Property('0.0.0.0')
    """Alias for :attr:`tslumd.receiver.UmdReceiver.hostaddr`"""

    hostport: int = Property(65000)
    """Alias for :attr:`tslumd.receiver.UmdReceiver.hostport`"""

    device_maps: Dict[int, DeviceMapping] = DictProperty()
    """A ``dict`` of :class:`~.mapper.DeviceMapping` definitions stored with
    their :attr:`~.mapper.DeviceMapping.device_index` as keys
    """

    mapped_devices: Dict[int, MappedDevice] = DictProperty()
    """A ``dict`` of :class:`~.mapper.MappedDevice` stored with the
    ``device_index`` of their :attr:`~.mapper.MappedDevice.map` as keys
    """

    def on_tally_added(self, tally: Tally):
        """Fired when a :class:`tslumd.tallyobj.Tally` instance is
        added to :attr:`tallies`
        """

    def on_tally_updated(self, tally: Tally):
        """Fired when any :class:`tslumd.tallyobj.Tally` instance has
        been updated
        """

    _events_ = ['on_tally_added', 'on_tally_updated']
    interface_name = 'tslumd'
    def __init__(self):
        self._reading_config = False
        self._config_read = asyncio.Event()
        self._connect_lock = asyncio.Lock()
        super().__init__()
        self.receiver = UmdReceiver()
        self.hostaddr = self.receiver.hostaddr
        self.hostport = self.receiver.hostport
        self.receiver.bind_async(self.loop,
            on_tally_added=self._on_receiver_tally_added,
            on_tally_updated=self._on_receiver_tally_updated,
        )
        self.bind_async(self.loop,
            config=self.read_config,
        )
        self.bind(**{prop:self.update_config for prop in ['hostaddr', 'hostport']})

    @property
    def tallies(self) -> Dict[int, Tally]:
        """Alias for :attr:`tslumd.receiver.UmdReceiver.tallies`
        """
        return self.receiver.tallies

    async def set_engine(self, engine: 'jvconnected.engine.Engine'):
        if engine is self.engine:
            return
        if engine.config is not self.config:
            self._config_read.clear()
        await super().set_engine(engine)
        engine.bind_async(
            self.loop,
            on_device_added=self.on_engine_device_added,
            on_device_removed=self.on_engine_device_removed,
        )

    async def open(self):
        async with self._connect_lock:
            if self.running:
                return
            logger.debug('UmdIo.open()')
            if self.config is not None:
                await self._config_read.wait()
            self.running = True
            await self.receiver.open()
            logger.success('UmdIo running')

    async def close(self):
        async with self._connect_lock:
            if not self.running:
                return
            logger.debug('UmdIo.close()')
            self.running = False
            await self.receiver.close()
            logger.success('UmdIo closed')

    async def set_bind_address(self, hostaddr: str, hostport: int):
        """Set the :attr:`hostaddr` and :attr:`hostport` and restart the server
        """
        await self.receiver.set_bind_address(hostaddr, hostport)
        self.hostaddr = self.receiver.hostaddr
        self.hostport = self.receiver.hostport


    async def set_hostaddr(self, hostaddr: str):
        """Set the :attr:`hostaddr` and restart the server
        """
        await self.set_bind_address(hostaddr, self.hostport)

    async def set_hostport(self, hostport: int):
        """Set the :attr:`hostport` and restart the server
        """
        await self.set_bind_address(self.hostaddr, hostport)

    async def _on_receiver_tally_added(self, tally, **kwargs):
        for mapped_device in self.mapped_devices.values():
            if mapped_device.have_tallies:
                continue
            r = mapped_device.get_tallies()
            if r:
                await mapped_device.update_device_tally()
        self.emit('on_tally_added', tally, **kwargs)

    async def _on_receiver_tally_updated(self, tally: Tally, props_changed: Set[str], **kwargs):
        self.emit('on_tally_updated', tally, props_changed, **kwargs)

    def get_device_by_index(self, ix: int) -> Optional['jvconnected.device.Device']:
        device = None
        if self.engine is not None:
            device_conf = self.engine.config.indexed_devices.get_by_index(ix)
            if device_conf is not None:
                device = self.engine.devices.get(device_conf.id)
        return device

    @logger.catch
    async def add_device_mapping(self, device_map: 'DeviceMapping'):
        """Add a :class:`~.mapper.DeviceMapping` definition to :attr:`device_maps`
        and update the :attr:`config`.

        An instance of :class:`~.mapper.MappedDevice` is also created and
        associated with its :class:`~jvconnected.device.Device`
        if found in the :attr:`engine`.
        """
        ix = device_map.device_index
        self.device_maps[ix] = device_map
        mapped_device = self.mapped_devices.get(ix)
        if mapped_device is not None:
            await mapped_device.set_device(None)
            del self.mapped_devices[ix]
        device = self.get_device_by_index(ix)
        mapped_device = MappedDevice(map=device_map, umd_io=self)
        self.mapped_devices[ix] = mapped_device
        await mapped_device.set_device(device)
        self.update_config()

    async def remove_device_mapping(self, device_index: int):
        """Remove a :class:`~.mapper.DeviceMapping` and its associated
        :class:`~.mapper.MappedDevice` by the given device index
        """
        if device_index not in self.device_maps:
            return
        del self.device_maps[device_index]
        mapped_device = self.mapped_devices.get(device_index)
        if mapped_device is not None:
            await mapped_device.set_device(None)
            del self.mapped_devices[device_index]
        self.update_config()

    async def on_engine_device_added(self, device, **kwargs):
        mapped_device = self.mapped_devices.get(device.device_index)
        if mapped_device is not None:
            await mapped_device.set_device(device)

    async def on_engine_device_removed(self, device, reason, **kwargs):
        mapped_device = self.mapped_devices.get(device.device_index)
        if mapped_device is not None:
            await mapped_device.set_device(None)

    def update_config(self, *args, **kwargs):
        """Update the :attr:`config` with current state
        """
        if self._reading_config:
            return
        if self.config is None:
            return
        if not self._config_read.is_set():
            return
        d = self.get_config_section()
        if d is None:
            return
        d['hostaddr'] = self.hostaddr
        d['hostport'] = self.hostport
        m = self.device_maps
        d['device_maps'] = [m[k] for k in sorted(m.keys())]

    @logger.catch
    async def read_config(self, *args, **kwargs):
        d = self.get_config_section()
        if d is None:
            return
        self._reading_config = True
        hostaddr = d.get('hostaddr', self.hostaddr)
        hostport = d.get('hostport', self.hostport)
        coros = []
        for dev_map in d.get('device_maps', []):
            coros.append(self.add_device_mapping(dev_map))
        await asyncio.gather(*coros)
        await self.set_bind_address(hostaddr, hostport)
        self._reading_config = False
        self._config_read.set()
예제 #7
0
class FakeDevice(Dispatcher):
    model_name = Property('GY-HC500')
    serial_number = Property('12340000')
    hostaddr = Property('127.0.0.1')
    hostport = Property(9090)
    dns_name_prefix = Property('hc500')
    api_version = Property('01.234.567')
    resolution = Property('1920x1080')
    country = Property('US')
    parameter_groups = DictProperty()
    zc_service_info = Property()

    def __init__(self, **kwargs):
        self.image_server = ImageServer()
        keys = [
            'model_name', 'serial_number', 'hostaddr', 'hostport',
            'dns_name_prefix'
        ]
        kwargs.setdefault('hostaddr', str(get_non_loopback_ip().ip))
        for key in keys:
            if key in kwargs:
                setattr(self, key, kwargs[key])
        for cls in PARAMETER_GROUP_CLS:
            self._add_param_group(cls)
        self._command_map = {
            'GetSystemInfo': self.handle_system_info_req,
            'GetCamStatus': self.handle_status_request,
            'SetWebButtonEvent': self.handle_web_button_event,
            'SetWebSliderEvent': self.handle_web_slider_event,
            'SeesawSwitchOperation': self.handle_seesaw_event,
            'SetWebXYFieldEvent': self.handle_web_xy_event,
            'SetStudioTally': self.handle_tally_request,
            'JpegEncode': self.handle_jpg_encode_request,
        }
        self.zc_service_info = self._build_zc_service_info()
        attrs = ['dns_name_prefix', 'serial_number', 'hostaddr', 'hostport']
        self.bind(**{attr: self._check_zc_service_info for attr in attrs})

    @property
    def dns_name(self):
        return f'{self.dns_name_prefix}-{self.serial_number}'

    @logger.catch
    async def open(self):
        coros = set()
        for pg in self.parameter_groups.values():
            coros.add(pg.open())
        await asyncio.gather(*coros)

    @logger.catch
    async def close(self):
        coros = set()
        for pg in self.parameter_groups.values():
            coros.add(pg.open())
        await asyncio.gather(*coros)

    def _add_param_group(self, cls, **kwargs):
        pg = cls(self, **kwargs)
        assert pg.name not in self.parameter_groups
        self.parameter_groups[pg.name] = pg
        return pg

    async def handle_command_req(self, request):
        payload = await request.json()
        command = payload['Request']['Command']
        resp_data = {'Response': {'Requested': command}}
        m = self._command_map.get(command)
        if m is None:
            resp_data['Response']['Result'] = 'Error'
        else:
            data = await m(request, payload)
            if isinstance(data, dict):
                resp_data['Response']['Data'] = data
            resp_data['Response']['Result'] = 'Success'
        return web.json_response(resp_data)

    async def handle_jpg_req(self, request):
        return await self.image_server.get_file_response(request)

    async def handle_system_info_req(self, request, payload):
        data = {
            'Model': self.model_name,
            'Destination': self.country,
            'ApiVersion': self.api_version,
            'Serial': self.serial_number,
            'Resolution': self.resolution,
        }
        return data

    async def handle_status_request(self, request, payload):
        data = {}
        for pg in self.parameter_groups.values():
            pg.update_status_dict(data)
        return data

    async def handle_web_button_event(self, request, payload):
        coros = []
        for pg in self.parameter_groups.values():
            coros.append(pg.handle_web_button_event(request, payload))
        await asyncio.gather(*coros)

    async def handle_web_slider_event(self, request, payload):
        coros = []
        for pg in self.parameter_groups.values():
            coros.append(pg.handle_web_slider_event(request, payload))
        await asyncio.gather(*coros)

    async def handle_seesaw_event(self, request, payload):
        coros = []
        for pg in self.parameter_groups.values():
            coros.append(pg.handle_seesaw_event(request, payload))
        await asyncio.gather(*coros)

    async def handle_web_xy_event(self, request, payload):
        coros = []
        for pg in self.parameter_groups.values():
            coros.append(pg.handle_web_xy_event(request, payload))
        await asyncio.gather(*coros)

    async def handle_tally_request(self, request, payload):
        await self.parameter_groups['tally'].handle_tally_request(
            request, payload)

    async def handle_jpg_encode_request(self, request, payload):
        params = payload['Request']['Params']
        encode = params['Operate'] == 'Start'
        await self.image_server.set_encoding(encode)

    def _build_zc_service_info(self) -> 'ServiceInfo':
        return ServiceInfo(
            PROCAM_FQDN,
            f'{self.dns_name}.{PROCAM_FQDN}',
            addresses=[socket.inet_aton(self.hostaddr)],
            port=self.hostport,
            properties={b'model': bytes(self.model_name, 'UTF-8')},
        )

    def _check_zc_service_info(self, *args, **kwargs):
        info = self._build_zc_service_info()
        self.zc_service_info = info
예제 #8
0
 class A(Dispatcher):
     test_dict = DictProperty(copy_on_change=True)
     test_list = ListProperty(copy_on_change=True)
     no_cp_dict = DictProperty()
     no_cp_list = ListProperty()
예제 #9
0
 class A(Dispatcher):
     test_dict = DictProperty({'defaultkey': 'defaultval'})
     test_list = ListProperty(['defaultitem'])
예제 #10
0
 class A(Dispatcher):
     test_prop = Property()
     test_dict = DictProperty()
     test_list = ListProperty()
예제 #11
0
 class A(Dispatcher):
     test_dict = DictProperty({'a': 1, 'b': 2, 'c': 3, 'd': 4})
예제 #12
0
class MidiIO(Interface):
    """Midi interface handler
    """
    inport_names: tp.List[str] = ListProperty(copy_on_change=True)
    """list of input port names to use (as ``str``)"""

    outport_names: tp.List[str] = ListProperty(copy_on_change=True)
    """list of output port names to use (as ``str``)"""

    inports: tp.Dict[str, InputPort] = DictProperty()
    """Mapping of :class:`~.aioport.InputPort` instances stored with their
    :attr:`~.aioport.Input.name` as keys
    """

    outports: tp.Dict[str, OutputPort] = DictProperty()
    """Mapping of :class:`~.aioport.OutputPort` instances stored with their
    :attr:`~.aioport.OutputPort.name` as keys
    """

    mapped_devices: tp.Dict[str, 'jvconnected.interfaces.midi.mapped_device.MappedDevice'] = DictProperty()
    """Mapping of :class:`~.mapped_device.MappedDevice` instances stored with
    the device id as keys
    """

    device_channel_map: Dict[str, int] = DictProperty()
    """Mapping of :class:`~.mapped_device.MappedDevice` instances
    stored with the device id as keys
    """

    channel_device_map: Dict[int, str] = DictProperty()
    """Mapping of Midi channel assignments using the Midi channel
    as keys and :attr:`device_id <jvconnected.config.DeviceConfig.id>`
    as values
    """

    mapper = Property()

    def port_state(self, io_type: IOType, name: str, state: bool):
        """Fired when a port is added or removed using one of :meth:`add_input`,
        :meth:`add_output`, :meth:`remove_input`, :meth:`remove_output`.
        """

    interface_name = 'midi'
    _events_ = ['port_state']
    def __init__(self):
        super().__init__()
        self._consume_tasks = {}
        self._reading_config = False
        self._device_map_lock = asyncio.Lock()
        self._port_lock = asyncio.Lock()
        self._refresh_event = asyncio.Event()
        self._refresh_task = None
        self.mapper = MidiMapper()
        self.bind_async(
            self.loop,
            inport_names=self.on_inport_names,
            outport_names=self.on_outport_names,
        )
        self.bind(config=self.read_config)

    @classmethod
    def get_available_inputs(cls) -> List[str]:
        """Get all detected input port names
        """
        return mido.get_input_names()

    @classmethod
    def get_available_outputs(cls) -> List[str]:
        """Get all detected output port names
        """
        return mido.get_output_names()

    async def set_engine(self, engine: 'jvconnected.engine.Engine'):
        if engine is self.engine:
            return
        await super().set_engine(engine)
        await self.automap_engine_devices()
        engine.bind_async(self.loop, devices=self.automap_engine_devices)

    async def automap_engine_devices(self, *args, **kwargs):
        """Map the engine's devices by index
        """
        config = self.engine.config
        coros = set()
        config_update = False
        async with self._device_map_lock:
            for conf_device in config.indexed_devices.values():
                device_id = conf_device.id
                device_index = conf_device.device_index
                if device_index > 15:
                    break
                device = self.engine.devices.get(device_id)
                mapped_device = self.mapped_devices.get(device_id)

                if device is None:
                    if mapped_device is not None:
                        self._unmap_device(device_id)
                        config_update = True
                elif mapped_device is None:
                    mapped_device = await self.map_device(device, send_all_parameters=False)
                    coros.add(mapped_device.send_all_parameters())
                    config_update = True
                else:
                    assert device is mapped_device.device
            if config_update:
                self.update_config()
        if len(coros):
            await asyncio.gather(*coros)

    @logger.catch
    async def open(self):
        """Open any configured input and output ports and begin communication
        """
        if self.running:
            return
        logger.debug('MidiIO.open()')
        self.running = True
        await self.open_ports()
        self._refresh_task = asyncio.ensure_future(self.periodic_refresh())
        logger.success('MidiIO running')

    async def close(self):
        """Stop communication and close all input and output ports
        """
        if not self.running:
            return
        logger.debug('MidiIO.close()')
        self.running = False
        self._refresh_event.set()
        await self._refresh_task
        self._refresh_task = None
        await self.close_ports()
        logger.success('MidiIO stopped')

    async def open_ports(self):
        """Open any configured input and output ports.
        (Called by :meth:`open`)
        """
        for name in self.inport_names:
            await self.add_input(name)
        for name in self.outport_names:
            await self.add_output(name)

    async def close_ports(self):
        """Close all running input and output ports
        (Called by :meth:`close`)
        """
        coros = set()
        for port in self.inports.values():
            coros.add(port.close())
        for port in self.outports.values():
            coros.add(port.close())
        await asyncio.gather(*coros)

    async def add_input(self, name: str):
        """Add an input port

        The port name will be added to :attr:`inport_names` and stored in the
        :attr:`config`.

        If MidiIO is :attr:`running`, an instance of :class:`~.aioport.InputPort`
        will be created and added to :attr:`inports`.

        Arguments:
            name (str): The port name (as it appears in :meth:`get_available_inputs`)

        """
        async with self._port_lock:
            logger.info(f'add_input: {name}')
            if name not in self.inport_names:
                self.inport_names.append(name)
            if name in self.inports:
                raise ValueError(f'Input "{name}" already open')
            port = InputPort(name)
            logger.debug(f'port: {port}')
            self.inports[name] = port
            port.bind_async(self.loop, running=self.on_inport_running)
            if self.running:
                try:
                    await port.open()
                except rtmidi.SystemError as exc:
                    await port.close()
                    del self.inports[name]
                    logger.exception(exc)
                    return
        self.emit('port_state', IOType.INPUT, name, True)

    async def add_output(self, name: str):
        """Add an output port

        The port name will be added to :attr:`outport_names` and stored in the
        :attr:`config`.

        If MidiIO is :attr:`running`, an instance of :class:`~.aioport.OutputPort`
        will be created and added to :attr:`outports`.

        Arguments:
            name (str): The port name (as it appears in :meth:`get_available_outputs`)

        """
        async with self._port_lock:
            if name not in self.outport_names:
                self.outport_names.append(name)
            if self.running:
                if name in self.outports:
                    raise ValueError(f'Output "{name}" already open')
                port = OutputPort(name)
                logger.debug(f'port: {port}')
                self.outports[name] = port
                try:
                    await port.open()
                except rtmidi.SystemError as exc:
                    await port.close()
                    del self.outports[name]
                    logger.exception(exc)
                    return
        self.emit('port_state', IOType.OUTPUT, name, True)

    async def close_inport(self, name: str):
        port = self.inports[name]
        port.unbind(self)
        await port.close()
        task = self._consume_tasks.get(name)
        if task is not None:
            await task
            del self._consume_tasks[name]

    async def remove_input(self, name: str):
        """Remove an input port from :attr:`inports` and :attr:`inport_names`

        If the port exists in :attr:`inports`, it will be closed and removed.

        Arguments:
            name (str): The port name

        """
        async with self._port_lock:
            if name in self.inports:
                await self.close_inport(name)
                del self.inports[name]
            if name in self.inport_names:
                self.inport_names.remove(name)
        self.emit('port_state', IOType.INPUT, name, False)

    async def remove_output(self, name: str):
        """Remove an output port from :attr:`outports` and :attr:`outport_names`

        If the port exists in :attr:`outports`, it will be closed and removed.

        Arguments:
            name (str): The port name

        """
        async with self._port_lock:
            if name in self.outports:
                port = self.outports[name]
                await port.close()
                del self.outports[name]
            if name in self.outport_names:
                self.outport_names.remove(name)
        self.emit('port_state', IOType.OUTPUT, name, False)

    @logger.catch
    async def periodic_refresh(self):
        while self.running:
            try:
                r = await asyncio.wait_for(self._refresh_event.wait(), 30)
            except asyncio.TimeoutError:
                r = False
            if r:
                break
            coros = set()
            for mapped_device in self.mapped_devices.values():
                coros.add(mapped_device.send_all_parameters())
            if len(coros):
                logger.debug('refreshing midi data')
                await asyncio.gather(*coros)

    @logger.catch
    async def consume_incoming_messages(self, port: InputPort):
        while self.running and port.running:
            msgs = await port.receive_many(timeout=.5)
            if msgs is None:
                continue
            logger.opt(lazy=True).debug(
                '{x}', x=lambda: '\n'.join([f'MIDI rx: {msg}' for msg in msgs])
            )
            coros = set()
            for device in self.mapped_devices.values():
                coros.add(device.handle_incoming_messages(msgs))
            if len(coros):
                await asyncio.gather(*coros)

    async def send_message(self, msg: mido.messages.messages.BaseMessage):
        """Send a message to all output ports in :attr:`outports`

        Arguments:
            msg: The :class:`Message <mido.Message>` to send

        """
        coros = set()
        for port in self.outports.values():
            if port.running:
                coros.add(port.send(msg))
        if len(coros):
            await asyncio.gather(*coros)
            logger.opt(lazy=True).debug(f'MIDI tx: {msg}')

    async def send_messages(self, msgs: Sequence[mido.Message]):
        """Send a message to all output ports in :attr:`outports`

        Arguments:
            msgs: A sequence of :class:`Messages <mido.Message>` to send

        """
        coros = set()
        for port in self.outports.values():
            if port.running:
                coros.add(port.send_many(*msgs))
        if len(coros):
            await asyncio.gather(*coros)
            logger.opt(lazy=True).debug(
                '{x}', x=lambda: '\n'.join([f'MIDI tx: {msg}' for msg in msgs])
            )

    @logger.catch
    async def map_device(
        self,
        device: 'jvconnected.device.Device',
        send_all_parameters: bool = True,
        midi_channel: Optional[int] = None
    ) -> MappedDevice:
        """Connect a :class:`jvconnected.device.Device` to a :class:`.mapped_device.MappedDevice`

        The Midi channel used for the device is retreived from the :attr:`config`
        if available. If no channel assignment was found, the next available
        channel is used and saved in the :attr:`config`.

        Arguments:
            device: The :class:`~jvconnected.device.Device` to map
            send_all_parameters (bool, optional): If True, send all current
                parameter values once the device is mapped. Default is True
            midi_channel (int, optional): The Midi channel to use for
                the device (from 0 to 15). If not provided, the channel is
                assigned automatically using :meth:`get_midi_channel_for_device`

        Raises:
            ValidationError: If *midi_channel* was provided and already in use
            ValueError: If there are no Midi channels available

        """
        device_id = device.id
        assert device_id not in self.mapped_devices
        if midi_channel is None:
            midi_channel = self.get_midi_channel_for_device(device_id)
        self.validate_device_channel(device_id, midi_channel)

        m = MappedDevice(self, midi_channel, device, self.mapper)
        self.mapped_devices[device_id] = m
        await self._assign_device_channel(device_id, midi_channel)
        if send_all_parameters:
            await m.send_all_parameters()
        logger.debug(f'mapped device: {m} to midi channel {midi_channel}')
        return m

    @logger.catch
    async def unmap_device(self, device_id: str, unassign_channel: bool = False):
        """Unmap a device

        Arguments:
            device_id (str): The :attr:`id <jvconnected.config.DeviceConfig.id>`
                of the device to unmap
            unassign_channel (bool, optional): If True, removes the Midi channel
                assignment for the device and updates the saved config. If False
                (the default), only removes the :class:`~.mapped_device.MappedDevice`
                from :attr:`mapped_devices`.

        """
        logger.debug(f'unmap_device: {device_id}')
        async with self._device_map_lock:
            if device_id not in self.device_channel_map:
                return
            self._unmap_device(device_id, unassign_channel)
            self.update_config()

    def _unmap_device(self, device_id: str, unassign_channel: bool = False):
        if device_id in self.mapped_devices:
            # logger.debug(f'unmap device: {self.mapped_devices[device_id]}')
            del self.mapped_devices[device_id]
        if unassign_channel:
            midi_channel = self.device_channel_map[device_id]
            del self.channel_device_map[midi_channel]
            del self.device_channel_map[device_id]

    @logger.catch
    async def remap_device_channel(self, device_id: str, midi_channel: int):
        """Reassign the Midi channel for a device

        If the device is online, the existing :class:`~.mapped_device.MappedDevice`
        attached to it is reassigned as well.

        Arguments:
            device_id (str): The :attr:`id <jvconnected.config.DeviceConfig.id>`
                of the device
            midi_channel (int): The new Midi channel for the device

        Raises:
            ValidationError: If the given *midi_channel* is already in use

        """
        mapped_device = self.mapped_devices.get(device_id)
        logger.debug(f'remap_device: {device_id}, {midi_channel}, {mapped_device}')
        if mapped_device is not None:
            if mapped_device.midi_channel == midi_channel:
                return
            await self.unmap_device(device_id, unassign_channel=True)
        self.validate_device_channel(device_id, midi_channel)
        device = self.engine.devices.get(device_id)
        if device is not None:
            await self.map_device(device, midi_channel=midi_channel)
        else:
            await self._assign_device_channel(device_id, midi_channel)

    def get_midi_channel_for_device(self, device_id: str) -> int:
        """Get the assigned Midi channel for a device or next one available

        If the :attr:`device_id <jvconnected.config.DeviceConfig.id>` exists
        in the :attr:`config`, it is used. If no assignment exists, the next
        available channel is returned.

        Raises:
            ValueError: If there are no available channels

        """
        chan = self.device_channel_map.get(device_id)
        if chan is not None:
            return chan
        all_channels = set(range(16))
        in_use = set(self.channel_device_map.keys())
        available = all_channels - in_use
        if not len(available):
            raise ValueError('No Midi channel available')
        return min(available)

    def validate_device_channel(self, device_id: str, midi_channel: int) -> None:
        if midi_channel < 0 or midi_channel > 15:
            raise ValidationError('Midi channel out of range')
        if midi_channel in self.channel_device_map:
            if self.channel_device_map[midi_channel] != device_id:
                raise ValidationError(f'Channel {midi_channel} already assigned')

    async def _assign_device_channel(self, device_id: str, midi_channel: int):
        assert len(device_id)
        # self.validate_device_channel(device_id, midi_channel)
        if midi_channel in self.channel_device_map:
            assert self.channel_device_map[midi_channel] == device_id
        else:
            self.channel_device_map[midi_channel] = device_id
        if device_id in self.device_channel_map:
            assert self.device_channel_map[device_id] == midi_channel
        else:
            self.device_channel_map[device_id] = midi_channel
        if not self._device_map_lock.locked():
            async with self._device_map_lock:
                self.update_config()

    async def on_engine_running(self, instance, value, **kwargs):
        if instance is not self.engine:
            return
        if value:
            if not self.running:
                await self.open()
        else:
            await self.close()

    def update_config(self, *args, **kwargs):
        """Update the :attr:`config` with current state
        """
        if self._reading_config:
            return
        d = self.get_config_section()
        if d is None:
            return
        d['inport_names'] = self.inport_names.copy()
        d['outport_names'] = self.outport_names.copy()
        d['device_channel_map'] = self.device_channel_map.copy()

    def read_config(self, *args, **kwargs):
        d = self.get_config_section()
        if d is None:
            return
        self._reading_config = True
        for attr in ['inport_names', 'outport_names']:
            conf_val = d.get(attr, [])
            prop_val = getattr(self, attr)
            if conf_val == prop_val:
                continue
            setattr(self, attr, conf_val.copy())
        conf_val = d.get('device_channel_map', {})
        if conf_val != self.device_channel_map:
            self.device_channel_map = conf_val.copy()
            self.channel_device_map = {v:k for k,v in conf_val.items()}
        self._reading_config = False

    async def on_inport_names(self, instance, value, **kwargs):
        self.update_config()
        # if not self.running:
        #     return
        # if self._port_lock.locked():
        #     return
        # old = kwargs['old']
        # new_values = set(old) - set(value)
        # removed_values = set(value) - set(old)
        # logger.info(f'on_inport_names: {value}, new_values: {new_values}, removed_values: {removed_values}')
        # for name in removed_values:
        #     if name in self.inports:
        #         await self.remove_input(name)
        # for name in new_values:
        #     if name not in self.inports:
        #         await self.add_input(name)

    async def on_outport_names(self, instance, value, **kwargs):
        self.update_config()
        # if not self.running:
        #     return
        # old = kwargs['old']
        # new_values = set(old) - set(value)
        # removed_values = set(value) - set(old)
        # for name in removed_values:
        #     if name in self.outports:
        #         await self.remove_output(name)
        # for name in new_values:
        #     if name not in self.outports:
        #         await self.add_output(name)
        # self.update_config()

    async def on_inport_running(self, port, value, **kwargs):
        logger.debug(f'{self}.on_inport_running({port}, {value})')
        if port is not self.inports.get(port.name):
            return
        if value:
            logger.debug(f'starting consume task for {port}')
            assert port.name not in self._consume_tasks
            task = asyncio.ensure_future(self.consume_incoming_messages(port))
            self._consume_tasks[port.name] = task
            logger.debug(f'consume task running for {port}')
        else:
            logger.debug(f'stopping consume task for {port}')
            task = self._consume_tasks.get(port.name)
            if task is not None:
                await task
                del self._consume_tasks[port.name]