"""Creates and maintains Prometheus metric values.""" import logging from libpurecool import const, dyson_pure_state, dyson_pure_state_v2 from prometheus_client import Gauge, Enum, REGISTRY # An astute reader may notice this value seems to be slightly wrong. # The definition is 0 K = -273.15 C, but it appears Dyson use this # slightly rounded value instead. KELVIN_TO_CELSIUS = -273 def enum_values(cls): return [x.value for x in list(cls)] def update_gauge(gauge, name: str, serial: str, value): gauge.labels(name=name, serial=serial).set(value) def update_enum(enum, name: str, serial: str, state): enum.labels(name=name, serial=serial).state(state) class Metrics: """Registers/exports and updates Prometheus metrics for DysonLink fans.""" def __init__(self, registry=REGISTRY): labels = ['name', 'serial'] def make_gauge(name, documentation): return Gauge(name, documentation, labels, registry=registry) def make_enum(name, documentation, state_cls): return Enum(name, documentation, labels, states=enum_values(state_cls), registry=registry) # Environmental Sensors (v1 & v2 common) self.humidity = make_gauge( 'dyson_humidity_percent', 'Relative humidity (percentage)') self.temperature = make_gauge( 'dyson_temperature_celsius', 'Ambient temperature (celsius)') self.voc = make_gauge( 'dyson_volatile_organic_compounds_units', 'Level of Volatile organic compounds') # Environmental Sensors (v1 units only) self.dust = make_gauge('dyson_dust_units', 'Level of Dust (V1 units only)') # Environmental Sensors (v2 units only) # Not included: p10r and p25r as they are marked as "unknown" in libpurecool. self.pm25 = make_gauge( 'dyson_pm25_units', 'Level of PM2.5 particulate matter (V2 units only)') self.pm10 = make_gauge( 'dyson_pm10_units', 'Level of PM10 particulate matter (V2 units only)') self.nox = make_gauge('dyson_nitrogen_oxide_units', 'Level of nitrogen oxides (NOx, V2 units only)') # Operational State (v1 & v2 common) # Not included: tilt (known values: "OK", others?), standby_monitoring. # Synthesised: fan_mode (for V2), fan_power & auto_mode (for V1) self.fan_mode = make_enum( 'dyson_fan_mode', 'Current mode of the fan', const.FanMode) self.fan_power = make_enum( 'dyson_fan_power_mode', 'Current power mode of the fan (like fan_mode but binary)', const.FanPower) self.auto_mode = make_enum( 'dyson_fan_auto_mode', 'Current auto mode of the fan (like fan_mode but binary)', const.AutoMode) self.fan_state = make_enum( 'dyson_fan_state', 'Current running state of the fan', const.FanState) self.fan_speed = make_gauge( 'dyson_fan_speed_units', 'Current speed of fan (-1 = AUTO)') self.oscillation = make_enum( 'dyson_oscillation_mode', 'Current oscillation mode', const.Oscillation) self.night_mode = make_enum( 'dyson_night_mode', 'Night mode', const.NightMode) self.heat_mode = make_enum( 'dyson_heat_mode', 'Current heat mode', const.HeatMode) self.heat_state = make_enum( 'dyson_heat_state', 'Current heat state', const.HeatState) self.heat_target = make_gauge( 'dyson_heat_target_celsius', 'Heat target temperature (celsius)') # Operational State (v1 only) self.focus_mode = make_enum( 'dyson_focus_mode', 'Current focus mode (V1 units only)', const.FocusMode) self.quality_target = make_gauge( 'dyson_quality_target_units', 'Quality target for fan (V1 units only)') self.filter_life = make_gauge( 'dyson_filter_life_seconds', 'Remaining HEPA filter life (seconds, V1 units only)') # Operational State (v2 only) # Not included: oscillation (known values: "ON", "OFF", "OION", "OIOF") using oscillation_state instead self.continuous_monitoring = make_enum( 'dyson_continuous_monitoring_mode', 'Monitor air quality continuously (V2 units only)', const.ContinuousMonitoring) self.carbon_filter_life = make_gauge( 'dyson_carbon_filter_life_percent', 'Percent remaining of carbon filter (V2 units only)') self.hepa_filter_life = make_gauge( 'dyson_hepa_filter_life_percent', 'Percent remaining of HEPA filter (V2 units only)') self.night_mode_speed = make_gauge( 'dyson_night_mode_fan_speed_units', 'Night mode fan speed (V2 units only)') self.oscillation_angle_low = make_gauge( 'dyson_oscillation_angle_low_degrees', 'Low oscillation angle (V2 units only)') self.oscillation_angle_high = make_gauge( 'dyson_oscillation_angle_high_degrees', 'High oscillation angle (V2 units only)') self.dyson_front_direction_mode = make_enum( 'dyson_front_direction_mode', 'Airflow direction from front (V2 units only)', const.FrontalDirection) def update(self, name: str, serial: str, message: object) -> None: """Receives device/environment state and updates Prometheus metrics. Args: name: (str) Name of device. serial: (str) Serial number of device. message: must be one of a DysonEnvironmentalSensor{,V2}State, DysonPureHotCool{,V2}State or DysonPureCool{,V2}State. """ if not name or not serial: logging.error( 'Ignoring update with name=%s, serial=%s', name, serial) logging.debug('Received update for %s (serial=%s): %s', name, serial, message) if isinstance(message, dyson_pure_state.DysonEnvironmentalSensorState): self.updateEnvironmentalState(name, serial, message) elif isinstance(message, dyson_pure_state_v2.DysonEnvironmentalSensorV2State): self.updateEnvironmentalV2State(name, serial, message) elif isinstance(message, dyson_pure_state.DysonPureCoolState): self.updatePureCoolState(name, serial, message) elif isinstance(message, dyson_pure_state_v2.DysonPureCoolV2State): self.updatePureCoolV2State(name, serial, message) else: logging.warning('Received unknown update from "%s" (serial=%s): %s; ignoring', name, serial, type(message)) def updateEnviromentalStateCommon(self, name: str, serial: str, message): temp = round(message.temperature + KELVIN_TO_CELSIUS, 1) update_gauge(self.humidity, name, serial, message.humidity) update_gauge(self.temperature, name, serial, temp) def updateEnvironmentalState(self, name: str, serial: str, message: dyson_pure_state.DysonEnvironmentalSensorState): self.updateEnviromentalStateCommon(name, serial, message) update_gauge(self.dust, name, serial, message.dust) update_gauge(self.voc, name, serial, message.volatil_organic_compounds) def updateEnvironmentalV2State(self, name: str, serial: str, message: dyson_pure_state_v2.DysonEnvironmentalSensorV2State): self.updateEnviromentalStateCommon(name, serial, message) update_gauge(self.pm25, name, serial, message.particulate_matter_25) update_gauge(self.pm10, name, serial, message.particulate_matter_10) # Previously, Dyson normalised the VOC range from [0,10]. Issue #5 # discovered on V2 devices, the range is [0, 100]. NOx seems to be # similarly ranged. For compatibility and consistency we rerange the values # values to the original [0,10]. voc = message.volatile_organic_compounds/10 nox = message.nitrogen_dioxide/10 update_gauge(self.voc, name, serial, voc) update_gauge(self.nox, name, serial, nox) def updateHeatStateCommon(self, name: str, serial: str, message): # Convert from Decikelvin to to Celsius. heat_target = round(int(message.heat_target) / 10 + KELVIN_TO_CELSIUS, 1) update_enum(self.heat_mode, name, serial, message.heat_mode) update_enum(self.heat_state, name, serial, message.heat_state) update_gauge(self.heat_target, name, serial, heat_target) def updatePureCoolStateCommon(self, name: str, serial: str, message): update_enum(self.fan_state, name, serial, message.fan_state) update_enum(self.night_mode, name, serial, message.night_mode) # The API can return 'AUTO' rather than a speed when the device is in # automatic mode. Provide -1 to keep it an int. speed = message.speed if speed == 'AUTO': speed = -1 update_gauge(self.fan_speed, name, serial, speed) def updatePureCoolState(self, name: str, serial: str, message: dyson_pure_state.DysonPureCoolState): self.updatePureCoolStateCommon(name, serial, message) update_enum(self.fan_mode, name, serial, message.fan_mode) update_enum(self.oscillation, name, serial, message.oscillation) update_gauge(self.quality_target, name, serial, message.quality_target) # Synthesize compatible values for V2-originated metrics: auto = const.AutoMode.AUTO_OFF.value power = const.FanPower.POWER_OFF.value if message.fan_mode == const.FanMode.AUTO.value: auto = const.AutoMode.AUTO_ON.value if message.fan_mode in (const.FanMode.AUTO.value, const.FanMode.FAN.value): power = const.FanPower.POWER_ON.value update_enum(self.auto_mode, name, serial, auto) update_enum(self.fan_power, name, serial, power) # Convert filter_life from hours to seconds. filter_life = int(message.filter_life) * 60 * 60 update_gauge(self.filter_life, name, serial, filter_life) # Metrics only available with DysonPureHotCoolState if isinstance(message, dyson_pure_state.DysonPureHotCoolState): self.updateHeatStateCommon(name, serial, message) update_enum(self.focus_mode, name, serial, message.focus_mode) def updatePureCoolV2State(self, name: str, serial: str, message: dyson_pure_state_v2.DysonPureCoolV2State): self.updatePureCoolStateCommon(name, serial, message) update_enum(self.fan_power, name, serial, message.fan_power) update_enum(self.continuous_monitoring, name, serial, message.continuous_monitoring) update_enum(self.dyson_front_direction_mode, name, serial, message.front_direction) update_gauge(self.carbon_filter_life, name, serial, int(message.carbon_filter_state)) update_gauge(self.hepa_filter_life, name, serial, int(message.hepa_filter_state)) update_gauge(self.night_mode_speed, name, serial, int(message.night_mode_speed)) # V2 provides oscillation_status and oscillation as fields, # oscillation_status provides values compatible with V1, so we use that. # oscillation returns as 'OION', 'OIOF.' update_enum(self.oscillation, name, serial, message.oscillation_status) update_gauge(self.oscillation_angle_low, name, serial, int(message.oscillation_angle_low)) update_gauge(self.oscillation_angle_high, name, serial, int(message.oscillation_angle_high)) # Maintain compatibility with the V1 fan metrics. fan_mode = const.FanMode.OFF.value if message.auto_mode == const.AutoMode.AUTO_ON.value: fan_mode = 'AUTO' elif message.fan_power == const.FanPower.POWER_ON.value: fan_mode = 'FAN' else: logging.warning('Received unknown fan_power setting from "%s" (serial=%s): %s, defaulting to "%s', name, serial, message.fan_mode, fan_mode) update_enum(self.fan_mode, name, serial, fan_mode) if isinstance(message, dyson_pure_state_v2.DysonPureHotCoolV2State): self.updateHeatStateCommon(name, serial, message)