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
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 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
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()
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})'
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()
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
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()
class A(Dispatcher): test_dict = DictProperty({'a': 1, 'b': 2, 'c': 3, 'd': 4})
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]