From 6398651cebeaa71e9e2df547230a8c7fc968b9f4 Mon Sep 17 00:00:00 2001 From: Cody Cutrer Date: Mon, 2 Dec 2024 22:10:44 -0700 Subject: [PATCH] [mqtt.homeassistant] Implement Device Tracker (#17831) * [mqtt.homeassistant] implement Device Tracker Signed-off-by: Cody Cutrer Signed-off-by: Ciprian Pascu --- .../README.md | 11 + .../internal/ComponentChannelType.java | 4 +- .../internal/component/AbstractComponent.java | 4 +- .../internal/component/ComponentFactory.java | 2 + .../internal/component/DeviceTracker.java | 232 +++++++++++++++ .../resources/OH-INF/i18n/mqtt.properties | 3 + .../OH-INF/thing/homeassistant-channels.xml | 14 + .../component/DeviceTrackerTests.java | 274 ++++++++++++++++++ 8 files changed, 542 insertions(+), 2 deletions(-) create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTracker.java create mode 100644 bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrackerTests.java diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/README.md b/bundles/org.openhab.binding.mqtt.homeassistant/README.md index 355045262e5..38e1cfc3c6a 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/README.md +++ b/bundles/org.openhab.binding.mqtt.homeassistant/README.md @@ -72,6 +72,17 @@ Base64 encoding is not supported | state | String | RO | The current state of the cover, possibly including opening, closing, or stopped. | | json-attributes | String | RO | Additional attributes, as a serialized JSON string. | +### [Device Tracker](https://www.home-assistant.io/integrations/device_tracker.mqtt/) + +| Channel ID | Type | R/W | Description | +|-----------------|---------------|-----|------------------------------------------------------------------------------------------------------------------------------------| +| home | Switch | RO | If the tracker reports itself as home or not home. | +| location-name | String | RO | The arbitrary location the tracker reports itself as at (can often be "home" or "not_home"). | +| location | Location | RO | The GPS location, if the tracker can report it. | +| gps-accuracy | Number:Length | RO | The accuracy of a GPS fix. Even if a tracker can provide GPS location, it may not be able to determine and/or report its accuracy. | +| source-type | String | RO | The source of the data, if the tracker reports it. May be "gps", "router", "bluetooth", or "bluetooth_le". | +| json-attributes | String | RO | Additional attributes, as a serialized JSON string. | + ### [Device Trigger](https://www.home-assistant.io/integrations/device_trigger.mqtt/) If a device has multiple device triggers for the same subtype (the particular button), they will only show up as a single channel, and all events for that button will be delivered to that channel. diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java index b99f8683ec1..4bb55035dff 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/ComponentChannelType.java @@ -26,11 +26,13 @@ public enum ComponentChannelType { COLOR("ha-color"), DIMMER("ha-dimmer"), IMAGE("ha-image"), + LOCATION("ha-location"), NUMBER("ha-number"), ROLLERSHUTTER("ha-rollershutter"), STRING("ha-string"), SWITCH("ha-switch"), - TRIGGER("ha-trigger"); + TRIGGER("ha-trigger"), + GPS_ACCURACY("ha-gps-accuracy"); final ChannelTypeUID channelTypeUID; diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java index 28e17c01a1f..fb71bb59914 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/AbstractComponent.java @@ -157,8 +157,10 @@ public abstract class AbstractComponent protected void addJsonAttributesChannel() { if (channelConfiguration.getJsonAttributesTopic() != null) { + ChannelStateUpdateListener listener = (this instanceof ChannelStateUpdateListener localThis) ? localThis + : componentConfiguration.getUpdateListener(); buildChannel(JSON_ATTRIBUTES_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(), "JSON Attributes", - componentConfiguration.getUpdateListener()) + listener) .stateTopic(channelConfiguration.getJsonAttributesTopic(), channelConfiguration.getJsonAttributesTemplate()) .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).isAdvanced(true).build(); diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java index fe864be0635..7dc66de6875 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/ComponentFactory.java @@ -65,6 +65,8 @@ public class ComponentFactory { return new Cover(componentConfiguration, newStyleChannels); case "device_automation": return new DeviceTrigger(componentConfiguration, newStyleChannels); + case "device_tracker": + return new DeviceTracker(componentConfiguration, newStyleChannels); case "event": return new Event(componentConfiguration, newStyleChannels); case "fan": diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTracker.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTracker.java new file mode 100644 index 00000000000..70b47010ba8 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTracker.java @@ -0,0 +1,232 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.homeassistant.internal.component; + +import java.math.BigDecimal; +import java.util.Objects; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.binding.mqtt.generic.ChannelStateUpdateListener; +import org.openhab.binding.mqtt.generic.values.LocationValue; +import org.openhab.binding.mqtt.generic.values.NumberValue; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannel; +import org.openhab.binding.mqtt.homeassistant.internal.ComponentChannelType; +import org.openhab.binding.mqtt.homeassistant.internal.config.dto.AbstractChannelConfiguration; +import org.openhab.binding.mqtt.homeassistant.internal.exception.ConfigurationException; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PointType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.type.AutoUpdatePolicy; +import org.openhab.core.types.Command; +import org.openhab.core.types.State; +import org.openhab.core.types.UnDefType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.google.gson.JsonSyntaxException; +import com.google.gson.annotations.SerializedName; + +/** + * A MQTT Device Tracker, following the https://www.home-assistant.io/integrations/device_tracker.mqtt/specification. + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class DeviceTracker extends AbstractComponent + implements ChannelStateUpdateListener { + public static final String HOME_CHANNEL_ID = "home"; + public static final String LOCATION_CHANNEL_ID = "location"; + public static final String GPS_ACCURACY_CHANNEL_ID = "gps-accuracy"; // Always in meters + public static final String LOCATION_NAME_CHANNEL_ID = "location-name"; + public static final String SOURCE_TYPE_CHANNEL_ID = "source-type"; + + public static final String[] SOURCE_TYPE_OPTIONS = new String[] { "gps", "router", "bluetooth", "bluetooth_le" }; + + /** + * Configuration class for MQTT component + */ + static class ChannelConfiguration extends AbstractChannelConfiguration { + ChannelConfiguration() { + super("MQTT Binary Sensor"); + } + + @SerializedName("source_type") + protected @Nullable String sourceType; + @SerializedName("state_topic") + protected @Nullable String stateTopic; + @SerializedName("payload_home") + protected String payloadHome = "home"; + @SerializedName("payload_not_home") + protected String payloadNotHome = "not_home"; + @SerializedName("payload_reset") + protected String payloadReset = "None"; + } + + /** + * DTO for JSON Attributes providing location data + */ + static class JSONAttributes { + protected @Nullable BigDecimal latitude; + protected @Nullable BigDecimal longitude; + @SerializedName("gps_accuracy") + protected @Nullable BigDecimal gpsAccuracy; + } + + private final Logger logger = LoggerFactory.getLogger(DeviceTracker.class); + + private final ChannelStateUpdateListener channelStateUpdateListener; + private final OnOffValue homeValue = new OnOffValue(); + private final NumberValue accuracyValue = new NumberValue(BigDecimal.ZERO, null, null, SIUnits.METRE); + private final TextValue locationNameValue = new TextValue(); + private final LocationValue locationValue = new LocationValue(); + private final @Nullable ComponentChannel homeChannel, locationChannel, accuracyChannel; + + public DeviceTracker(ComponentFactory.ComponentConfiguration componentConfiguration, boolean newStyleChannels) { + super(componentConfiguration, ChannelConfiguration.class, newStyleChannels); + this.channelStateUpdateListener = componentConfiguration.getUpdateListener(); + + if (channelConfiguration.stateTopic == null && channelConfiguration.getJsonAttributesTopic() == null) { + throw new ConfigurationException("Device trackers must define either state_topic or json_attributes_topic"); + } + homeValue.update(UnDefType.NULL); + locationNameValue.update(UnDefType.NULL); + accuracyValue.update(UnDefType.NULL); + locationValue.update(UnDefType.NULL); + + if (channelConfiguration.stateTopic != null) { + homeChannel = buildChannel(HOME_CHANNEL_ID, ComponentChannelType.SWITCH, homeValue, "At Home", + componentConfiguration.getUpdateListener()).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + + buildChannel(LOCATION_NAME_CHANNEL_ID, ComponentChannelType.STRING, locationNameValue, "Location Name", + this).stateTopic(channelConfiguration.stateTopic, channelConfiguration.getValueTemplate()) + .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + } else { + homeChannel = null; + } + + String sourceType = channelConfiguration.sourceType; + if (sourceType != null) { + TextValue sourceTypeValue = new TextValue(SOURCE_TYPE_OPTIONS); + sourceTypeValue.update(new StringType(sourceType)); + buildChannel(SOURCE_TYPE_CHANNEL_ID, ComponentChannelType.STRING, sourceTypeValue, "Source Type", this) + .isAdvanced(true).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + } + + if (channelConfiguration.getJsonAttributesTopic() != null) { + locationChannel = buildChannel(LOCATION_CHANNEL_ID, ComponentChannelType.LOCATION, locationValue, + "Location", this).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + + accuracyChannel = buildChannel(GPS_ACCURACY_CHANNEL_ID, ComponentChannelType.GPS_ACCURACY, accuracyValue, + "GPS Accuracy", this).isAdvanced(true).withAutoUpdatePolicy(AutoUpdatePolicy.VETO).build(); + } else { + locationChannel = accuracyChannel = null; + } + + finalizeChannels(); + } + + // Override to set ourselves as listener + protected void addJsonAttributesChannel() { + if (channelConfiguration.getJsonAttributesTopic() != null) { + buildChannel(JSON_ATTRIBUTES_CHANNEL_ID, ComponentChannelType.STRING, new TextValue(), "JSON Attributes", + this) + .stateTopic(channelConfiguration.getJsonAttributesTopic(), + channelConfiguration.getJsonAttributesTemplate()) + .withAutoUpdatePolicy(AutoUpdatePolicy.VETO).isAdvanced(true).build(); + } + } + + @Override + public void updateChannelState(ChannelUID channel, State state) { + if (channel.getIdWithoutGroup().equals(LOCATION_NAME_CHANNEL_ID)) { + String stateString = state.toString(); + if (stateString.isEmpty()) { + return; + } + + State homeState; + if (channelConfiguration.payloadHome.equals(stateString)) { + homeState = OnOffType.ON; + } else if (channelConfiguration.payloadNotHome.equals(stateString)) { + homeState = OnOffType.OFF; + } else { + homeState = UnDefType.UNDEF; + } + + if (channelConfiguration.payloadReset.equals(stateString)) { + state = UnDefType.NULL; + locationNameValue.update(state); + homeState = UnDefType.NULL; + ComponentChannel locationChannel = this.locationChannel; + if (locationChannel != null) { + locationValue.update(UnDefType.NULL); + accuracyValue.update(UnDefType.NULL); + channelStateUpdateListener.updateChannelState(locationChannel.getChannel().getUID(), + locationValue.getChannelState()); + channelStateUpdateListener.updateChannelState( + Objects.requireNonNull(accuracyChannel).getChannel().getUID(), + accuracyValue.getChannelState()); + } + } + channelStateUpdateListener.updateChannelState(channel, state); + homeValue.update(homeState); + channelStateUpdateListener.updateChannelState(Objects.requireNonNull(homeChannel).getChannel().getUID(), + homeState); + } else if (channel.getIdWithoutGroup().equals(JSON_ATTRIBUTES_CHANNEL_ID)) { + // First forward JSON attributes channel as-is + channelStateUpdateListener.updateChannelState(channel, state); + + JSONAttributes jsonAttributes; + try { + jsonAttributes = Objects.requireNonNull(getGson().fromJson(state.toString(), JSONAttributes.class)); + } catch (JsonSyntaxException e) { + logger.warn("Cannot parse JSON attributes '{}' for '{}'.", state, getHaID()); + return; + } + BigDecimal latitude = jsonAttributes.latitude; + BigDecimal longitude = jsonAttributes.longitude; + BigDecimal gpsAccuracy = jsonAttributes.gpsAccuracy; + if (latitude != null && longitude != null) { + locationValue.update(new PointType(new DecimalType(latitude), new DecimalType(longitude))); + } else { + locationValue.update(UnDefType.NULL); + } + if (gpsAccuracy != null) { + accuracyValue.update(new QuantityType<>(gpsAccuracy, SIUnits.METRE)); + } else { + accuracyValue.update(UnDefType.NULL); + } + channelStateUpdateListener.updateChannelState(Objects.requireNonNull(locationChannel).getChannel().getUID(), + locationValue.getChannelState()); + channelStateUpdateListener.updateChannelState(Objects.requireNonNull(accuracyChannel).getChannel().getUID(), + accuracyValue.getChannelState()); + } + } + + @Override + public void postChannelCommand(ChannelUID channelUID, Command value) { + throw new UnsupportedOperationException(); + } + + @Override + public void triggerChannel(ChannelUID channelUID, String eventPayload) { + throw new UnsupportedOperationException(); + } +} diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties index 22cb9fb44fb..b4853d7c35c 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/i18n/mqtt.properties @@ -16,8 +16,11 @@ channel-type.mqtt.ha-color-advanced.label = Color channel-type.mqtt.ha-color.label = Color channel-type.mqtt.ha-dimmer-advanced.label = Dimmer channel-type.mqtt.ha-dimmer.label = Dimmer +channel-type.mqtt.ha-gps-accuracy.label = GPS Accuracy +channel-type.mqtt.ha-gps-accuracy.description = The accuracy of the GPS fix, in meters. channel-type.mqtt.ha-image-advanced.label = Image channel-type.mqtt.ha-image.label = Image +channel-type.mqtt.ha-location.label = Location channel-type.mqtt.ha-number-advanced.label = Number channel-type.mqtt.ha-number.label = Number channel-type.mqtt.ha-rollershutter-advanced.label = Rollershutter diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml index cd4679398b1..73320d90aa8 100644 --- a/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/main/resources/OH-INF/thing/homeassistant-channels.xml @@ -22,12 +22,26 @@ + + Location + + + + Number + + Number:Length + + The accuracy of the GPS fix, in meters. + veto + + + Rollershutter diff --git a/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrackerTests.java b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrackerTests.java new file mode 100644 index 00000000000..8df94e88717 --- /dev/null +++ b/bundles/org.openhab.binding.mqtt.homeassistant/src/test/java/org/openhab/binding/mqtt/homeassistant/internal/component/DeviceTrackerTests.java @@ -0,0 +1,274 @@ +/** + * Copyright (c) 2010-2024 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.mqtt.homeassistant.internal.component; + +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; + +import java.util.Set; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.binding.mqtt.generic.values.LocationValue; +import org.openhab.binding.mqtt.generic.values.NumberValue; +import org.openhab.binding.mqtt.generic.values.OnOffValue; +import org.openhab.binding.mqtt.generic.values.TextValue; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PointType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.types.StringType; +import org.openhab.core.library.unit.SIUnits; +import org.openhab.core.types.UnDefType; + +/** + * Tests for {@link DeviceTracker} + * + * @author Cody Cutrer - Initial contribution + */ +@NonNullByDefault +public class DeviceTrackerTests extends AbstractComponentTests { + public static final String CONFIG_TOPIC = "device_tracker/112233445566-tracker"; + + @Test + public void testIPhone() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "stat_t": "home/TheengsGateway/BTtoMQTT/112233445566", + "name": "APPLEDEVICE-tracker", + "uniq_id": "112233445566-tracker", + "val_tpl": "{% if value_json.get('rssi') -%}home{%- else -%}not_home{%- endif %}", + "source_type": "bluetooth_le", + "device": { + "ids": ["112233445566"], + "cns": [["mac", "112233445566"]], + "mf": "Apple", + "mdl": "APPLEDEVICE", + "name": "Apple iPhone/iPad-123456", + "via_device": "TheengsGateway" + } + } + """); + + assertThat(component.channels.size(), is(3)); + assertThat(component.getName(), is("APPLEDEVICE-tracker")); + + assertChannel(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, "home/TheengsGateway/BTtoMQTT/112233445566", + "", "Location Name", TextValue.class); + assertChannel(component, DeviceTracker.HOME_CHANNEL_ID, "", "", "At Home", OnOffValue.class); + assertChannel(component, DeviceTracker.SOURCE_TYPE_CHANNEL_ID, "", "", "Source Type", TextValue.class); + assertState(component, DeviceTracker.SOURCE_TYPE_CHANNEL_ID, new StringType("bluetooth_le")); + + publishMessage("home/TheengsGateway/BTtoMQTT/112233445566", """ + { + "id": "11:22:33:44:55:66", + "rssi": -55, + "brand": "Apple", + "model": "Apple iPhone/iPad", + "model_id": "APPLEDEVICE", + "type": "TRACK", + "unlocked": false + } + """); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("home")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, OnOffType.ON); + publishMessage("home/TheengsGateway/BTtoMQTT/112233445566", """ + {"id": "11:22:33:44:55:66", "presence": "absent"} + """); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("not_home")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, OnOffType.OFF); + } + + @Test + public void testGeneric() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "stat_t": "devices/112233445566", + "name": "tracker" + } + """); + + assertThat(component.channels.size(), is(2)); + assertThat(component.getName(), is("tracker")); + + assertChannel(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, "devices/112233445566", "", "Location Name", + TextValue.class); + assertChannel(component, DeviceTracker.HOME_CHANNEL_ID, "", "", "At Home", OnOffValue.class); + + publishMessage("devices/112233445566", "home"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("home")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, OnOffType.ON); + publishMessage("devices/112233445566", "not_home"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("not_home")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, OnOffType.OFF); + publishMessage("devices/112233445566", "work"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("work")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.UNDEF); + publishMessage("devices/112233445566", "None"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.NULL); + } + + @Test + public void testGPS() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "stat_t": "devices/112233445566", + "name": "tracker", + "json_attributes_topic": "devices/112233445566/json" + } + """); + + assertThat(component.channels.size(), is(5)); + assertThat(component.getName(), is("tracker")); + + assertChannel(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, "devices/112233445566", "", "Location Name", + TextValue.class); + assertChannel(component, DeviceTracker.HOME_CHANNEL_ID, "", "", "At Home", OnOffValue.class); + assertChannel(component, DeviceTracker.LOCATION_CHANNEL_ID, "", "", "Location", LocationValue.class); + assertChannel(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, "", "", "GPS Accuracy", NumberValue.class); + assertChannel(component, DeviceTracker.JSON_ATTRIBUTES_CHANNEL_ID, "devices/112233445566/json", "", + "JSON Attributes", TextValue.class); + + publishMessage("devices/112233445566", "home"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("home")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, OnOffType.ON); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566", "not_home"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("not_home")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, OnOffType.OFF); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566", "work"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("work")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.UNDEF); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566/json", "not JSON"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("work")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.UNDEF); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566/json", """ + { + "nothing": 1 + } + """); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("work")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.UNDEF); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566/json", """ + { + "latitude": 45.5, + "longitude": 91.1 + } + """); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("work")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.UNDEF); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, + new PointType(new DecimalType(45.5), new DecimalType(91.1))); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566/json", """ + { + "latitude": 45.6, + "longitude": 91.2, + "gps_accuracy": 5.5 + } + """); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("work")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.UNDEF); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, + new PointType(new DecimalType(45.6), new DecimalType(91.2))); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, new QuantityType<>(5.5, SIUnits.METRE)); + publishMessage("devices/112233445566/json", """ + { + "latitude": 45.7, + "longitude": 91.3 + } + """); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, new StringType("work")); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.UNDEF); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, + new PointType(new DecimalType(45.7), new DecimalType(91.3))); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566", "None"); + assertState(component, DeviceTracker.LOCATION_NAME_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.HOME_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + } + + @Test + public void testGPSOnly() throws InterruptedException { + var component = discoverComponent(configTopicToMqtt(CONFIG_TOPIC), """ + { + "name": "tracker", + "json_attributes_topic": "devices/112233445566/json" + } + """); + + assertThat(component.channels.size(), is(3)); + assertThat(component.getName(), is("tracker")); + + assertChannel(component, DeviceTracker.LOCATION_CHANNEL_ID, "", "", "Location", LocationValue.class); + assertChannel(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, "", "", "GPS Accuracy", NumberValue.class); + assertChannel(component, DeviceTracker.JSON_ATTRIBUTES_CHANNEL_ID, "devices/112233445566/json", "", + "JSON Attributes", TextValue.class); + + publishMessage("devices/112233445566/json", "not JSON"); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566/json", """ + { + "nothing": 1 + } + """); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, UnDefType.NULL); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566/json", """ + { + "latitude": 45.5, + "longitude": 91.1 + } + """); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, + new PointType(new DecimalType(45.5), new DecimalType(91.1))); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + publishMessage("devices/112233445566/json", """ + { + "latitude": 45.6, + "longitude": 91.2, + "gps_accuracy": 5.5 + } + """); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, + new PointType(new DecimalType(45.6), new DecimalType(91.2))); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, new QuantityType<>(5.5, SIUnits.METRE)); + publishMessage("devices/112233445566/json", """ + { + "latitude": 45.7, + "longitude": 91.3 + } + """); + assertState(component, DeviceTracker.LOCATION_CHANNEL_ID, + new PointType(new DecimalType(45.7), new DecimalType(91.3))); + assertState(component, DeviceTracker.GPS_ACCURACY_CHANNEL_ID, UnDefType.NULL); + } + + @Override + protected Set getConfigTopics() { + return Set.of(CONFIG_TOPIC); + } +}