From 02f3adf27e71b1c1a07aaeadb8c4ca77c651f14a Mon Sep 17 00:00:00 2001
From: Sean Rees <sean@erifax.org>
Date: Tue, 12 Jan 2021 09:31:24 +0000
Subject: [PATCH] Handle oscillation and oscillation_state differently on V2

Thanks to @Scaredycrow for finding this issue. It appears that V2
devices emit two states: oscillation (the configured mode; this is
the intent) and oscillation_state (what the fan is currently doing).
In most cases, these should be equivalent -- though the fan will
report "IDLE" if the fan is intended to oscillate but has met its
air quality/heat target & is thusly turned off.

To make this work: dyson_oscillation_mode now reflects the intended
mode (which is no change for V1 and not a significant change for V2),
and a new metric: dyson_oscillation_state covers the possibility of
IDLE. I also added code for V1 to simulate the V2 behaviour for
consistency between the model generations.
---
 README.md  |  3 ++-
 metrics.py | 45 +++++++++++++++++++++++++++++++++++++++------
 2 files changed, 41 insertions(+), 7 deletions(-)

diff --git a/README.md b/README.md
index 0b0e0d9..50878c6 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,8 @@ dyson_fan_power | enum | all | ON if the fan is powered on, OFF otherwise
 dyson_auto_mode | enum | all | ON if the fan is in auto mode, OFF otherwise
 dyson_fan_state | enum | all |  FAN, OFF (what the fan is actually doing)
 dyson_fan_speed_units | gauge | all | 0-10 (or -1 if on AUTO)
-dyson_oscillation_mode | enum | all |  ON if the fan is oscillating, OFF otherwise
+dyson_oscillation_mode | enum | all |  ON if the fan in oscillation mode, OFF otherwise
+dyson_oscillation_state | enum | all | ON, OFF, IDLE. ON means the fan is currently oscillating, IDLE means the fan is in auto mode and the fan is paused
 dyson_oscillation_angle_low_degrees | gauge | V2 fans only | low angle of oscillation in degrees
 dyson_oscillation_angle_high_degrees | gauge | V2 fans only | high angle of oscillation in degrees
 dyson_night_mode | enum | all | ON if the fan is in night mode, OFF otherwise
diff --git a/metrics.py b/metrics.py
index 8d3f997..7b1e733 100644
--- a/metrics.py
+++ b/metrics.py
@@ -1,5 +1,6 @@
 """Creates and maintains Prometheus metric values."""
 
+import enum
 import logging
 
 from libpurecool import const, dyson_pure_state, dyson_pure_state_v2
@@ -24,6 +25,13 @@ def update_enum(enum, name: str, serial: str, state):
     enum.labels(name=name, serial=serial).state(state)
 
 
+class _OscillationState(enum.Enum):
+    """On V2 devices, oscillation_status can return 'IDLE' in auto mode."""
+    ON = 'ON'
+    OFF = 'OFF'
+    IDLE = 'IDLE'
+
+
 class Metrics:
     """Registers/exports and updates Prometheus metrics for DysonLink fans."""
 
@@ -71,7 +79,9 @@ class Metrics:
         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)
+            'dyson_oscillation_mode', 'Current oscillation mode (will the fan move?)', const.Oscillation)
+        self.oscillation_state = make_enum(
+            'dyson_oscillation_state', 'Current oscillation state (is the fan moving?)', _OscillationState)
         self.night_mode = make_enum(
             'dyson_night_mode', 'Night mode', const.NightMode)
         self.heat_mode = make_enum(
@@ -188,7 +198,6 @@ class Metrics:
         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)
 
@@ -203,6 +212,14 @@ class Metrics:
         update_enum(self.auto_mode, name, serial, auto)
         update_enum(self.fan_power, name, serial, power)
 
+        oscillation_state = message.oscillation
+        if message.fan_mode == const.FanMode.AUTO.value and message.fan_state == const.FanState.FAN_OFF:
+            # Compatibility with V2's behaviour for this value.
+            oscillation_state = _OscillationState.IDLE.value
+
+        update_enum(self.oscillation, name, serial, message.oscillation)
+        update_enum(self.oscillation_state, name, serial, oscillation_state)
+
         # Convert filter_life from hours to seconds.
         filter_life = int(message.filter_life) * 60 * 60
         update_gauge(self.filter_life, name, serial, filter_life)
@@ -228,10 +245,26 @@ class Metrics:
         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,
+        # V2 devices differentiate between _current_ oscillation status ('on', 'off', 'idle')
+        # and configured mode ('on', 'off'). This is roughly the difference between
+        # "is it oscillating right now" (oscillation_status) and "will it oscillate" (oscillation).
+        #
+        # This issue https://github.com/etheralm/libpurecool/issues/4#issuecomment-563358021
+        # seems to indicate that oscillation can be one of the OscillationV2 values (OION, OIOF)
+        # or one of the Oscillation values (ON, OFF) -- so support and translate both.
+        v2_to_v1_map = {
+            const.OscillationV2.OSCILLATION_ON.value: const.Oscillation.OSCILLATION_ON.value,
+            const.OscillationV2.OSCILLATION_OFF.value: const.Oscillation.OSCILLATION_OFF.value,
+            const.Oscillation.OSCILLATION_ON.value: const.Oscillation.OSCILLATION_ON.value,
+            const.Oscillation.OSCILLATION_OFF.value: const.Oscillation.OSCILLATION_OFF.value
+        }
+        oscillation = v2_to_v1_map.get(message.oscillation, None)
+        if oscillation:
+            update_enum(self.oscillation, name, serial, oscillation)
+        else:
+            logging.warning('Received unknown oscillation setting from "%s" (serial=%s): %s; ignoring',
+                            name, serial, message.oscillation)
+        update_enum(self.oscillation_state, name, serial,
                     message.oscillation_status)
         update_gauge(self.oscillation_angle_low, name,
                      serial, int(message.oscillation_angle_low))
-- 
GitLab