Skip to content

Commit 42813b7

Browse files
committed
feat(hass): support Zigbee2MQTT off-state updates via MQTT publish
1 parent 90ec00f commit 42813b7

3 files changed

Lines changed: 115 additions & 3 deletions

File tree

src/main/java/at/sv/hue/HueScheduler.java

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,6 +261,13 @@ public final class HueScheduler implements Runnable {
261261
description = "Disables certificate validation for older bridges using self-signed certificates." +
262262
" Default: ${DEFAULT-VALUE}")
263263
private boolean insecure;
264+
265+
@Option(names = "--z2m-base-topic", paramLabel = "<topic>",
266+
defaultValue = "${env:Z2M_BASE_TOPIC}",
267+
description = "The base topic for Zigbee2MQTT to enable silent updates for off lights. " +
268+
"If provided, Home Assistant will route off-state updates via MQTT. Example: zigbee2mqtt")
269+
String z2mBaseTopic;
270+
264271
private HueApi api;
265272
private StateScheduler stateScheduler;
266273
private final ManualOverrideTracker manualOverrideTracker;
@@ -398,7 +405,7 @@ private void setupHassApi() {
398405
HassAreaRegistry areaRegistry = new HassAreaRegistryImpl(
399406
new HassWebSocketClientImpl(websocketOrigin, accessToken, httpClient, 5));
400407
HassAvailabilityListener availabilityListener = new HassAvailabilityListener(this::clearCachesAndReSyncScenes);
401-
api = new HassApiImpl(apiHost, new HttpResourceProviderImpl(httpClient, maxConcurrentRequests), areaRegistry, availabilityListener, rateLimiter);
408+
api = new HassApiImpl(apiHost, new HttpResourceProviderImpl(httpClient, maxConcurrentRequests), areaRegistry, availabilityListener, rateLimiter, this.z2mBaseTopic);
402409
lightEventListener = createLightEventListener();
403410
sceneEventListener = new SceneEventListenerImpl(api, Ticker.systemTicker(),
404411
sceneActivationIgnoreWindowInSeconds,

src/main/java/at/sv/hue/api/hass/HassApiImpl.java

Lines changed: 59 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ public class HassApiImpl implements HueApi {
5555
private final HassAvailabilityListener availabilityListener;
5656
private final ObjectMapper mapper;
5757
private final String baseUrl;
58+
private final String z2mBaseTopic;
5859

5960
private final Object lightMapLock = new Object();
6061
private Map<String, State> availableStates;
@@ -63,12 +64,13 @@ public class HassApiImpl implements HueApi {
6364
private boolean nameToStatesMapInvalidated;
6465

6566
public HassApiImpl(String origin, HttpResourceProvider httpResourceProvider, HassAreaRegistry hassAreaRegistry,
66-
HassAvailabilityListener availabilityListener, RateLimiter rateLimiter) {
67+
HassAvailabilityListener availabilityListener, RateLimiter rateLimiter, String z2mBaseTopic) {
6768
baseUrl = origin + "/api";
6869
this.httpResourceProvider = httpResourceProvider;
6970
this.hassAreaRegistry = hassAreaRegistry;
7071
this.availabilityListener = availabilityListener;
7172
this.rateLimiter = rateLimiter;
73+
this.z2mBaseTopic = z2mBaseTopic;
7274
mapper = new ObjectMapper();
7375
mapper.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
7476
mapper.setDefaultPropertyInclusion(JsonInclude.Include.NON_NULL);
@@ -167,11 +169,61 @@ public void allowFastSceneUpdate(String groupId) {
167169
private void putStateInternal(PutCall putCall) {
168170
String id = putCall.getId();
169171
assertSupportedStateType(id);
172+
173+
// Determine if this is a silent attribute update (light is off, and we aren't explicitly turning it on)
174+
boolean isOffUpdate = (putCall.getOn() == null && isLightOff(id)) || Boolean.FALSE.equals(putCall.getOn());
175+
boolean hasAttributesToUpdate = putCall.getBri() != null || putCall.getCt() != null || (putCall.getX() != null && putCall.getY() != null);
176+
177+
if (isOffUpdate && hasAttributesToUpdate && this.z2mBaseTopic != null) {
178+
State state = getAndAssertLightExists(id);
179+
String friendlyName = state.getAttributes().getFriendly_name();
180+
181+
if (friendlyName != null) {
182+
publishZ2mMqttUpdate(putCall, friendlyName);
183+
184+
// If on is null, we only wanted a silent update. We can safely return.
185+
if (putCall.getOn() == null) {
186+
return;
187+
}
188+
// If putCall.getOn() == false, we fall through to let HA run the actual turn_off service
189+
}
190+
}
191+
170192
ChangeState changeState = getChangeState(putCall);
171193
changeState.setEntity_id(id);
172194
httpResourceProvider.postResource(getUpdateUrl(putCall), getBody(changeState));
173195
}
174196

197+
private void publishZ2mMqttUpdate(PutCall putCall, String friendlyName) {
198+
List<String> payloadParts = new ArrayList<>();
199+
200+
// Hardcode the null state to bypass JSON serializer dropping it
201+
payloadParts.add("\"state\":null");
202+
203+
if (putCall.getBri() != null) {
204+
payloadParts.add("\"brightness\":" + hueToHassBrightness(putCall.getBri()));
205+
}
206+
if (putCall.getCt() != null) {
207+
payloadParts.add("\"color_temp\":" + miredsToKelvin(putCall.getCt()));
208+
}
209+
if (putCall.getX() != null && putCall.getY() != null) {
210+
Double[] xy = getXyColor(putCall);
211+
payloadParts.add("\"color\":{\"x\":" + xy[0] + ",\"y\":" + xy[1] + "}");
212+
}
213+
if (putCall.getTransitionTime() != null) {
214+
payloadParts.add("\"transition\":" + convertToSeconds(putCall.getTransitionTime()));
215+
}
216+
217+
// Combine the parts into a raw JSON string
218+
String rawZ2mPayload = "{" + String.join(",", payloadParts) + "}";
219+
220+
MqttPublish publish = new MqttPublish();
221+
publish.setTopic(this.z2mBaseTopic + "/" + friendlyName + "/set");
222+
publish.setPayload(rawZ2mPayload);
223+
224+
httpResourceProvider.postResource(createUrl("/services/mqtt/publish"), getBody(publish));
225+
}
226+
175227
private ChangeState getChangeState(PutCall putCall) {
176228
ChangeState changeState = new ChangeState();
177229
changeState.setBrightness(hueToHassBrightness(putCall.getBri()));
@@ -641,4 +693,10 @@ private static final class CreateScene {
641693
String scene_id;
642694
Map<String, ChangeState> entities;
643695
}
696+
697+
@Data
698+
private static final class MqttPublish {
699+
String topic;
700+
String payload;
701+
}
644702
}

src/test/java/at/sv/hue/api/hass/HassApiTest.java

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import org.intellij.lang.annotations.Language;
2222
import org.junit.jupiter.api.BeforeEach;
2323
import org.junit.jupiter.api.Test;
24+
import org.mockito.ArgumentCaptor;
2425
import org.mockito.Mockito;
2526

2627
import java.net.MalformedURLException;
@@ -31,7 +32,9 @@
3132
import java.util.List;
3233

3334
import static org.assertj.core.api.Assertions.*;
35+
import static org.junit.jupiter.api.Assertions.assertTrue;
3436
import static org.mockito.ArgumentMatchers.*;
37+
import static org.mockito.Mockito.mock;
3538
import static org.mockito.Mockito.verify;
3639
import static org.mockito.Mockito.when;
3740

@@ -55,7 +58,7 @@ private void setupApi(String origin) {
5558
HassAvailabilityListener availabilityListener = new HassAvailabilityListener(() -> {
5659
});
5760
api = new HassApiImpl(origin, http, areaRegistry, availabilityListener, permits -> {
58-
});
61+
}, null);
5962
baseUrl = origin + "/api";
6063
}
6164

@@ -2770,6 +2773,50 @@ void putState_turnOn_fan_usesCorrectService() {
27702773
"{\"entity_id\":\"fan.test_fan\"}");
27712774
}
27722775

2776+
@Test
2777+
void testZ2mMqttPublish_whenLightIsOff_andAttributesChanged() {
2778+
// 1. Re-initialize the API for this specific test to include the "zigbee2mqtt" topic.
2779+
// We reuse 'http' and 'areaRegistry' mocks that are automatically created in @BeforeEach.
2780+
HassAvailabilityListener availabilityListener = new HassAvailabilityListener(() -> {});
2781+
api = new HassApiImpl("http://localhost:8123", http, areaRegistry, availabilityListener, permits -> {}, "zigbee2mqtt");
2782+
2783+
// 2. Use the existing test helper method to mock the state of the light as "off"
2784+
setGetResponse("/states", """
2785+
[
2786+
{
2787+
"entity_id": "light.my_z2m_bulb",
2788+
"state": "off",
2789+
"attributes": {
2790+
"friendly_name": "My Bulb",
2791+
"supported_features": 44
2792+
}
2793+
}
2794+
]
2795+
""");
2796+
2797+
// 3. Create a PutCall that updates brightness/color but DOES NOT turn the light on (on=null)
2798+
PutCall silentUpdate = PutCall.builder()
2799+
.id("light.my_z2m_bulb")
2800+
.bri(150)
2801+
.ct(300)
2802+
.build();
2803+
2804+
// 4. Trigger the putState call
2805+
api.putState(silentUpdate);
2806+
2807+
// 5. Verify the correct MQTT publish payload was sent (uses getUrl() to match the URL object)
2808+
org.mockito.ArgumentCaptor<String> bodyCaptor = org.mockito.ArgumentCaptor.forClass(String.class);
2809+
verify(http).postResource(eq(getUrl("/services/mqtt/publish")), bodyCaptor.capture());
2810+
2811+
// 6. Assert the JSON payload matches the required Z2M structure
2812+
String payload = bodyCaptor.getValue();
2813+
assertThat(payload).contains("\"topic\":\"zigbee2mqtt/My Bulb/set\"");
2814+
2815+
// Add extra slashes to account for the escaped nested JSON string
2816+
assertThat(payload).contains("\\\"state\\\":null");
2817+
assertThat(payload).contains("\\\"brightness\\\":");
2818+
}
2819+
27732820
@Test
27742821
void unsupportedType_exception() {
27752822
assertThatThrownBy(() -> getLightState("sensor.sun_next_setting")).isInstanceOf(UnsupportedStateException.class);

0 commit comments

Comments
 (0)