From b2ba52ce3d81fcd68f243a1b0d0ef744f8390075 Mon Sep 17 00:00:00 2001 From: Morton Jonuschat Date: Mon, 20 Feb 2023 14:43:41 -0800 Subject: [PATCH] sensor: add support to track generic single value sensors This feature implements a sensor component that can be used to track/log generic sensors from multiple sources. Each sensor can have properties like unit of measurement, accuracy and a display name that help frontends display the tracked measurements. Signed-off-by: Morton Jonuschat --- docs/api_changes.md | 14 ++ docs/configuration.md | 95 +++++++++- docs/web_api.md | 172 ++++++++++++++++++ moonraker/components/sensor.py | 309 +++++++++++++++++++++++++++++++++ 4 files changed, 586 insertions(+), 4 deletions(-) create mode 100644 moonraker/components/sensor.py diff --git a/docs/api_changes.md b/docs/api_changes.md index 607ba04..30f92fd 100644 --- a/docs/api_changes.md +++ b/docs/api_changes.md @@ -1,6 +1,20 @@ ## This document keeps a record of all changes to Moonraker's web APIs. +### February 20th 2023 +- The following new endpoints are available when at least one `[sensor]` + section has been configured: + - `GET /server/sensors/list` + - `GET /server/sensors/sensor` + - `GET /server/sensors/measurements` + + See [web_api.md](web_api.md) for details on these new endpoints. +- A `sensors:sensor_update` notification has been added. When at least one + monitored sensor is reporting a changed value Moonraker will broadcast this + notification. + + See [web_api.md](web_api.md) for details on this new notification. + ### February 17 2023 - Moonraker API Version 1.2.1 - An error in the return value for some file manager endpoints has diff --git a/docs/configuration.md b/docs/configuration.md index d91e3a0..72c815b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -1160,8 +1160,8 @@ device_id: # An explanation on how you could get the device id, can be found here: # https://developers.meethue.com/develop/get-started-2/#turning-a-light-on-and-off device_type: light -# Set to light to control a single hue light, or group to control a hue light gorup. -# If device_type is set to light, the device_id should be the light id, +# Set to light to control a single hue light, or group to control a hue light gorup. +# If device_type is set to light, the device_id should be the light id, # and if the device_type is group, the device_id should be the group id. # The default is "light. @@ -1610,7 +1610,7 @@ instance_name: status_objects: # A newline separated list of Klipper objects whose state will be # published. There are two different ways to publish the states - you -# can use either or both depending on your need. See the +# can use either or both depending on your need. See the # "publish_split_status" options for details. # # For example, this option could be set as follows: @@ -1634,7 +1634,7 @@ status_objects: # If not configured then no objects will be tracked and published to # the klipper/status topic. publish_split_status: False -# Configures how to publish status updates to MQTT. +# Configures how to publish status updates to MQTT. # # When set to False (default), all Klipper object state updates will be # published to a single mqtt state with the following topic: @@ -2187,6 +2187,93 @@ ambient_sensor: More on how your data is used in the SimplyPrint privacy policy here; [https://simplyprint.io/legal/privacy](https://simplyprint.io/legal/privacy) +### `[sensor]` + +Enables data collection from additional sensor sources. Multiple "sensor" +sources may be configured, each with their own section, ie: `[sensor current]`, +`[sensor voltage]`. + +#### Options common to all sensor devices + +The following configuration options are available for all sensor types: + +```ini +# moonraker.conf + +[sensor my_sensor] +type: +# The type of device. Supported types: mqtt +# This parameter must be provided. +name: +# The friendly display name of the sensor. +# The default is the sensor source name. +``` + +#### MQTT Sensor Configuration + +The following options are available for `mqtt` sensor types: + +```ini +# moonraker.conf + +qos: +# The MQTT QOS level to use when publishing and subscribing to topics. +# The default is to use the setting supplied in the [mqtt] section. +state_topic: +# The mqtt topic to subscribe to for sensor state updates. This parameter +# must be provided. +state_response_template: +# A template used to parse the payload received with the state topic. A +# "payload" variable is provided the template's context. This template must +# call the provided set_result() method to pass sensor values to Moonraker. +# `set_result()` expects two parameters, the name of the measurement (as +# string) and the value of the measurement (either integer or float number). +# +# This allows for sensor that can return multiple readings (e.g. temperature/ +# humidity sensors or powermeters). +# For example: +# {% set notification = payload|fromjson %} +# {set_result("temperature", notification["temperature"]|float)} +# {set_result("humidity", notification["humidity"]|float)} +# {set_result("pressure", notification["pressure"]|float)} +# +# The above example assumes a json response with multiple fields in a struct +# is received. Individual measurements are extracted from that struct, coerced +# to a numeric format and passed to Moonraker. The default is the payload. +``` + +!!! Note + Moonraker's MQTT client must be properly configured to add a MQTT sensor. + See the [mqtt](#mqtt) section for details. + +!!! Tip + MQTT is the most robust way of collecting sensor data from networked + devices through Moonraker. A well implemented MQTT sensor will publish all + changes in state to the `state_topic`. Moonraker receives these changes, + updates its internal state, and notifies connected clients. + +Example: + +```ini +# moonraker.conf + +# Example configuration for a Shelly Pro 1PM (Gen2) switch with +# integrated power meter running the Shelly firmware over MQTT. +[sensor mqtt_powermeter] +type: mqtt +name: Powermeter +# Use a different display name +state_topic: shellypro1pm-8cb113caba09/status/switch:0 +# The response is a JSON object with a multiple fields that we convert to +# float values before passing them to Moonraker. +state_response_template: + {% set notification = payload|fromjson %} + {set_result("power", notification["apower"]|float)} + {set_result("voltage", notification["voltage"]|float)} + {set_result("current", notification["current"]|float)} + {set_result("energy", notification["aenergy"]["by_minute"][0]|float * 0.000001)} +``` + ## Include directives It is possible to include configuration from other files via include diff --git a/docs/web_api.md b/docs/web_api.md index 09ca4bc..8e3a453 100644 --- a/docs/web_api.md +++ b/docs/web_api.md @@ -4915,6 +4915,154 @@ State of the strip. } ``` +### Sensor APIs +The APIs below are available when the `[sensor]` component has been configured. + +#### Get Sensor List +HTTP request: +```http +GET /server/sensors/list +``` +JSON-RPC request: +```json +{ + "jsonrpc": "2.0", + "method":"server.sensors.list", + "id": 5646 +} +``` +Returns: + +An array of objects containing info for each configured sensor. +```json +{ + "sensors": { + "sensor1": { + "id": "sensor1", + "friendly_name": "Sensor 1", + "type": "mqtt", + "values": { + "value1": 0, + "value2": 119.8 + } + } + } +} +``` + +#### Get Sensor Information +Returns the status for a single configured sensor. + +HTTP request: +```http +GET /server/sensors/info?sensor=sensor1 +``` +JSON-RPC request: +```json +{ + "jsonrpc": "2.0", + "method": "/server/sensors/info?sensor=sensor1", + "params": { + "sensor": "sensor1" + }, + "id": 4564 +} +``` +Returns: + +An object containing sensor information for the requested sensor: +```json +{ + "id": "sensor1", + "friendly_name": "Sensor 1", + "type": "mqtt", + "values": { + "value1": 0.0, + "value2": 120.0 + } +} +``` + +#### Get Sensor Measurements +Returns all recorded measurements for a configured sensor. + +HTTP request: +```http +GET /server/sensors/measurements?sensor=sensor1 +``` +JSON-RPC request: +```json +{ + "jsonrpc": "2.0", + "method": "server.sensors.measurements", + "params": { + "sensor": "sensor1" + }, + "id": 4564 +} +``` +Returns: + +An object containing all recorded measurements for the requested sensor: +```json +{ + "sensor1": { + "value1": [ + 3.1, + 3.2, + 3.0 + ], + "value2": [ + 120.0, + 120.0, + 119.9 + ] + } +} +``` + +#### Get Batch Sensor Measurements +Returns recorded measurements for all sensors. + +HTTP request: +```http +GET /server/sensors/measurements +``` +JSON-RPC request: +```json +{ + "jsonrpc": "2.0", + "method": "server.sensors.measurements", + "id": 4564 +} +``` +Returns: + +An object containing all measurements for every configured sensor: +```json +{ + "sensor1": { + "value1": [ + 3.1, + 3.2, + 3.0 + ], + "value2": [ + 120.0, + 120.0, + 119.9 + ] + }, + "sensor2": { + "value_a": [ + 1, + 1, + 0 + ] + } +} +``` + ### OctoPrint API emulation Partial support of OctoPrint API is implemented with the purpose of allowing uploading of sliced prints to a moonraker instance. @@ -6290,6 +6438,30 @@ disconnects clients will receive a `disconnected` event with the data field omitted. All other events are determined by the agent, where each event may or may not include optional `data`. +#### Sensor Events + +Moonraker will emit a `sensors:sensor_update` notification when a measurement +from at least one monitored sensor changes. + +```json +{ + "jsonrpc": "2.0", + "method": "sensors:sensor_update", + "params": [ + { + "sensor1": { + "humidity": 28.9, + "temperature": 22.4 + } + } + ] +``` + +When a sensor reading changes, all connections will receive a +`sensors:sensor_update` event where the params contains a data struct +with the sensor id as the key and the sensors letest measurements as value +struct. + ### Appendix #### Websocket setup diff --git a/moonraker/components/sensor.py b/moonraker/components/sensor.py new file mode 100644 index 0000000..29068fd --- /dev/null +++ b/moonraker/components/sensor.py @@ -0,0 +1,309 @@ +# Generic sensor support +# +# Copyright (C) 2022 Morton Jonuschat +# +# This file may be distributed under the terms of the GNU GPLv3 license. + +# Component to read additional generic sensor data and make it +# available to clients +from __future__ import annotations + +import logging +from collections import defaultdict, deque +from dataclasses import dataclass, replace +from functools import partial + +# Annotation imports +from typing import ( + Any, + DefaultDict, + Deque, + Dict, + List, + Optional, + Type, + TYPE_CHECKING, + Union, +) + +if TYPE_CHECKING: + from confighelper import ConfigHelper + from websockets import WebRequest + +SENSOR_UPDATE_TIME = 1.0 +SENSOR_EVENT_NAME = "sensors:sensor_update" + + +@dataclass(frozen=True) +class SensorConfiguration: + id: str + name: str + type: str + source: str = "" + + +if TYPE_CHECKING: + from confighelper import ConfigHelper + + from .mqtt import MQTTClient + + +def _set_result( + name: str, value: Union[int, float], store: Dict[str, Union[int, float]] +) -> None: + if not isinstance(value, (int, float)): + store[name] = float(value) + else: + store[name] = value + + +@dataclass(frozen=True) +class Sensor: + config: SensorConfiguration + values: Dict[str, Deque[Union[int, float]]] + + +class BaseSensor: + def __init__(self, name: str, cfg: ConfigHelper, store_size: int = 1200) -> None: + self.server = cfg.get_server() + self.error_state: Optional[str] = None + + self.config = SensorConfiguration( + id=name, + type=cfg.get("type"), + name=cfg.get("name", name), + ) + self.last_measurements: Dict[str, Union[int, float]] = {} + self.last_value: Dict[str, Union[int, float]] = {} + self.values: DefaultDict[str, Deque[Union[int, float]]] = defaultdict( + lambda: deque(maxlen=store_size) + ) + + def _update_sensor_value(self, eventtime: float) -> None: + """ + Append the last updated value to the store. + """ + for key, value in self.last_measurements.items(): + self.values[key].append(value) + + # Copy the last measurements data + self.last_value = {**self.last_measurements} + + async def initialize(self) -> bool: + """ + Sensor initialization executed on Moonraker startup. + """ + logging.info("Registered sensor '%s'", self.config.name) + return True + + def get_sensor_info(self) -> Dict[str, Any]: + return { + "id": self.config.id, + "friendly_name": self.config.name, + "type": self.config.type, + "values": self.last_measurements, + } + + def get_sensor_measurements(self) -> Dict[str, List[Union[int, float]]]: + return {key: list(values) for key, values in self.values.items()} + + def get_name(self) -> str: + return self.config.name + + def close(self) -> None: + pass + + +class MQTTSensor(BaseSensor): + def __init__(self, name: str, cfg: ConfigHelper, store_size: int = 1200): + super().__init__(name=name, cfg=cfg) + self.mqtt: MQTTClient = self.server.load_component(cfg, "mqtt") + + self.state_topic: str = cfg.get("state_topic") + self.state_response = cfg.load_template("state_response_template", "{payload}") + self.config = replace(self.config, source=self.state_topic) + self.qos: Optional[int] = cfg.getint("qos", None, minval=0, maxval=2) + + self.server.register_event_handler( + "mqtt:disconnected", self._on_mqtt_disconnected + ) + + def _on_state_update(self, payload: bytes) -> None: + measurements: Dict[str, Union[int, float]] = {} + context = { + "payload": payload.decode(), + "set_result": partial(_set_result, store=measurements), + } + + try: + self.state_response.render(context) + except Exception as e: + logging.error("Error updating sensor results: %s", e) + self.error_state = str(e) + else: + self.error_state = None + self.last_measurements = measurements + logging.debug( + "Received updated sensor value for %s: %s", + self.config.name, + self.last_measurements, + ) + + async def _on_mqtt_disconnected(self): + self.error_state = "MQTT Disconnected" + self.last_measurements = {} + + async def initialize(self) -> bool: + await super().initialize() + try: + self.mqtt.subscribe_topic( + self.state_topic, + self._on_state_update, + self.qos, + ) + self.error_state = None + return True + except Exception as e: + self.error_state = str(e) + return False + + +class Sensors: + __sensor_types: Dict[str, Type[BaseSensor]] = {"MQTT": MQTTSensor} + + def __init__(self, config: ConfigHelper) -> None: + self.server = config.get_server() + self.store_size = config.getint("sensor_store_size", 1200) + prefix_sections = config.get_prefix_sections("sensor") + self.sensors: Dict[str, BaseSensor] = {} + + # Register timer to update sensor values in store + self.sensors_update_timer = self.server.get_event_loop().register_timer( + self._update_sensor_values + ) + + # Register endpoints + self.server.register_endpoint( + "/server/sensors/list", + ["GET"], + self._handle_sensor_list_request, + ) + self.server.register_endpoint( + "/server/sensors/info", + ["GET"], + self._handle_sensor_info_request, + ) + self.server.register_endpoint( + "/server/sensors/measurements", + ["GET"], + self._handle_sensor_measurements_request, + ) + + # Register notifications + self.server.register_notification(SENSOR_EVENT_NAME) + + for section in prefix_sections: + cfg = config[section] + + try: + try: + _, name = cfg.get_name().split(maxsplit=1) + except ValueError: + raise cfg.error(f"Invalid section name: {cfg.get_name()}") + + logging.info(f"Configuring sensor: {name}") + + sensor_type: str = cfg.get("type") + sensor_class: Optional[Type[BaseSensor]] = self.__sensor_types.get( + sensor_type.upper(), None + ) + if sensor_class is None: + raise config.error(f"Unsupported sensor type: {sensor_type}") + + self.sensors[name] = sensor_class( + name=name, + cfg=cfg, + store_size=self.store_size, + ) + except Exception as e: + # Ensures that configuration errors are shown to the user + self.server.add_warning( + f"Failed to configure sensor [{cfg.get_name()}]\n{e}" + ) + continue + + def _update_sensor_values(self, eventtime: float) -> float: + """ + Iterate through the sensors and store the last updated value. + """ + changed_data: Dict[str, Dict[str, Union[int, float]]] = {} + for sensor_name, sensor in self.sensors.items(): + base_value = sensor.last_value + sensor._update_sensor_value(eventtime=eventtime) + + # Notify if a change in sensor values was detected + if base_value != sensor.last_value: + changed_data[sensor_name] = sensor.last_value + if changed_data: + self.server.send_event(SENSOR_EVENT_NAME, changed_data) + + return eventtime + SENSOR_UPDATE_TIME + + async def component_init(self) -> None: + try: + logging.debug("Initializing sensor component") + for sensor in self.sensors.values(): + if not await sensor.initialize(): + self.server.add_warning( + f"Sensor '{sensor.get_name()}' failed to initialize" + ) + + self.sensors_update_timer.start() + + except Exception as e: + logging.exception(e) + + async def _handle_sensor_list_request( + self, web_request: WebRequest + ) -> Dict[str, Dict[str, Any]]: + output = { + "sensors": { + key: sensor.get_sensor_info() for key, sensor in self.sensors.items() + } + } + return output + + async def _handle_sensor_info_request( + self, web_request: WebRequest + ) -> Dict[str, Any]: + sensor_name: str = web_request.get_str("sensor") + if sensor_name not in self.sensors: + raise self.server.error(f"No valid sensor named {sensor_name}") + + sensor = self.sensors[sensor_name] + + return sensor.get_sensor_info() + + async def _handle_sensor_measurements_request( + self, web_request: WebRequest + ) -> Dict[str, Dict[str, Any]]: + sensor_name: str = web_request.get_str("sensor", "") + if sensor_name and sensor_name not in self.sensors: + raise self.server.error(f"No valid sensor named {sensor_name}") + + output = { + key: sensor.get_sensor_measurements() + for key, sensor in self.sensors.items() + if sensor_name is "" or key == sensor_name + } + + return output + + def close(self) -> None: + self.sensors_update_timer.stop() + for sensor in self.sensors.values(): + sensor.close() + + +def load_component(config: ConfigHelper) -> Sensors: + return Sensors(config)