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 <mjonuschat+moonraker@gmail.com>
This commit is contained in:
Morton Jonuschat 2023-02-20 14:43:41 -08:00 committed by GitHub
parent 0a811b9e44
commit b2ba52ce3d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 586 additions and 4 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -0,0 +1,309 @@
# Generic sensor support
#
# Copyright (C) 2022 Morton Jonuschat <mjonuschat+moonraker@gmail.com>
#
# 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)