Source code for vidhubcontrol.config

import os
import json
import asyncio

import jsonfactory
from pydispatch import Dispatcher, Property
from pydispatch.properties import ListProperty, DictProperty

from vidhubcontrol.discovery import BMDDiscovery
from vidhubcontrol.backends import (
    DummyBackend,
    SmartViewDummyBackend,
    SmartScopeDummyBackend,
    TelnetBackend,
    SmartViewTelnetBackend,
    SmartScopeTelnetBackend,
)

BACKENDS = {
    'vidhub':{cls.__name__:cls for cls in [DummyBackend, TelnetBackend]},
    'smartview':{cls.__name__:cls for cls in [SmartViewDummyBackend, SmartViewTelnetBackend]},
    'smartscope':{cls.__name__:cls for cls in [SmartScopeDummyBackend, SmartScopeTelnetBackend]},
}

class ConfigBase(Dispatcher):
    _conf_attrs = []
    _events_ = ['trigger_save']
    def _get_conf_data(self):
        d = {}
        for attr in self._conf_attrs:
            val = getattr(self, attr)
            if isinstance(val, ConfigBase):
                val = val._get_conf_data()
            d[attr] = val
        return d

[docs]class Config(ConfigBase): """Config store for devices Handles storage of device connection information and any user-defined values for the backends defined in the :doc:`backends module <backends>`. Data is stored in JSON format. During :meth:`start`, all previously stored devices will be loaded and begin communication. Devices are also discovered using `Zeroconf`_ through the :doc:`discovery module <discovery>`. Since each device has a unique id, network address changes (due to DHCP, etc) are handled appropriately. The configuration data is stored when: * A device is added or removed * A change is detected for a device's network address * Any user-defined device value changes (device name, presets, etc) The recommended method to start ``Config`` is through the :meth:`load_async` method. Example: .. code-block:: python import asyncio from vidhubcontrol.config import Config loop = asyncio.get_event_loop() conf = loop.run_until_complete(Config.load_async(loop=loop)) Keyword Arguments: filename (:obj:`str`, optional): Filename to load/save config data to. If not given, defaults to :attr:`DEFAULT_FILENAME` loop: The :class:`EventLoop <asyncio.BaseEventLoop>` to use. If not given, the value from :func:`asyncio.get_event_loop` will be used. auto_start (bool): If ``True`` (default), the :meth:`start` method will be added to the asyncio event loop on initialization. Attributes: vidhubs (dict): A :class:`~pydispatch.properties.DictProperty` of :class:`VidhubConfig` instances using :attr:`~DeviceConfigBase.device_id` as keys smartviews (dict): A :class:`~pydispatch.properties.DictProperty` of :class:`SmartViewConfig` instances using :attr:`~DeviceConfigBase.device_id` as keys smartscopes (dict): A :class:`~pydispatch.properties.DictProperty` of :class:`SmartScopeConfig` instances using :attr:`~DeviceConfigBase.device_id` as keys .. autoattribute:: DEFAULT_FILENAME .. _Zeroconf: https://en.wikipedia.org/wiki/Zero-configuration_networking """ DEFAULT_FILENAME = '~/vidhubcontrol.json' USE_DISCOVERY = True vidhubs = DictProperty() smartviews = DictProperty() smartscopes = DictProperty() _conf_attrs = ['vidhubs', 'smartscopes', 'smartviews'] _device_type_map = { 'vidhub':{'prop':'vidhubs'}, 'smartview':{'prop':'smartviews'}, 'smartscope':{'prop':'smartscopes'}, } loop = None def __init__(self, **kwargs): self.start_kwargs = kwargs.copy() auto_start = kwargs.get('auto_start', True) self.starting = asyncio.Event() self.running = asyncio.Event() self.stopped = asyncio.Event() self.filename = kwargs.get('filename', self.DEFAULT_FILENAME) if 'loop' in kwargs: Config.loop = kwargs['loop'] elif Config.loop is None: Config.loop = asyncio.get_event_loop() self.discovery_listener = None self.discovery_lock = asyncio.Lock() if auto_start: self._start_fut = asyncio.ensure_future(self.start(**kwargs), loop=self.loop) else: async def _start_fut(config): await config.running.wait() self._start_fut = asyncio.ensure_future(_start_fut(self), loop=self.loop) def id_for_device(self, device): if not isinstance(device, DeviceConfigBase): prop = getattr(self, self._device_type_map[device.device_type]['prop']) obj = None for _obj in prop.values(): if _obj.backend is device: obj = _obj break if obj is None: raise Exception('Could not find device {!r}'.format(device)) else: device = obj if device.device_id is not None: return device.device_id return str(id(device)) async def _initialize_backends(self, **kwargs): """Creates and initializes device backends Keyword Arguments: vidhubs (dict): A ``dict`` containing the necessary data (as values) to create an instance of :class:`VidhubConfig` smartviews (dict): A ``dict`` containing the necessary data (as values) to create an instance of :class:`SmartViewConfig` smartscopes (dict): A ``dict`` containing the necessary data (as values) to create an instance of :class:`SmartScopeConfig` Note: All config object instances are created using the :meth:`DeviceConfigBase.create` classmethod. """ async def _init_backend(prop, cls, **okwargs): okwargs['config'] = self obj = await cls.create(**okwargs) device_id = obj.device_id if device_id is None: device_id = self.id_for_device(obj) prop[device_id] = obj obj.bind( device_id=self.on_backend_device_id, trigger_save=self.on_device_trigger_save, ) tasks = [] for key, d in self._device_type_map.items(): items = kwargs.get(d['prop'], {}) prop = getattr(self, d['prop']) for item_data in items.values(): okwargs = item_data.copy() task = _init_backend(prop, d['cls'], **okwargs) tasks.append(task) if len(tasks): await asyncio.wait(tasks)
[docs] async def start(self, **kwargs): """Starts the device backends and discovery routines Keyword arguments passed to the initialization will be used here, but can be overridden in this method. They will also be passed to :meth:`_initialize_backends`. """ if self.starting.is_set(): await self.running.wait() return if self.running.is_set(): return self.starting.set() self.start_kwargs.update(kwargs) kwargs = self.start_kwargs await self._initialize_backends(**kwargs) if not self.USE_DISCOVERY: self.starting.clear() self.running.set() return if self.discovery_listener is not None: await self.running.wait() return self.discovery_listener = BMDDiscovery(self.loop) self.discovery_listener.bind( service_added=self.on_discovery_service_added, ) await self.discovery_listener.start() self.starting.clear() self.running.set()
[docs] async def stop(self): """Stops all device backends and discovery routines """ self.running.clear() if self.discovery_listener is None: return await self.discovery_listener.stop() self.discovery_listener = None for vidhub in self.vidhubs.values(): await vidhub.backend.disconnect() for smartview in self.smartviews.values(): await smartview.backend.disconnect() for smartscope in self.smartscopes.values(): await smartscope.backend.disconnect() self.stopped.set() Config.loop = None
[docs] async def build_backend(self, device_type, backend_name, **kwargs): """Creates a "backend" instance The supplied keyword arguments are used to create the instance object which will be created using its :meth:`~vidhubcontrol.backends.base.BackendBase.create` classmethod. The appropriate subclass of :class:`DeviceConfigBase` will be created and stored to the config using :meth:`add_device`. Arguments: device_type (str): Device type to create. Choices are "vidhub", "smartview", "smartscope" backend_name (str): The class name of the backend as found in :doc:`backends` Returns: An instance of a :class:`vidhubcontrol.backends.base.BackendBase` subclass """ prop = getattr(self, self._device_type_map[device_type]['prop']) for obj in prop.values(): if obj.backend_name != backend_name: continue if kwargs.get('device_id') is not None and kwargs['device_id'] == obj.device_id: return obj.backend if kwargs.get('hostaddr') is not None and kwargs['hostaddr'] == obj.hostaddr: return obj.backend cls = BACKENDS[device_type][backend_name] kwargs['event_loop'] = self.loop backend = await cls.create_async(**kwargs) await self.add_device(backend) return backend
async def add_vidhub(self, backend): return await self.add_device(backend) async def add_smartview(self, backend): return await self.add_device(backend) async def add_smartscope(self, backend): return await self.add_device(backend)
[docs] async def add_device(self, backend): """Adds a "backend" instance to the config A subclass of :class:`DeviceConfigBase` will be either created or updated from the given backend instance. If the ``device_id`` exists in the config, the :attr:`DeviceConfigBase.backend` value of the matching :class:`DeviceConfigBase` instance will be set to the given ``backend``. Otherwise, a new :class:`DeviceConfigBase` instance will be created using the :meth:`DeviceConfigBase.from_existing` classmethod. Arguments: backend: An instance of one of the subclasses of :class:`vidhubcontrol.backends.base.BackendBase` found in :doc:`backends` """ device_type = backend.device_type cls = self._device_type_map[device_type]['cls'] prop = getattr(self, self._device_type_map[device_type]['prop']) if backend.device_id is not None and backend.device_id in prop: obj = prop[backend.device_id] obj.backend = backend else: obj = await cls.from_existing(backend, config=self) if obj.device_id is None: obj.device_id = self.id_for_device(obj) prop[obj.device_id] = obj obj.bind( trigger_save=self.on_device_trigger_save, device_id=self.on_backend_device_id, ) self.save()
def on_backend_device_id(self, backend, value, **kwargs): if value is None: return old = kwargs.get('old') prop = getattr(self, self._device_type_map[backend.device_type]['prop']) if old in prop: del prop[old] if value in prop: self.save() return prop[value] = backend self.save() async def add_discovered_device(self, device_type, info, device_id): async with self.discovery_lock: prop = getattr(self, self._device_type_map[device_type]['prop']) cls = None for key, _cls in BACKENDS[device_type].items(): if 'Telnet' in key: cls = _cls break hostaddr = str(info.address) hostport = int(info.port) if device_id in prop: obj = prop[device_id] if obj.hostaddr != hostaddr or obj.hostport != hostport: await obj.reset_hostaddr(hostaddr, hostport) return backend = await cls.create_async( hostaddr=hostaddr, hostport=hostport, event_loop=self.loop, ) if backend is None: return if backend.device_id != device_id: await backend.disconnect() return await self.add_device(backend) def on_discovery_service_added(self, info, **kwargs): if kwargs.get('class') not in ['Videohub', 'SmartView']: return device_type = kwargs.get('device_type') device_id = kwargs.get('id') if device_id is None: return prop = getattr(self, self._device_type_map[device_type]['prop']) if device_id in prop: obj = prop[device_id] if obj.backend is not None and obj.backend.connected: return asyncio.run_coroutine_threadsafe(self.add_discovered_device(device_type, info, device_id), loop=self.loop) def on_device_trigger_save(self, *args, **kwargs): self.save()
[docs] def save(self, filename=None): """Saves the config data to the given filename Arguments: filename (:obj:`str`, optional): The filename to write config data to. If not supplied, the current :attr:`filename` is used. Notes: If the ``filename`` argument is provided, it will replace the existing :attr:`filename` value. """ if filename is not None: self.filename = filename else: filename = self.filename filename = os.path.expanduser(filename) data = self._get_conf_data() if not os.path.exists(os.path.dirname(filename)): os.makedirs(os.path.dirname(filename)) s = jsonfactory.dumps(data, indent=4) with open(filename, 'w') as f: f.write(s)
@classmethod def _prepare_load_params(cls, filename=None, **kwargs): if filename is None: filename = cls.DEFAULT_FILENAME kwargs['filename'] = filename filename = os.path.expanduser(filename) if os.path.exists(filename): with open(filename, 'r') as f: s = f.read() kwargs.update(jsonfactory.loads(s)) return kwargs
[docs] @classmethod def load(cls, filename=None, **kwargs): """Creates a Config instance, loading data from the given filename Arguments: filename (:obj:`str`, optional): The filename to read config data from, defaults to :const:`Config.DEFAULT_FILENAME` Returns: A :class:`Config` instance """ kwargs = cls._prepare_load_params(filename, **kwargs) return cls(**kwargs)
[docs] @classmethod async def load_async(cls, filename=None, **kwargs): """Creates a Config instance, loading data from the given filename This coroutine method creates the ``Config`` instance and will ``await`` all start-up coroutines and futures before returning. Arguments: filename (:obj:`str`, optional): The filename to read config data from, defaults to :attr:`DEFAULT_FILENAME` Returns: A :class:`Config` instance """ kwargs = cls._prepare_load_params(filename, **kwargs) kwargs['auto_start'] = False config = cls(**kwargs) await config.start() await config._start_fut return config
[docs]class DeviceConfigBase(ConfigBase): """Base class for device config storage Attributes: config: A reference to the parent :class:`Config` instance backend: An instance of :class:`vidhubcontrol.backends.base.BackendBase` backend_name (str): The class name of the backend, used when loading from saved config data hostaddr (str): The IPv4 address of the device hostport (int): The port address of the device device_name (str): User-defined name to store with the device, defaults to the :attr:`device_id` value device_id (str): The unique id as reported by the device backend_unavailable (bool): ``True`` if communication with the device could not be established """ config = Property() backend = Property() backend_name = Property() hostaddr = Property() hostport = Property(9990) device_name = Property() device_id = Property() backend_unavailable = Property(False) _conf_attrs = [ 'backend_name', 'hostaddr', 'hostport', 'device_name', 'device_id', ] def __init__(self, **kwargs): self.config = kwargs.get('config') self.bind(backend=self.on_backend_set) self.loop = kwargs.get('event_loop', Config.loop)
[docs] @classmethod async def create(cls, **kwargs): """Creates device config and backend instances asynchronously Keyword arguments passed to this classmethod are passed to the init method and will be used to set its attributes. If a "backend" keyword argument is supplied, it should be a running instance of :class:`vidhubcontrol.backends.base.BackendBase`. It will then be used to collect config values from. If "backend" is not present, the appropriate one will be created using :meth:`build_backend`. Returns: An instance of :class:`DeviceConfigBase` """ self = cls(**kwargs) for attr in self._conf_attrs: setattr(self, attr, kwargs.get(attr)) self.backend = kwargs.get('backend') if self.backend is None: self.backend = await self.build_backend(**self._get_conf_data()) return self
[docs] @classmethod async def from_existing(cls, backend, **kwargs): """Creates a device config object from an existing backend Keyword arguments will be passed to the :meth:`create` method Arguments: backend: An instance of :class:`vidhubcontrol.backends.base.BackendBase` Returns: An instance of :class:`DeviceConfigBase` """ d = dict( backend=backend, backend_name=backend.__class__.__name__, hostaddr=getattr(backend, 'hostaddr', None), hostport=getattr(backend, 'hostport', None), device_name=backend.device_name, device_id=backend.device_id, ) for key, val in d.items(): kwargs.setdefault(key, val) kwargs['event_loop'] = backend.event_loop return await cls.create(**kwargs)
async def reset_hostaddr(self, hostaddr, hostport=None): if hostport is None: hostport = self.hostport await self.backend.disconnect() self.hostaddr = hostaddr self.hostport = hostport self.backend.hostaddr = hostaddr self.backend.hostport = hostport await self.backend.connect() self.emit('trigger_save')
[docs] async def build_backend(self, cls=None, **kwargs): """Creates a backend instance asynchronously Keyword arguments will be passed to the :meth:`vidhubcontrol.backends.base.BackendBase.create_async` method. Arguments: cls (optional): A subclass of :class:`~vidhubcontrol.backends.base.BackendBase`. If not present, the class will be determined from existing values of :attr:`device_type` and :attr:`backend_name` Returns: An instance of :class:`vidhubcontrol.backends.base.BackendBase` """ kwargs.setdefault('event_loop', self.loop) if cls is None: cls = BACKENDS[self.device_type][self.backend_name] backend = await cls.create_async(**kwargs) if backend is not None: if backend.connection_unavailable: self.backend_unavailable = True return backend
def on_backend_prop_change(self, instance, value, **kwargs): if instance is not self.backend: return if not instance.connected: return prop = kwargs.get('property') setattr(self, prop.name, value) self.emit('trigger_save') def on_backend_set(self, instance, backend, **kwargs): old = kwargs.get('old') if old is not None: old.unbind(self) if backend is None: return if backend.connected: if self.backend.device_name != self.device_name: self.device_name = self.backend.device_name if backend.device_id is None: if self.device_id is None: self.device_id = self.config.id_for_device(self) elif backend.connected: self.device_id = backend.device_id backend.bind( device_name=self.on_backend_prop_change, device_id=self._on_backend_device_id, ) if hasattr(backend, 'hostport'): if backend.connected: self.hostaddr = backend.hostaddr self.hostport = backend.hostport backend.bind( hostaddr=self.on_backend_prop_change, hostport=self.on_backend_prop_change, ) def _on_backend_device_id(self, backend, value, **kwargs): if backend is not self.backend: return if not backend.connected: return if backend.device_id is None: if self.device_id is not None: self.device_id = self.config.id_for_device(self) else: self.device_id = backend.device_id
[docs]class VidhubConfig(DeviceConfigBase): """Config container for VideoHub devices Attributes: presets (list): Preset data collected from the device :class:`presets <vidhubcontrol.backends.base.Preset>`. Will be used on initialization to populate the preset data to the device """ presets = ListProperty() _conf_attrs = DeviceConfigBase._conf_attrs + [ 'presets', ] device_type = 'vidhub'
[docs] @classmethod async def create(cls, **kwargs): kwargs.setdefault('presets', []) self = await super().create(**kwargs) return self
[docs] @classmethod async def from_existing(cls, backend, **kwargs): kwargs.setdefault('presets', []) for preset in backend.presets: kwargs['presets'].append(dict( name=preset.name, index=preset.index, crosspoints=preset.crosspoints.copy(), )) return await super().from_existing(backend, **kwargs)
[docs] async def build_backend(self, cls=None, **kwargs): kwargs['presets'] = kwargs['presets'][:] return await super().build_backend(cls, **kwargs)
def on_backend_set(self, instance, backend, **kwargs): super().on_backend_set(instance, backend, **kwargs) if self.backend is None: return pkwargs = {k:self.on_preset_update for k in ['name', 'crosspoints']} for preset in self.backend.presets: preset.bind(**pkwargs) self.backend.bind(on_preset_added=self.on_preset_added) def on_preset_added(self, *args, **kwargs): preset = kwargs.get('preset') self.presets.append(dict( name=preset.name, index=preset.index, crosspoints=preset.crosspoints.copy(), )) bkwargs = {k:self.on_preset_update for k in ['name', 'crosspoints']} self.emit('trigger_save') preset.bind(**bkwargs) def on_preset_update(self, instance, value, **kwargs): prop = kwargs.get('property') if prop.name == 'crosspoints': value = value.copy() self.presets[instance.index][prop.name] = value self.emit('trigger_save') def _get_conf_data(self): d = super()._get_conf_data() for pdata in d['presets']: if 'backend' in pdata: del pdata['backend'] return d
[docs]class SmartViewConfig(DeviceConfigBase): """Config container for SmartView devices """ device_type = 'smartview'
[docs]class SmartScopeConfig(DeviceConfigBase): """Config container for SmartScope devices """ device_type = 'smartscope'
Config._device_type_map['vidhub']['cls'] = VidhubConfig Config._device_type_map['smartview']['cls'] = SmartViewConfig Config._device_type_map['smartscope']['cls'] = SmartScopeConfig @jsonfactory.register class JsonHandler(object): def encode(self, o): if isinstance(o, ConfigBase): d = o._get_conf_data() return d def decode(self, d): keys = [key for key in d if key.isdigit()] if len(keys) == len(d.keys()): return {int(key):d[key] for key in d.keys()} return d