async def patch_device(request: core_api.APIRequest, params: Attributes) -> None: def unexpected_field_code(field: str) -> str: if field in core_device_attrs.get_attrdefs(): return 'attribute-not-modifiable' else: return 'no-such-attribute' core_api_schema.validate(params, core_device_attrs.get_schema(), unexpected_field_code=unexpected_field_code, unexpected_field_name='attribute') try: reboot_required = core_device_attrs.set_attrs(params) except core_device_attrs.DeviceAttributeError as e: raise core_api.APIError(400, e.error, attribute=e.attribute) except Exception as e: raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e await core_device.save() await core_device_events.trigger_update() if reboot_required: main.loop.call_later(2, system.reboot)
async def put_device(request: core_api.APIRequest, params: Attributes) -> None: core_api_schema.validate(params, core_device_attrs.get_schema(loose=True)) # Password fields must explicitly be ignored, so we pop them from supplied data for f in ('admin', 'normal', 'viewonly'): params.pop(f'{f}_password', None) # Ignore the date attribute params.pop('date', None) # Reset device attributes await core_device.reset(preserve_attrs=[ 'admin_password_hash', 'normal_password_hash', 'viewonly_password_hash' ]) await core_device.load() try: core_device_attrs.set_attrs(params, ignore_extra=True) except core_device_attrs.DeviceAttributeError as e: raise core_api.APIError(400, e.error, attribute=e.attribute) except Exception as e: raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e await core_device.save() await core_device_events.trigger_update()
async def patch_port(request: core_api.APIRequest, port_id: str, params: Attributes) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no such port') def unexpected_field_msg(field: str) -> str: if field in port.get_non_modifiable_attrs(): return 'attribute not modifiable: {field}' else: return 'no such attribute: {field}' core_api_schema.validate(params, await port.get_schema(), unexpected_field_msg=unexpected_field_msg) # Step validation for name, value in params.items(): attrdef = port.ATTRDEFS[name] step = attrdef.get('step') _min = attrdef.get('min') if None not in (step, _min) and step != 0 and (value - _min) % step: raise core_api.APIError(400, f'invalid field: {name}') errors_by_name = {} async def set_attr(attr_name: str, attr_value: Attribute) -> None: core_api.logger.debug('setting attribute %s = %s on %s', attr_name, json_utils.dumps(attr_value), port) try: await port.set_attr(attr_name, attr_value) except Exception as e: errors_by_name[attr_name] = e if params: await asyncio.wait([set_attr(name, value) for name, value in params.items()]) if errors_by_name: name, error = next(iter(errors_by_name.items())) if isinstance(error, core_api.APIError): raise error elif isinstance(error, core_ports.InvalidAttributeValue): raise core_api.APIError(400, f'invalid field: {name}') elif isinstance(error, core_ports.PortTimeout): raise core_api.APIError(504, 'port timeout') elif isinstance(error, core_ports.PortError): raise core_api.APIError(502, f'port error: {error}') else: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, str(error)) await port.save()
async def patch_webhooks(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_WEBHOOKS) try: core_webhooks.setup(**params) except core_webhooks.InvalidParamError as e: raise core_api.APIError(400, str(e)) from e core_webhooks.save()
async def patch_reverse(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_REVERSE) try: core_reverse.setup(**params) except core_reverse.InvalidParamError as e: raise core_api.APIError(400, str(e)) from e core_reverse.save()
async def patch_port_value(request: core_api.APIRequest, port_id: str, params: PortValue) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no-such-port') try: core_api_schema.validate(params, await port.get_value_schema()) except core_api.APIError: # Transform any validation error into an invalid-field APIError for value raise core_api.APIError(400, 'invalid-value') from None value = params # Step validation step = await port.get_attr('step') min_ = await port.get_attr('min') if None not in (step, min_) and step != 0 and (value - min_) % step: raise core_api.APIError(400, 'invalid-value') if not port.is_enabled(): raise core_api.APIError(400, 'port-disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only-port') old_value = port.get_last_read_value() try: await port.write_transformed_value(value, reason=core_ports.CHANGE_REASON_API) except core_ports.PortTimeout as e: raise core_api.APIError(504, 'port-timeout') from e except core_ports.PortError as e: raise core_api.APIError(502, 'port-error', code=str(e)) from e except core_api.APIError: raise except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e await core_main.update() # If port value hasn't really changed, use 202 Accepted to inform consumer that new value hasn't been applied yet current_value = port.get_last_read_value() if (old_value == current_value) and (old_value != value): port.debug("API supplied value hasn't been applied right away") raise core_api.APIAccepted()
async def patch_firmware(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_FIRMWARE) status = await fwupdate.get_status() if status not in (fwupdate.STATUS_IDLE, fwupdate.STATUS_ERROR): raise core_api.APIError(503, 'busy') if params.get('url'): await fwupdate.update_to_url(params['url']) else: # Assuming params['version'] await fwupdate.update_to_version(params['version'])
async def post_slave_device_events(request: core_api.APIRequest, name: str, params: GenericJSONDict) -> None: slave = slaves_devices.get(name) if not slave: raise core_api.APIError(404, 'no-such-device') # Slave events endpoint has special privilege requirements: its token signature must be validated using slave admin # password auth = request.headers.get('Authorization') if not auth: slave.warning('missing authorization header') raise core_api.APIError(401, 'authentication-required') try: core_api_auth.parse_auth_header( auth, core_api_auth.ORIGIN_DEVICE, lambda u: slave.get_admin_password_hash(), require_usr=False ) except core_api_auth.AuthError as e: slave.warning(str(e)) raise core_api.APIError(401, 'authentication-required') from e core_api_schema.validate(params, api_schema.POST_SLAVE_DEVICE_EVENTS) if slave.get_poll_interval() > 0: raise core_api.APIError(400, 'polling-enabled') if slave.is_listen_enabled(): raise core_api.APIError(400, 'listening-enabled') # At this point we can be sure the slave is permanently offline try: await slave.handle_event(params) except Exception as e: raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e slave.update_last_sync() await slave.save() # As soon as we receive event requests (normally generated by a webhook), we can consider the device is temporarily # reachable, so we apply provisioning values and update data locally slave.schedule_provisioning_and_update(1)
async def patch_port_value(request: core_api.APIRequest, port_id: str, params: PortValue) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no such port') core_api_schema.validate(params, await port.get_value_schema(), invalid_request_msg='invalid value') value = params # Step validation step = await port.get_attr('step') _min = await port.get_attr('min') if None not in (step, _min) and step != 0 and (value - _min) % step: raise core_api.APIError(400, 'invalid field: value') if not port.is_enabled(): raise core_api.APIError(400, 'port disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only port') old_value = port.get_value() try: await port.write_transformed_value(value, reason=core_ports.CHANGE_REASON_API) except core_ports.PortTimeout as e: raise core_api.APIError(504, 'port timeout') from e except core_ports.PortError as e: raise core_api.APIError(502, f'port error: {e}') from e except core_api.APIError: raise except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, str(e)) from e await main.update() # If port value hasn't really changed, trigger a value-change to inform consumer that new value has been ignored current_value = port.get_value() if old_value == current_value: port.debug('API supplied value was ignored') port.trigger_value_change()
async def patch_port_sequence(request: core_api.APIRequest, port_id: str, params: GenericJSONDict) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no-such-port') core_api_schema.validate(params, core_api_schema.PATCH_PORT_SEQUENCE) values = params['values'] delays = params['delays'] repeat = params['repeat'] if len(values) != len(delays): raise core_api.APIError(400, 'invalid-field', field='delays') value_schema = await port.get_value_schema() step = await port.get_attr('step') min_ = await port.get_attr('min') for value in values: # Translate any APIError generated when validating value schema into an invalid-field APIError on value try: core_api_schema.validate(value, value_schema) except core_api.APIError: raise core_api.APIError(400, 'invalid-field', field='values') from None # Step validation if None not in (step, min_) and step != 0 and (value - min_) % step: raise core_api.APIError(400, 'invalid-field', field='values') if not port.is_enabled(): raise core_api.APIError(400, 'port-disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only-port') if await port.get_attr('expression'): raise core_api.APIError(400, 'port-with-expression') try: await port.set_sequence(values, delays, repeat) except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, 'unexpected-error', message=str(e)) from e
async def patch_discovered_device(request: core_api.APIRequest, name: str, params: GenericJSONDict) -> GenericJSONDict: core_api_schema.validate(params, api_schema.PATCH_DISCOVERED_DEVICE) discovered_devices = slaves_discover.get_discovered_devices() or {} discovered_device = discovered_devices.get(name) if not discovered_device: raise core_api.APIError(404, 'no-such-device') attrs = params['attrs'] try: discovered_device = await slaves_discover.configure( discovered_device, attrs) except Exception as e: raise slaves_exceptions.adapt_api_error(e) from e return discovered_device.to_json()
async def post_reset(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.POST_RESET) factory = params.get('factory') if factory: core_api.logger.info('resetting to factory defaults') core_ports.reset() core_vports.reset() core_device.reset() if settings.webhooks.enabled: core_webhooks.reset() if settings.reverse.enabled: core_reverse.reset() if settings.slaves.enabled: slaves.reset() main.loop.call_later(2, system.reboot)
async def patch_slave_device(request: core_api.APIRequest, name: str, params: GenericJSONDict) -> None: core_api_schema.validate(params, api_schema.PATCH_SLAVE_DEVICE) slave = slaves_devices.get(name) if not slave: raise core_api.APIError(404, 'no such device') if params.get('enabled') is True and not slave.is_enabled(): await slave.enable() elif params.get('enabled') is False and slave.is_enabled(): await slave.disable() if params.get('poll_interval') and params.get('listen_enabled'): raise core_api.APIError(400, 'listening and polling') if params.get('poll_interval') is not None: slave.set_poll_interval(params['poll_interval']) if params.get('listen_enabled') is not None: if params['listen_enabled']: # We need to know if device supports listening; we therefore call GET /device before enabling it if slave.is_enabled(): try: attrs = await slave.api_call('GET', '/device') except Exception as e: raise exceptions.adapt_api_error(e) from e if 'listen' not in attrs['flags']: raise core_api.APIError(400, 'no listen support') slave.enable_listen() else: slave.disable_listen() slave.save() slave.trigger_update()
async def put_webhooks(request: core_api.APIRequest, params: GenericJSONDict) -> None: core_api_schema.validate(params, core_api_schema.PATCH_WEBHOOKS) # Also ensure that needed fields are not empty when webhooks are enabled if params['enabled']: if not params['host']: raise core_api.APIError(400, 'invalid-field', field='host') if not params['path']: raise core_api.APIError(400, 'invalid-field', field='path') if 'password' not in params and 'password_hash' not in params: raise core_api.APIError(400, 'missing-field', field='password') try: core_webhooks.setup(**params) except core_webhooks.InvalidParamError as e: raise core_api.APIError(400, 'invalid-field', field=e.param) from e await core_webhooks.save()
async def post_port_sequence(request: core_api.APIRequest, port_id: str, params: GenericJSONDict) -> None: port = core_ports.get(port_id) if port is None: raise core_api.APIError(404, 'no such port') core_api_schema.validate(params, core_api_schema.POST_PORT_SEQUENCE) values = params['values'] delays = params['delays'] repeat = params['repeat'] if len(values) != len(delays): raise core_api.APIError(400, 'invalid field: delays') value_schema = await port.get_value_schema() step = await port.get_attr('step') _min = await port.get_attr('min') for value in values: core_api_schema.validate(value, value_schema, invalid_request_msg='invalid field: values') # Step validation if None not in (step, _min) and step != 0 and (value - _min) % step: raise core_api.APIError(400, 'invalid field: values') if not port.is_enabled(): raise core_api.APIError(400, 'port disabled') if not await port.is_writable(): raise core_api.APIError(400, 'read-only port') if await port.get_attr('expression'): raise core_api.APIError(400, 'port with expression') try: await port.set_sequence(values, delays, repeat) except Exception as e: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, str(e)) from e
async def post_introspect(request: core_api.APIRequest, params: GenericJSONDict) -> GenericJSONDict: core_api_schema.validate(params, core_api_schema.POST_INTROSPECT) exc_str = None res_str = None try: imports = params.get('imports', []) extra_locals = {} for imp in imports: extra_locals[imp.split('.')[0]] = importlib.__import__(imp) result = eval(params['code'], globals(), dict(locals(), **extra_locals)) if inspect.isawaitable(result): result = await result res_str = str(result) except Exception: exc_str = traceback.format_exc() return {'result': res_str, 'exception': exc_str}
async def post_ports(request: core_api.APIRequest, params: GenericJSONDict) -> Attributes: core_api_schema.validate(params, core_api_schema.POST_PORTS) port_id = params['id'] port_type = params['type'] _min = params.get('min') _max = params.get('max') integer = params.get('integer') step = params.get('step') choices = params.get('choices') if core_ports.get(port_id): raise core_api.APIError(400, 'duplicate port') if len(core_vports.all_settings()) >= settings.core.virtual_ports: raise core_api.APIError(400, 'too many ports') core_vports.add(port_id, port_type, _min, _max, integer, step, choices) port = await core_ports.load_one( 'qtoggleserver.core.vports.VirtualPort', { 'port_id': port_id, '_type': port_type, '_min': _min, '_max': _max, 'integer': integer, 'step': step, 'choices': choices } ) # A virtual port is enabled by default await port.enable() await port.save() return await port.to_json()
async def put_ports(request: core_api.APIRequest, params: GenericJSONList) -> None: if not settings.core.backup_support: raise core_api.APIError(404, 'no-such-function') core_api_schema.validate( params, core_api_schema.PUT_PORTS ) core_api.logger.debug('restoring ports') # Disable event handling during the processing of this request, as we're going to trigger a full-update at the end core_events.disable() # Temporarily disable core updating (port polling, expression evaluating and value-change handling) core_main.disable_updating() try: # Remove all (local) virtual ports for port in core_ports.get_all(): if not isinstance(port, core_vports.VirtualPort): continue await port.remove() await core_vports.remove(port.get_id()) # Reset ports await core_ports.reset() if settings.slaves.enabled: await slaves.reset_ports() for port in core_ports.get_all(): await port.reset() add_port_schema = dict(core_api_schema.POST_PORTS) add_port_schema['additionalProperties'] = True # Restore supplied attributes for attrs in params: id_ = attrs.get('id') if id_ is None: core_api.logger.warning('ignoring entry without id') continue port = core_ports.get(id_) # Virtual ports must be added first (unless they belong to a slave) virtual = attrs.get('virtual') if port is not None: # Port already exists so it probably belongs to a slave virtual = False for slave in slaves_devices.get_all(): if id_.startswith(f'{slave.get_name()}.'): # id indicates that port belongs to a slave virtual = False break if 'provisioning' in attrs: # A clear indication that port belongs to a slave virtual = False if virtual: await wrap_error_with_port_id( id_, core_api_schema.validate, attrs, add_port_schema ) port = await wrap_error_with_port_id( id_, add_virtual_port, attrs ) if port is None: core_api.logger.warning('ignoring unknown port id "%s"', id_) continue if isinstance(port, slaves_ports.SlavePort): core_api.logger.debug('restoring slave port "%s"', id_) # For slave ports, ignore any attributes that are not kept on master attrs = {n: v for n, v in attrs.items() if n in ('tag', 'expression', 'expires')} else: core_api.logger.debug('restoring local port "%s"', id_) await wrap_error_with_port_id( id_, set_port_attrs, port, attrs, ignore_extra_attrs=True ) finally: core_main.enable_updating() core_events.enable() await core_events.trigger_full_update() core_api.logger.debug('ports restore done')
async def post_slave_devices(request: core_api.APIRequest, params: GenericJSONDict) -> GenericJSONDict: core_api_schema.validate(params, api_schema.POST_SLAVE_DEVICES) scheme = params['scheme'] host = params['host'] port = params['port'] path = params['path'] admin_password = params['admin_password'] poll_interval = params['poll_interval'] listen_enabled = params['listen_enabled'] # Look for slave duplicate for slave in slaves_devices.get_all(): if (slave.get_scheme() == scheme and slave.get_host() == host and slave.get_port() == port and slave.get_path() == path): raise core_api.APIError(400, 'duplicate device') if poll_interval and listen_enabled: raise core_api.APIError(400, 'listening and polling') try: slave = await slaves_devices.add(scheme, host, port, path, poll_interval, listen_enabled, admin_password=admin_password) except (core_responses.HostUnreachable, core_responses.NetworkUnreachable, core_responses.UnresolvableHostname) as e: raise core_api.APIError(502, 'unreachable') from e except core_responses.ConnectionRefused as e: raise core_api.APIError(502, 'connection refused') from e except core_responses.InvalidJson as e: raise core_api.APIError(502, 'invalid device') from e except core_responses.Timeout as e: raise core_api.APIError(504, 'device timeout') from e except exceptions.InvalidDevice as e: raise core_api.APIError(502, 'invalid device') from e except exceptions.NoListenSupport as e: raise core_api.APIError(400, 'no listen support') from e except exceptions.DeviceAlreadyExists as e: raise core_api.APIError(400, 'duplicate device') from e except core_api.APIError: raise except core_responses.HTTPError as e: # We need to treat the 401/403 slave responses as a 400 if e.code in (401, 403): raise core_api.APIError(400, 'forbidden') from e raise core_api.APIError.from_http_error(e) from e except Exception as e: raise exceptions.adapt_api_error(e) from e return slave.to_json()
async def post_slave_devices(request: core_api.APIRequest, params: GenericJSONDict) -> GenericJSONDict: core_api_schema.validate(params, api_schema.POST_SLAVE_DEVICES) slave = await add_slave_device(params) return slave.to_json()
async def put_slave_devices(request: core_api.APIRequest, params: GenericJSONList) -> None: if not settings.core.backup_support: raise core_api.APIError(404, 'no-such-function') core_api_schema.validate( params, api_schema.PUT_SLAVE_DEVICES ) core_api.logger.debug('restoring slave devices') # Disable event handling during the processing of this request, as we're going to trigger a full-update at the end core_events.disable() # Temporarily disable core updating (port polling, expression evaluating and value-change handling) core_main.disable_updating() try: # Remove all slave devices for slave in slaves_devices.get_all(): await slaves_devices.remove(slave) add_device_schema = dict(api_schema.POST_SLAVE_DEVICES) add_device_schema['additionalProperties'] = True # Validate supplied slave properties for index, properties in enumerate(params): await wrap_error_with_index( index, core_api_schema.validate, properties, add_device_schema ) # Add slave devices add_slave_futures = [] for index, properties in enumerate(params): add_slave_future = wrap_error_with_index( index, add_slave_device_retry_disabled, properties ) add_slave_futures.append(add_slave_future) added_slaves = await asyncio.gather(*add_slave_futures) # Wait for devices to come online wait_online_futures = [] for slave in added_slaves: if slave.is_enabled(): wait_online_futures.append(slave.wait_online(timeout=settings.slaves.long_timeout)) try: await asyncio.gather(*wait_online_futures) except asyncio.TimeoutError: # Ignore timeouts; this is a best-effort API call pass finally: core_main.enable_updating() core_events.enable() await core_events.trigger_full_update() core_api.logger.debug('slave devices restore done')
async def set_port_attrs(port: core_ports.BasePort, attrs: GenericJSONDict, ignore_extra_attrs: bool) -> None: non_modifiable_attrs = await port.get_non_modifiable_attrs() def unexpected_field_code(field: str) -> str: if field in non_modifiable_attrs: return 'attribute-not-modifiable' else: return 'no-such-attribute' schema = await port.get_schema() if ignore_extra_attrs: schema = dict(schema) schema['additionalProperties'] = True # Ignore non-existent and non-modifiable attributes core_api_schema.validate( attrs, schema, unexpected_field_code=unexpected_field_code, unexpected_field_name='attribute' ) # Step validation attrdefs = await port.get_attrdefs() for name, value in attrs.items(): attrdef = attrdefs.get(name) if attrdef is None: continue step = attrdef.get('step') min_ = attrdef.get('min') if None not in (step, min_) and step != 0 and (value - min_) % step: raise core_api.APIError(400, 'invalid-field', field=name) errors_by_name = {} async def set_attr(attr_name: str, attr_value: Attribute) -> None: core_api.logger.debug('setting attribute %s = %s on %s', attr_name, json_utils.dumps(attr_value), port) try: await port.set_attr(attr_name, attr_value) except Exception as e1: errors_by_name[attr_name] = e1 value = attrs.pop('value', None) if attrs: await asyncio.wait([set_attr(n, v) for n, v in attrs.items()]) if errors_by_name: name, error = next(iter(errors_by_name.items())) if isinstance(error, core_api.APIError): raise error elif isinstance(error, core_ports.InvalidAttributeValue): raise core_api.APIError(400, 'invalid-field', field=name, details=error.details) elif isinstance(error, core_ports.PortTimeout): raise core_api.APIError(504, 'port-timeout') elif isinstance(error, core_ports.PortError): raise core_api.APIError(502, 'port-error', code=str(error)) else: # Transform any unhandled exception into APIError(500) raise core_api.APIError(500, 'unexpected-error', message=str(error)) from error # If value is supplied among attrs, use it to update port value, but in background and ignoring any errors if value is not None and port.is_enabled(): asyncio.create_task(port.write_transformed_value(value, reason=core_ports.CHANGE_REASON_API)) await port.save()
async def post_ports(request: core_api.APIRequest, params: GenericJSONDict) -> Attributes: core_api_schema.validate(params, core_api_schema.POST_PORTS) port = await add_virtual_port(params) return await port.to_json()