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')
 class A(Dispatcher):
     test_list = ListProperty()
 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()
 class A(Dispatcher):
     test_dict = DictProperty({'defaultkey': 'defaultval'})
     test_list = ListProperty(['defaultitem'])
 class A(Dispatcher):
     test_prop = Property()
     test_dict = DictProperty()
     test_list = ListProperty()
Esempio n. 6
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]
Esempio n. 7
0
class MultiParameterSpec(BaseParameterSpec):
    """Combines multiple :class:`ParameterSpec` definitions
    """

    _doc_field_names: ClassVar[List[str]] = [
        'full_name',
        'prop_names',
        'value_types',
        'setter_method',
        'adjust_method',
    ]

    prop_names: List[str]  # = field(default_factory=list)
    """The Property/attribute names within the
    :class:`jvconnected.device.ParameterGroup` containing the parameter values
    """

    value_types: List[Value]  # = field(default_factory=list)
    """Specifications for the expected values of the attribute in
    :class:`~jvconnected.device.ParameterGroup`
    """

    value: List[Any] = ListProperty()
    """The current device value"""
    def __init__(self, **kwargs):
        super().__init__(**kwargs)
        self.prop_names = kwargs['prop_names']
        self.value_types = kwargs['value_types']
        self.value = [vt.py_type for vt in self.value_types]

    def _build_copy_kwargs(self) -> Dict:
        kw = super()._build_copy_kwargs()
        attrs = ['prop_name', 'value_type']
        kw.update({attr: getattr(self, attr) for attr in attrs})
        return kw

    def _bind_to_param_group(self, pg: 'jvconnected.device.ParameterGroup'):
        pg.bind(**{self.prop_name: self.on_device_prop_change})

    def on_device_prop_change(self, instance, value, **kwargs):
        if instance is not self.device_param_group:
            return
        prop = kwargs['property']
        assert prop.name == self.prop_name
        i = self.prop_names.index(prop.name)
        vtype = self.value_types[i]
        assert isinstance(value, vtype.py_type)
        self.value[i] = value
        self.emit(
            'on_device_value_changed',
            self,
            self.value,
            prop_name=prop.name,
            value_type=vtype,
        )

    def get_param_value(self) -> List[Any]:
        """Get the current device value
        """
        pg = self.device_param_group
        value = [getattr(pg, key) for key in self.prop_names]
        for vtype, item in zip(self.value_types, value):
            assert isinstance(item, vtype)
        return value

    async def set_param_value(self, value: List[Any]):
        """Set the device value

        Raises:
            ValueError: If no :attr:`setter_method` is defined

        """
        pg = self.device_param_group
        if self.setter_method:
            m = getattr(pg, self.setter_method)
            await m(*value)
        else:
            raise ValueError(f'No setter method for {self}')