Procházet zdrojové kódy

Improvements in group 0 behavior (#426)

* add support for kelvin command
* Add helper for re-initing fields
* add methods for clearing fields
* Improve behavior with bulb mode
* Improve behavior with group 0 state
* Add tests
Chris Mullins před 6 roky
rodič
revize
743fc9f2a7

+ 3 - 0
lib/MiLight/MiLightClient.cpp

@@ -429,6 +429,9 @@ void MiLightClient::update(const JsonObject& request) {
   if (request.containsKey("temperature")) {
     this->updateTemperature(request["temperature"]);
   }
+  if (request.containsKey("kelvin")) {
+    this->updateTemperature(request["kelvin"]);
+  }
   // HomeAssistant
   if (request.containsKey("color_temp")) {
     this->updateTemperature(

+ 127 - 4
lib/MiLightState/GroupState.cpp

@@ -69,6 +69,15 @@ bool BulbId::operator==(const BulbId &other) {
 }
 
 GroupState::GroupState() {
+  initFields();
+}
+
+GroupState::GroupState(const JsonObject& jsonState) {
+  initFields();
+  patch(jsonState);
+}
+
+void GroupState::initFields() {
   state.fields._state                = 0;
   state.fields._brightness           = 0;
   state.fields._brightnessColor      = 0;
@@ -128,6 +137,64 @@ void GroupState::print(Stream& stream) const {
   stream.printf("State: %08X %08X\n", state.rawData[0], state.rawData[1]);
 }
 
+bool GroupState::clearField(GroupStateField field) {
+  bool clearedAny = false;
+
+  switch (field) {
+    // Always set and can't be cleared
+    case GroupStateField::COMPUTED_COLOR:
+    case GroupStateField::DEVICE_ID:
+    case GroupStateField::GROUP_ID:
+    case GroupStateField::DEVICE_TYPE:
+      break;
+
+    case GroupStateField::STATE:
+    case GroupStateField::STATUS:
+      clearedAny = isSetState();
+      state.fields._isSetState = 0;
+      break;
+
+    case GroupStateField::BRIGHTNESS:
+    case GroupStateField::LEVEL:
+      clearedAny = clearBrightness();
+      break;
+
+    case GroupStateField::COLOR:
+    case GroupStateField::HUE:
+    case GroupStateField::OH_COLOR:
+      clearedAny = isSetHue();
+      state.fields._isSetHue = 0;
+      break;
+
+    case GroupStateField::SATURATION:
+      clearedAny = isSetSaturation();
+      state.fields._isSetSaturation = 0;
+      break;
+
+    case GroupStateField::MODE:
+    case GroupStateField::EFFECT:
+      clearedAny = isSetMode();
+      state.fields._isSetMode = 0;
+      break;
+
+    case GroupStateField::KELVIN:
+    case GroupStateField::COLOR_TEMP:
+      clearedAny = isSetKelvin();
+      state.fields._isSetKelvin = 0;
+      break;
+
+    case GroupStateField::BULB_MODE:
+      clearedAny = isSetBulbMode();
+      state.fields._isSetBulbMode = 0;
+
+      // Clear brightness as well
+      clearedAny = clearBrightness() || clearedAny;
+      break;
+  }
+
+  return clearedAny;
+}
+
 bool GroupState::isSetField(GroupStateField field) const {
   switch (field) {
     case GroupStateField::COMPUTED_COLOR:
@@ -289,7 +356,11 @@ bool GroupState::setState(const MiLightStatus status) {
 }
 
 bool GroupState::isSetBrightness() const {
-  if (! state.fields._isSetBulbMode) {
+  // If we don't know what mode we're in, just assume white mode.  Do this for a few
+  // reasons:
+  //   * Some bulbs don't have multiple modes
+  //   * It's confusing to not have a default
+  if (! isSetBulbMode()) {
     return state.fields._isSetBrightness;
   }
 
@@ -304,6 +375,33 @@ bool GroupState::isSetBrightness() const {
 
   return false;
 }
+bool GroupState::clearBrightness() {
+  bool cleared = false;
+
+  if (!state.fields._isSetBulbMode) {
+    cleared = state.fields._isSetBrightness;
+    state.fields._isSetBrightness = 0;
+  } else {
+    switch (state.fields._bulbMode) {
+      case BULB_MODE_COLOR:
+        cleared = state.fields._isSetBrightnessColor;
+        state.fields._isSetBrightnessColor = 0;
+        break;
+
+      case BULB_MODE_SCENE:
+        cleared = state.fields._isSetBrightnessMode;
+        state.fields._isSetBrightnessMode = 0;
+        break;
+
+      case BULB_MODE_WHITE:
+        cleared = state.fields._isSetBrightness;
+        state.fields._isSetBrightness = 0;
+        break;
+    }
+  }
+
+  return cleared;
+}
 uint8_t GroupState::getBrightness() const {
   switch (state.fields._bulbMode) {
     case BULB_MODE_WHITE:
@@ -532,6 +630,31 @@ bool GroupState::applyIncrementCommand(GroupStateField field, IncrementDirection
   return false;
 }
 
+bool GroupState::clearNonMatchingFields(const GroupState& other) {
+#ifdef STATE_DEBUG
+  this->debugState("Clearing fields.  Current state");
+  other.debugState("Other state");
+#endif
+
+  bool clearedAny = false;
+
+  for (size_t i = 0; i < size(ALL_PHYSICAL_FIELDS); ++i) {
+    GroupStateField field = ALL_PHYSICAL_FIELDS[i];
+
+    if (other.isSetField(field) && isSetField(field) && getFieldValue(field) != other.getFieldValue(field)) {
+      if (clearField(field)) {
+        clearedAny = true;
+      }
+    }
+  }
+
+#ifdef STATE_DEBUG
+  this->debugState("Result");
+#endif
+
+  return clearedAny;
+}
+
 bool GroupState::patch(const GroupState& other) {
 #ifdef STATE_DEBUG
   other.debugState("Patching existing state with: ");
@@ -717,7 +840,7 @@ void GroupState::applyField(JsonObject& partialState, const BulbId& bulbId, Grou
       case GroupStateField::EFFECT:
         if (getBulbMode() == BULB_MODE_SCENE) {
           partialState["effect"] = String(getMode());
-        } else if (getBulbMode() == BULB_MODE_WHITE) {
+        } else if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) {
           partialState["effect"] = "white_mode";
         } else if (getBulbMode() == BULB_MODE_NIGHT) {
           partialState["effect"] = "night_mode";
@@ -725,13 +848,13 @@ void GroupState::applyField(JsonObject& partialState, const BulbId& bulbId, Grou
         break;
 
       case GroupStateField::COLOR_TEMP:
-        if (getBulbMode() == BULB_MODE_WHITE) {
+        if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) {
           partialState["color_temp"] = getMireds();
         }
         break;
 
       case GroupStateField::KELVIN:
-        if (getBulbMode() == BULB_MODE_WHITE) {
+        if (isSetBulbMode() && getBulbMode() == BULB_MODE_WHITE) {
           partialState["kelvin"] = getKelvin();
         }
         break;

+ 11 - 1
lib/MiLightState/GroupState.h

@@ -49,14 +49,19 @@ public:
   GroupState(const GroupState& other);
   GroupState& operator=(const GroupState& other);
 
+  // Convenience constructor that patches defaults with JSON state
+  GroupState(const JsonObject& jsonState);
+
+  void initFields();
+
   bool operator==(const GroupState& other) const;
   bool isEqualIgnoreDirty(const GroupState& other) const;
   void print(Stream& stream) const;
 
-
   bool isSetField(GroupStateField field) const;
   uint16_t getFieldValue(GroupStateField field) const;
   void setFieldValue(GroupStateField field, uint16_t value);
+  bool clearField(GroupStateField field);
 
   bool isSetScratchField(GroupStateField field) const;
   uint16_t getScratchFieldValue(GroupStateField field) const;
@@ -73,6 +78,7 @@ public:
   bool isSetBrightness() const;
   uint8_t getBrightness() const;
   bool setBrightness(uint8_t brightness);
+  bool clearBrightness();
 
   // 8 bits
   bool isSetHue() const;
@@ -115,6 +121,10 @@ public:
   inline bool setMqttDirty();
   bool clearMqttDirty();
 
+  // Clears all of the fields in THIS GroupState that have different values
+  // than the provided group state.
+  bool clearNonMatchingFields(const GroupState& other);
+
   // Patches this state with ONLY the set fields in the other. Returns 
   // true if there were any changes.
   bool patch(const GroupState& other);

+ 29 - 17
lib/MiLightState/GroupStateStore.cpp

@@ -10,26 +10,17 @@ GroupStateStore::GroupStateStore(const size_t maxSize, const size_t flushRate)
 GroupState* GroupStateStore::get(const BulbId& id) {
   GroupState* state = cache.get(id);
 
-  // Always force re-initialization of group 0 state
-  if (id.groupId == 0 || state == NULL) {
+  if (state == NULL) {
     trackEviction();
     GroupState loadedState = GroupState::defaultState(id.deviceType);
 
-    // For device types with groups, group 0 is a "virtual" group.  All devices paired with the same ID will respond
-    // to group 0.  So it doesn't make sense to store group 0 state by itself.
-    //
-    // For devices that don't have groups, we made the unfortunate decision to represent state using the fake group
-    // ID 0, so we can't always ignore group 0.
     const MiLightRemoteConfig* remoteConfig = MiLightRemoteConfig::fromType(id.deviceType);
 
     if (remoteConfig == NULL) {
       return NULL;
     }
 
-    if (id.groupId != 0 || remoteConfig->numGroups == 0) {
-      persistence.get(id, loadedState);
-    }
-
+    persistence.get(id, loadedState);
     state = cache.set(id, loadedState);
   }
 
@@ -41,15 +32,23 @@ GroupState* GroupStateStore::get(const uint16_t deviceId, const uint8_t groupId,
   return get(bulbId);
 }
 
-// save state for a bulb.  If id.groupId == 0, will iterate across all groups
-// and individually save each group (recursively)
+// Save state for a bulb.
+//
+// Notes:
+//
+// * For device types with groups, group 0 is a "virtual" group.  All devices paired with the same ID will 
+//   respond to group 0.  When state for an individual (i.e., != 0) group is changed, the state for 
+//   group 0 becomes out of sync and should be cleared.
+//
+// * If id.groupId == 0, will iterate across all groups and individually save each group (recursively)
+//
 GroupState* GroupStateStore::set(const BulbId &id, const GroupState& state) {
+  BulbId otherId(id);
   GroupState* storedState = get(id);
-  *storedState = state;
+  storedState->patch(state);
 
   if (id.groupId == 0) {
     const MiLightRemoteConfig* remote = MiLightRemoteConfig::fromType(id.deviceType);
-    BulbId individualBulb(id);
 
 #ifdef STATE_DEBUG
     Serial.printf_P(PSTR("Fanning out group 0 state for device ID 0x%04X (%d groups in total)\n"), id.deviceId, remote->numGroups);
@@ -57,11 +56,16 @@ GroupState* GroupStateStore::set(const BulbId &id, const GroupState& state) {
 #endif
 
     for (size_t i = 1; i <= remote->numGroups; i++) {
-      individualBulb.groupId = i;
+      otherId.groupId = i;
 
-      GroupState* individualState = get(individualBulb);
+      GroupState* individualState = get(otherId);
       individualState->patch(state);
     }
+  } else {
+    otherId.groupId = 0;
+    GroupState* group0State = get(otherId);
+
+    group0State->clearNonMatchingFields(state);
   }
   
   return storedState;
@@ -72,6 +76,14 @@ GroupState* GroupStateStore::set(const uint16_t deviceId, const uint8_t groupId,
   return set(bulbId, state);
 }
 
+void GroupStateStore::clear(const BulbId& bulbId) {
+  GroupState* state = get(bulbId);
+
+  if (state != NULL) {
+    state->initFields();
+  }
+}
+
 void GroupStateStore::trackEviction() {
   if (cache.isFull()) {
     evictedIds.add(cache.getLru());

+ 2 - 0
lib/MiLightState/GroupStateStore.h

@@ -26,6 +26,8 @@ public:
   GroupState* set(const BulbId& id, const GroupState& state);
   GroupState* set(const uint16_t deviceId, const uint8_t groupId, const MiLightRemoteType deviceType, const GroupState& state);
 
+  void clear(const BulbId& id);
+
   /*
    * Flushes all states to persistent storage.  Returns true iff anything was
    * flushed.

+ 19 - 0
lib/WebServer/MiLightHttpServer.cpp

@@ -27,6 +27,7 @@ void MiLightHttpServer::begin() {
   const char groupPattern[] = "/gateways/:device_id/:type/:group_id";
   server.onPatternAuthenticated(groupPattern, HTTP_PUT, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); });
   server.onPatternAuthenticated(groupPattern, HTTP_POST, [this](const UrlTokenBindings* b) { handleUpdateGroup(b); });
+  server.onPatternAuthenticated(groupPattern, HTTP_DELETE, [this](const UrlTokenBindings* b) { handleDeleteGroup(b); });
   server.onPatternAuthenticated(groupPattern, HTTP_GET, [this](const UrlTokenBindings* b) { handleGetGroup(b); });
 
   server.onPatternAuthenticated("/raw_commands/:type", HTTP_ANY, [this](const UrlTokenBindings* b) { handleSendRaw(b); });
@@ -353,6 +354,24 @@ void MiLightHttpServer::handleGetGroup(const UrlTokenBindings* urlBindings) {
   sendGroupState(bulbId, stateStore->get(bulbId));
 }
 
+void MiLightHttpServer::handleDeleteGroup(const UrlTokenBindings* urlBindings) {
+  const String _deviceId = urlBindings->get("device_id");
+  uint8_t _groupId = atoi(urlBindings->get("group_id"));
+  const MiLightRemoteConfig* _remoteType = MiLightRemoteConfig::fromType(urlBindings->get("type"));
+
+  if (_remoteType == NULL) {
+    char buffer[40];
+    sprintf_P(buffer, PSTR("Unknown device type\n"));
+    server.send(400, TEXT_PLAIN, buffer);
+    return;
+  }
+
+  BulbId bulbId(parseInt<uint16_t>(_deviceId), _groupId, _remoteType->type);
+  stateStore->clear(bulbId);
+
+  server.send_P(200, APPLICATION_JSON, PSTR("true"));
+}
+
 void MiLightHttpServer::handleUpdateGroup(const UrlTokenBindings* urlBindings) {
   DynamicJsonBuffer buffer;
   JsonObject& request = buffer.parse(server.arg("plain"));

+ 1 - 0
lib/WebServer/MiLightHttpServer.h

@@ -57,6 +57,7 @@ protected:
   void handleListenGateway(const UrlTokenBindings* urlBindings);
   void handleSendRaw(const UrlTokenBindings* urlBindings);
   void handleUpdateGroup(const UrlTokenBindings* urlBindings);
+  void handleDeleteGroup(const UrlTokenBindings* urlBindings);
   void handleGetGroup(const UrlTokenBindings* urlBindings);
 
   void handleRequest(const JsonObject& request);

+ 2 - 1
platformio.ini

@@ -24,7 +24,8 @@ lib_deps_external =
   CircularBuffer@~1.2.0
 extra_scripts =
   pre:.build_web.py
-build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -DHTTP_UPLOAD_BUFLEN=128 -D FIRMWARE_NAME=milight-hub -Idist -Ilib/DataStructures
+build_flags = !python .get_version.py -DMQTT_MAX_PACKET_SIZE=200 -DHTTP_UPLOAD_BUFLEN=128 -D FIRMWARE_NAME=milight-hub -Idist -Ilib/DataStructures 
+# -D STATE_DEBUG
 # -D DEBUG_PRINTF
 # -D MQTT_DEBUG
 # -D MILIGHT_UDP_DEBUG

+ 3 - 2
src/main.cpp

@@ -109,12 +109,13 @@ void onPacketSentHandler(uint8_t* packet, const MiLightRemoteConfig& config) {
 
   // update state to reflect changes from this packet
   GroupState* groupState = stateStore->get(bulbId);
+  const GroupState stateUpdates(result);
 
   if (groupState != NULL) {
-    groupState->patch(result);
+    groupState->patch(stateUpdates);
 
     // Copy state before setting it to avoid group 0 re-initialization clobbering it
-    stateStore->set(bulbId, GroupState(*groupState));
+    stateStore->set(bulbId, stateUpdates);
   }
 
   if (mqttClient) {

+ 6 - 0
test/remote/helpers/state_helpers.rb

@@ -0,0 +1,6 @@
+module StateHelpers
+  def states_are_equal(desired_state, retrieved_state)
+    expect(retrieved_state).to include(*desired_state.keys)
+    expect(retrieved_state.select { |x| desired_state.include?(x) } ).to eq(desired_state)
+  end
+end

+ 14 - 2
test/remote/lib/api_client.rb

@@ -74,11 +74,23 @@ class ApiClient
     request(:Post, path, body)
   end
 
+  def delete(path)
+    request(:Delete, path)
+  end
+
+  def state_path(params = {})
+    "/gateways/#{params[:id]}/#{params[:type]}/#{params[:group_id]}"
+  end
+
+  def delete_state(params = {})
+    delete(state_path(params))
+  end
+
   def get_state(params = {})
-    get("/gateways/#{params[:id]}/#{params[:type]}/#{params[:group_id]}")
+    get(state_path(params))
   end
 
   def patch_state(state, params = {})
-    put("/gateways/#{params[:id]}/#{params[:type]}/#{params[:group_id]}", state.to_json)
+    put(state_path(params), state.to_json)
   end
 end

+ 131 - 22
test/remote/spec/state_spec.rb

@@ -1,4 +1,9 @@
 require 'api_client'
+require './helpers/state_helpers'
+
+RSpec.configure do |c|
+  c.include StateHelpers
+end
 
 RSpec.describe 'State' do
   before(:all) do
@@ -12,6 +17,7 @@ RSpec.describe 'State' do
       type: 'rgb_cct',
       group_id: 1
     }
+    @client.delete_state(@id_params)
   end
 
   context 'toggle command' do
@@ -32,6 +38,25 @@ RSpec.describe 'State' do
     end
   end
 
+  context 'deleting' do
+    it 'should support deleting state' do
+      desired_state = {
+        'status' => 'ON',
+        'level' => 10,
+        'hue' => 49,
+        'saturation' => 20
+      }
+      @client.patch_state(desired_state, @id_params)
+
+      resulting_state = @client.get_state(@id_params)
+      expect(resulting_state).to_not be_empty
+
+      @client.delete_state(@id_params)
+      resulting_state = @client.get_state(@id_params)
+      expect(resulting_state).to be_empty
+    end
+  end
+
   context 'persistence' do
     it 'should persist parameters' do
       desired_state = {
@@ -43,8 +68,7 @@ RSpec.describe 'State' do
       @client.patch_state(desired_state, @id_params)
       patched_state = @client.get_state(@id_params)
 
-      expect(patched_state.keys).to include(*desired_state.keys)
-      expect(patched_state.select { |x| desired_state.include?(x) } ).to eq(desired_state)
+      states_are_equal(desired_state, patched_state)
 
       desired_state = {
         'status' => 'ON',
@@ -55,8 +79,7 @@ RSpec.describe 'State' do
       @client.patch_state(desired_state, @id_params)
       patched_state = @client.get_state(@id_params)
 
-      expect(patched_state.keys).to include(*desired_state.keys)
-      expect(patched_state.select { |x| desired_state.include?(x) } ).to eq(desired_state)
+      states_are_equal(desired_state, patched_state)
     end
 
     it 'should affect member groups when changing group 0' do
@@ -75,35 +98,121 @@ RSpec.describe 'State' do
       patched_state = @client.patch_state(individual_state, @id_params)
 
       expect(patched_state).to_not eq(desired_state)
-      expect(patched_state.keys).to include(*individual_state.keys)
-      expect(patched_state.select { |x| individual_state.include?(x) } ).to eq(individual_state)
+      states_are_equal(individual_state, patched_state)
 
       group_4_state = @client.get_state(group_0_params.merge(group_id: 4))
 
-      expect(group_4_state.keys).to include(*desired_state.keys)
-      expect(group_4_state.select { |x| desired_state.include?(x) } ).to eq(desired_state)
+      states_are_equal(desired_state, group_4_state)
 
       @client.patch_state(desired_state, group_0_params)
       group_1_state = @client.get_state(group_0_params.merge(group_id: 1))
 
-      expect(group_1_state.keys).to include(*desired_state.keys)
-      expect(group_1_state.select { |x| desired_state.include?(x) } ).to eq(desired_state)
+      states_are_equal(desired_state, group_1_state)
     end
 
-    # it 'should keep group 0 state' do
-    #   group_0_params = @id_params.merge(group_id: 0)
-    #   desired_state = {
-    #     'status' => 'ON',
-    #     'level' => 100,
-    #     'hue' => 0,
-    #     'saturation' => 100
-    #   }
+    it 'should keep group 0 state' do
+      group_0_params = @id_params.merge(group_id: 0)
+      desired_state = {
+        'status' => 'ON',
+        'level' => 100,
+        'hue' => 0,
+        'saturation' => 100
+      }
+
+      patched_state = @client.patch_state(desired_state, group_0_params)
+
+      states_are_equal(desired_state, patched_state)
+    end
 
-    #   patched_state = @client.patch_state(desired_state, group_0_params)
+    it 'should clear group 0 state after member group state changes' do
+      group_0_params = @id_params.merge(group_id: 0)
+      desired_state = {
+        'status' => 'ON',
+        'level' => 100,
+        'kelvin' => 100
+      }
 
-    #   expect(patched_state.keys).to include(*desired_state.keys)
-    #   expect(patched_state.select { |x| desired_state.include?(x) } ).to eq(desired_state)
-    # end
+      @client.patch_state(desired_state, group_0_params)
+      @client.patch_state(desired_state.merge('kelvin' => 10), @id_params)
+
+      resulting_state = @client.get_state(group_0_params)
+
+      expect(resulting_state.keys).to_not include('kelvin')
+      states_are_equal(desired_state.reject { |x| x == 'kelvin' }, resulting_state)
+    end
+
+    it 'should not clear group 0 state when updating member group state if value is the same' do
+      group_0_params = @id_params.merge(group_id: 0)
+      desired_state = {
+        'status' => 'ON',
+        'level' => 100,
+        'kelvin' => 100
+      }
+
+      @client.patch_state(desired_state, group_0_params)
+      @client.patch_state(desired_state.merge('kelvin' => 100), @id_params)
+
+      resulting_state = @client.get_state(group_0_params)
+
+      expect(resulting_state).to include('kelvin')
+      states_are_equal(desired_state, resulting_state)
+    end
+
+    it 'changing member state mode and then changing level should preserve group 0 brightness for original mode' do
+      group_0_params = @id_params.merge(group_id: 0)
+      desired_state = {
+        'status' => 'ON',
+        'level' => 100,
+        'hue' => 0,
+        'saturation' => 100
+      }
+
+      @client.delete_state(group_0_params)
+      @client.patch_state(desired_state, group_0_params)
+
+      # color -> white mode.  should not have brightness because brightness will
+      # have been previously unknown to group 0.
+      @client.patch_state(desired_state.merge('color_temp' => 253, 'level' => 11), @id_params)
+      resulting_state = @client.get_state(group_0_params)
+      expect(resulting_state.keys).to_not include('level')
+
+      # color -> effect mode.  same as above
+      @client.patch_state(desired_state, group_0_params)
+      @client.patch_state(desired_state.merge('mode' => 0), @id_params)
+      resulting_state = @client.get_state(group_0_params)
+      expect(resulting_state).to_not include('level')
+
+      # white mode -> color.  
+      white_mode_desired_state = {'status' => 'ON', 'color_temp' => 253, 'level' => 11}
+      @client.patch_state(white_mode_desired_state, group_0_params)
+      @client.patch_state({'hue' => 10}, @id_params)
+      resulting_state = @client.get_state(group_0_params)
+      expect(resulting_state).to_not include('level')
+
+      @client.patch_state({'hue' => 10}, group_0_params)
+      resulting_state = @client.get_state(group_0_params)
+      expect(resulting_state['level']).to eq(100)
+
+      # white mode -> effect mode.  level never set for group 0, so level should
+      # level should be present.
+      @client.patch_state(white_mode_desired_state, group_0_params)
+      @client.patch_state({'mode' => 0}, @id_params)
+      resulting_state = @client.get_state(group_0_params)
+      expect(resulting_state).to_not include('level')
+
+      # effect mode -> color.  same as white mode -> color
+      effect_mode_desired_state = {'status' => 'ON', 'mode' => 0, 'level' => 100}
+      @client.patch_state(effect_mode_desired_state, group_0_params)
+      @client.patch_state({'hue' => 10}, @id_params)
+      resulting_state = @client.get_state(group_0_params)
+      expect(resulting_state).to_not include('level')
+
+      # effect mode -> white
+      @client.patch_state(effect_mode_desired_state, group_0_params)
+      @client.patch_state({'color_temp' => 253}, @id_params)
+      resulting_state = @client.get_state(group_0_params)
+      expect(resulting_state).to_not include('level')
+    end
   end
 
   context 'fields' do