core/tests/components/owntracks/test_device_tracker.py

1691 lines
59 KiB
Python

"""The tests for the Owntracks device tracker."""
import base64
from collections.abc import Callable, Generator
import json
import pickle
from typing import Any
from unittest.mock import patch
from nacl.encoding import Base64Encoder
from nacl.secret import SecretBox
import pytest
from homeassistant.components import owntracks
from homeassistant.components.device_tracker.legacy import Device
from homeassistant.const import STATE_NOT_HOME
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, async_fire_mqtt_message
from tests.typing import ClientSessionGenerator, MqttMockHAClient
type OwnTracksContextFactory = Callable[[], owntracks.OwnTracksContext]
USER = "greg"
DEVICE = "phone"
LOCATION_TOPIC = f"owntracks/{USER}/{DEVICE}"
EVENT_TOPIC = f"owntracks/{USER}/{DEVICE}/event"
WAYPOINTS_TOPIC = f"owntracks/{USER}/{DEVICE}/waypoints"
WAYPOINT_TOPIC = f"owntracks/{USER}/{DEVICE}/waypoint"
USER_BLACKLIST = "ram"
WAYPOINTS_TOPIC_BLOCKED = f"owntracks/{USER_BLACKLIST}/{DEVICE}/waypoints"
LWT_TOPIC = f"owntracks/{USER}/{DEVICE}/lwt"
BAD_TOPIC = f"owntracks/{USER}/{DEVICE}/unsupported"
DEVICE_TRACKER_STATE = f"device_tracker.{USER}_{DEVICE}"
IBEACON_DEVICE = "keys"
MOBILE_BEACON_FMT = "device_tracker.beacon_{}"
CONF_MAX_GPS_ACCURACY = "max_gps_accuracy"
CONF_WAYPOINT_IMPORT = owntracks.CONF_WAYPOINT_IMPORT
CONF_WAYPOINT_WHITELIST = owntracks.CONF_WAYPOINT_WHITELIST
CONF_SECRET = owntracks.CONF_SECRET
CONF_MQTT_TOPIC = owntracks.CONF_MQTT_TOPIC
CONF_EVENTS_ONLY = owntracks.CONF_EVENTS_ONLY
CONF_REGION_MAPPING = owntracks.CONF_REGION_MAPPING
TEST_ZONE_LAT = 45.0
TEST_ZONE_LON = 90.0
TEST_ZONE_DEG_PER_M = 0.0000127
FIVE_M = TEST_ZONE_DEG_PER_M * 5.0
# Home Assistant Zones
INNER_ZONE = {
"name": "zone",
"latitude": TEST_ZONE_LAT + 0.1,
"longitude": TEST_ZONE_LON + 0.1,
"radius": 50,
}
OUTER_ZONE = {
"name": "zone",
"latitude": TEST_ZONE_LAT,
"longitude": TEST_ZONE_LON,
"radius": 100000,
}
def build_message(test_params, default_params):
"""Build a test message from overrides and another message."""
new_params = default_params.copy()
new_params.update(test_params)
return new_params
# Default message parameters
DEFAULT_LOCATION_MESSAGE = {
"_type": "location",
"lon": OUTER_ZONE["longitude"],
"lat": OUTER_ZONE["latitude"],
"acc": 60,
"tid": "user",
"t": "u",
"batt": 92,
"cog": 248,
"alt": 27,
"p": 101.3977584838867,
"vac": 4,
"tst": 1,
"vel": 0,
}
# Owntracks will publish a transition when crossing
# a circular region boundary.
ZONE_EDGE = TEST_ZONE_DEG_PER_M * INNER_ZONE["radius"]
DEFAULT_TRANSITION_MESSAGE = {
"_type": "transition",
"t": "c",
"lon": INNER_ZONE["longitude"],
"lat": INNER_ZONE["latitude"] - ZONE_EDGE,
"acc": 60,
"event": "enter",
"tid": "user",
"desc": "inner",
"wtst": 1,
"tst": 2,
}
# iBeacons that are named the same as an HA zone
# are used to trigger enter and leave updates
# for that zone. In this case the "inner" zone.
#
# iBeacons that do not share an HA zone name
# are treated as mobile tracking devices for
# objects which can't track themselves e.g. keys.
#
# iBeacons are typically configured with the
# default lat/lon 0.0/0.0 and have acc 0.0 but
# regardless the reported location is not trusted.
#
# Owntracks will send both a location message
# for the device and an 'event' message for
# the beacon transition.
DEFAULT_BEACON_TRANSITION_MESSAGE = {
"_type": "transition",
"t": "b",
"lon": 0.0,
"lat": 0.0,
"acc": 0.0,
"event": "enter",
"tid": "user",
"desc": "inner",
"wtst": 1,
"tst": 2,
}
# Location messages
LOCATION_MESSAGE = DEFAULT_LOCATION_MESSAGE
LOCATION_MESSAGE_INACCURATE = build_message(
{
"lat": INNER_ZONE["latitude"] - ZONE_EDGE,
"lon": INNER_ZONE["longitude"] - ZONE_EDGE,
"acc": 2000,
},
LOCATION_MESSAGE,
)
LOCATION_MESSAGE_ZERO_ACCURACY = build_message(
{
"lat": INNER_ZONE["latitude"] - ZONE_EDGE,
"lon": INNER_ZONE["longitude"] - ZONE_EDGE,
"acc": 0,
},
LOCATION_MESSAGE,
)
LOCATION_MESSAGE_NOT_HOME = build_message(
{
"lat": OUTER_ZONE["latitude"] - 2.0,
"lon": INNER_ZONE["longitude"] - 2.0,
"acc": 100,
},
LOCATION_MESSAGE,
)
# Region GPS messages
REGION_GPS_ENTER_MESSAGE = DEFAULT_TRANSITION_MESSAGE
REGION_GPS_LEAVE_MESSAGE = build_message(
{
"lon": INNER_ZONE["longitude"] - ZONE_EDGE * 10,
"lat": INNER_ZONE["latitude"] - ZONE_EDGE * 10,
"event": "leave",
},
DEFAULT_TRANSITION_MESSAGE,
)
REGION_GPS_ENTER_MESSAGE_INACCURATE = build_message(
{"acc": 2000}, REGION_GPS_ENTER_MESSAGE
)
REGION_GPS_LEAVE_MESSAGE_INACCURATE = build_message(
{"acc": 2000}, REGION_GPS_LEAVE_MESSAGE
)
REGION_GPS_ENTER_MESSAGE_ZERO = build_message({"acc": 0}, REGION_GPS_ENTER_MESSAGE)
REGION_GPS_LEAVE_MESSAGE_ZERO = build_message({"acc": 0}, REGION_GPS_LEAVE_MESSAGE)
REGION_GPS_LEAVE_MESSAGE_OUTER = build_message(
{
"lon": OUTER_ZONE["longitude"] - 2.0,
"lat": OUTER_ZONE["latitude"] - 2.0,
"desc": "outer",
"event": "leave",
},
DEFAULT_TRANSITION_MESSAGE,
)
REGION_GPS_ENTER_MESSAGE_OUTER = build_message(
{
"lon": OUTER_ZONE["longitude"],
"lat": OUTER_ZONE["latitude"],
"desc": "outer",
"event": "enter",
},
DEFAULT_TRANSITION_MESSAGE,
)
# Region Beacon messages
REGION_BEACON_ENTER_MESSAGE = DEFAULT_BEACON_TRANSITION_MESSAGE
REGION_BEACON_LEAVE_MESSAGE = build_message(
{"event": "leave"}, DEFAULT_BEACON_TRANSITION_MESSAGE
)
# Mobile Beacon messages
MOBILE_BEACON_ENTER_EVENT_MESSAGE = build_message(
{"desc": IBEACON_DEVICE}, DEFAULT_BEACON_TRANSITION_MESSAGE
)
MOBILE_BEACON_LEAVE_EVENT_MESSAGE = build_message(
{"desc": IBEACON_DEVICE, "event": "leave"}, DEFAULT_BEACON_TRANSITION_MESSAGE
)
# Waypoint messages
WAYPOINTS_EXPORTED_MESSAGE = {
"_type": "waypoints",
"_creator": "test",
"waypoints": [
{
"_type": "waypoint",
"tst": 3,
"lat": 47,
"lon": 9,
"rad": 10,
"desc": "exp_wayp1",
},
{
"_type": "waypoint",
"tst": 4,
"lat": 3,
"lon": 9,
"rad": 500,
"desc": "exp_wayp2",
},
],
}
WAYPOINTS_UPDATED_MESSAGE = {
"_type": "waypoints",
"_creator": "test",
"waypoints": [
{
"_type": "waypoint",
"tst": 4,
"lat": 9,
"lon": 47,
"rad": 50,
"desc": "exp_wayp1",
}
],
}
WAYPOINT_MESSAGE = {
"_type": "waypoint",
"tst": 4,
"lat": 9,
"lon": 47,
"rad": 50,
"desc": "exp_wayp1",
}
WAYPOINT_ENTITY_NAMES = [
"zone.greg_phone_exp_wayp1",
"zone.greg_phone_exp_wayp2",
"zone.ram_phone_exp_wayp1",
"zone.ram_phone_exp_wayp2",
]
LWT_MESSAGE = {"_type": "lwt", "tst": 1}
BAD_MESSAGE = {"_type": "unsupported", "tst": 1}
BAD_JSON_PREFIX = "--$this is bad json#--"
BAD_JSON_SUFFIX = "** and it ends here ^^"
@pytest.fixture
def setup_comp(
hass: HomeAssistant,
mock_device_tracker_conf: list[Device],
mqtt_mock: MqttMockHAClient,
) -> None:
"""Initialize components."""
hass.loop.run_until_complete(async_setup_component(hass, "device_tracker", {}))
hass.states.async_set("zone.inner", "zoning", INNER_ZONE)
hass.states.async_set("zone.inner_2", "zoning", INNER_ZONE)
hass.states.async_set("zone.outer", "zoning", OUTER_ZONE)
async def setup_owntracks(
hass: HomeAssistant, config: dict[str, Any], ctx_cls=owntracks.OwnTracksContext
) -> None:
"""Set up OwnTracks."""
MockConfigEntry(
domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"}
).add_to_hass(hass)
with patch.object(owntracks, "OwnTracksContext", ctx_cls):
assert await async_setup_component(hass, "owntracks", {"owntracks": config})
await hass.async_block_till_done()
@pytest.fixture
def context(hass: HomeAssistant, setup_comp: None) -> OwnTracksContextFactory:
"""Set up the mocked context."""
orig_context = owntracks.OwnTracksContext
context = None
def store_context(*args):
"""Store the context."""
nonlocal context
context = orig_context(*args)
return context
hass.loop.run_until_complete(
setup_owntracks(
hass,
{
CONF_MAX_GPS_ACCURACY: 200,
CONF_WAYPOINT_IMPORT: True,
CONF_WAYPOINT_WHITELIST: ["jon", "greg"],
},
store_context,
)
)
def get_context():
"""Get the current context."""
return context
return get_context
async def send_message(
hass: HomeAssistant, topic: str, message: dict[str, Any], corrupt: bool = False
) -> None:
"""Test the sending of a message."""
str_message = json.dumps(message)
if corrupt:
mod_message = BAD_JSON_PREFIX + str_message + BAD_JSON_SUFFIX
else:
mod_message = str_message
async_fire_mqtt_message(hass, topic, mod_message)
await hass.async_block_till_done()
await hass.async_block_till_done()
def assert_location_state(hass: HomeAssistant, location: str) -> None:
"""Test the assertion of a location state."""
state = hass.states.get(DEVICE_TRACKER_STATE)
assert state.state == location
def assert_location_latitude(hass: HomeAssistant, latitude: float) -> None:
"""Test the assertion of a location latitude."""
state = hass.states.get(DEVICE_TRACKER_STATE)
assert state.attributes.get("latitude") == latitude
def assert_location_longitude(hass: HomeAssistant, longitude: float) -> None:
"""Test the assertion of a location longitude."""
state = hass.states.get(DEVICE_TRACKER_STATE)
assert state.attributes.get("longitude") == longitude
def assert_location_accuracy(hass: HomeAssistant, accuracy: int) -> None:
"""Test the assertion of a location accuracy."""
state = hass.states.get(DEVICE_TRACKER_STATE)
assert state.attributes.get("gps_accuracy") == accuracy
def assert_location_source_type(hass: HomeAssistant, source_type: str) -> None:
"""Test the assertion of source_type."""
state = hass.states.get(DEVICE_TRACKER_STATE)
assert state.attributes.get("source_type") == source_type
def assert_mobile_tracker_state(
hass: HomeAssistant, location: str, beacon: str = IBEACON_DEVICE
) -> None:
"""Test the assertion of a mobile beacon tracker state."""
dev_id = MOBILE_BEACON_FMT.format(beacon)
state = hass.states.get(dev_id)
assert state.state == location
def assert_mobile_tracker_latitude(
hass: HomeAssistant, latitude: float, beacon: str = IBEACON_DEVICE
) -> None:
"""Test the assertion of a mobile beacon tracker latitude."""
dev_id = MOBILE_BEACON_FMT.format(beacon)
state = hass.states.get(dev_id)
assert state.attributes.get("latitude") == latitude
def assert_mobile_tracker_accuracy(
hass: HomeAssistant, accuracy: int, beacon: str = IBEACON_DEVICE
) -> None:
"""Test the assertion of a mobile beacon tracker accuracy."""
dev_id = MOBILE_BEACON_FMT.format(beacon)
state = hass.states.get(dev_id)
assert state.attributes.get("gps_accuracy") == accuracy
@pytest.mark.usefixtures("context")
async def test_location_invalid_devid(hass: HomeAssistant) -> None:
"""Test the update of a location."""
await send_message(hass, "owntracks/paulus/nexus-5x", LOCATION_MESSAGE)
state = hass.states.get("device_tracker.paulus_nexus_5x")
assert state.state == "outer"
@pytest.mark.usefixtures("context")
async def test_location_update(hass: HomeAssistant) -> None:
"""Test the update of a location."""
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
assert_location_source_type(hass, "gps")
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
assert_location_accuracy(hass, LOCATION_MESSAGE["acc"])
assert_location_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_location_update_no_t_key(hass: HomeAssistant) -> None:
"""Test the update of a location when message does not contain 't'."""
message = LOCATION_MESSAGE.copy()
message.pop("t")
await send_message(hass, LOCATION_TOPIC, message)
assert_location_source_type(hass, "gps")
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
assert_location_accuracy(hass, LOCATION_MESSAGE["acc"])
assert_location_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_location_inaccurate_gps(hass: HomeAssistant) -> None:
"""Test the location for inaccurate GPS information."""
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_INACCURATE)
# Ignored inaccurate GPS. Location remains at previous.
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
assert_location_longitude(hass, LOCATION_MESSAGE["lon"])
@pytest.mark.usefixtures("context")
async def test_location_zero_accuracy_gps(hass: HomeAssistant) -> None:
"""Ignore the location for zero accuracy GPS information."""
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_ZERO_ACCURACY)
# Ignored inaccurate GPS. Location remains at previous.
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
assert_location_longitude(hass, LOCATION_MESSAGE["lon"])
# ------------------------------------------------------------------------
# GPS based event entry / exit testing
async def test_event_gps_entry_exit(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test the entry event."""
# Entering the owntracks circular region named "inner"
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
# Enter uses the zone's gps co-ords
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# Updates ignored when in a zone
# note that LOCATION_MESSAGE is actually pretty far
# from INNER_ZONE and has good accuracy. I haven't
# received a transition message though so I'm still
# associated with the inner zone regardless of GPS.
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
# Exit switches back to GPS
assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE["acc"])
assert_location_state(hass, "outer")
# Left clean zone state
assert not context().regions_entered[USER]
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# Now sending a location update moves me again.
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
assert_location_accuracy(hass, LOCATION_MESSAGE["acc"])
async def test_event_gps_with_spaces(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test the entry event."""
message = build_message({"desc": "inner 2"}, REGION_GPS_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner 2")
message = build_message({"desc": "inner 2"}, REGION_GPS_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
# Left clean zone state
assert not context().regions_entered[USER]
@pytest.mark.usefixtures("context")
async def test_event_gps_entry_inaccurate(hass: HomeAssistant) -> None:
"""Test the event for inaccurate entry."""
# Set location to the outer zone.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_INACCURATE)
# I enter the zone even though the message GPS was inaccurate.
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
async def test_event_gps_entry_exit_inaccurate(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test the event for inaccurate exit."""
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
# Enter uses the zone's gps co-ords
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_INACCURATE)
# Exit doesn't use inaccurate gps
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
# But does exit region correctly
assert not context().regions_entered[USER]
async def test_event_gps_entry_exit_zero_accuracy(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test entry/exit events with accuracy zero."""
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_ZERO)
# Enter uses the zone's gps co-ords
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_ZERO)
# Exit doesn't use zero gps
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
# But does exit region correctly
assert not context().regions_entered[USER]
@pytest.mark.usefixtures("context")
async def test_event_gps_exit_outside_zone_sets_away(hass: HomeAssistant) -> None:
"""Test the event for exit zone."""
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
assert_location_state(hass, "inner")
# Exit message far away GPS location
message = build_message({"lon": 90.0, "lat": 90.0}, REGION_GPS_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
# Exit forces zone change to away
assert_location_state(hass, STATE_NOT_HOME)
@pytest.mark.usefixtures("context")
async def test_event_gps_entry_exit_right_order(hass: HomeAssistant) -> None:
"""Test the event for ordering."""
# Enter inner zone
# Set location to the outer zone.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
assert_location_state(hass, "inner")
# Enter inner2 zone
message = build_message({"desc": "inner_2"}, REGION_GPS_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner_2")
# Exit inner_2 - should be in 'inner'
message = build_message({"desc": "inner_2"}, REGION_GPS_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner")
# Exit inner - should be in 'outer'
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE["acc"])
assert_location_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_event_gps_entry_exit_wrong_order(hass: HomeAssistant) -> None:
"""Test the event for wrong order."""
# Enter inner zone
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
assert_location_state(hass, "inner")
# Enter inner2 zone
message = build_message({"desc": "inner_2"}, REGION_GPS_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner_2")
# Exit inner - should still be in 'inner_2'
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
assert_location_state(hass, "inner_2")
# Exit inner_2 - should be in 'outer'
message = build_message({"desc": "inner_2"}, REGION_GPS_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_location_accuracy(hass, REGION_GPS_LEAVE_MESSAGE["acc"])
assert_location_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_event_gps_entry_unknown_zone(hass: HomeAssistant) -> None:
"""Test the event for unknown zone."""
# Just treat as location update
message = build_message({"desc": "unknown"}, REGION_GPS_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_latitude(hass, REGION_GPS_ENTER_MESSAGE["lat"])
assert_location_state(hass, "inner")
@pytest.mark.usefixtures("context")
async def test_event_gps_exit_unknown_zone(hass: HomeAssistant) -> None:
"""Test the event for unknown zone."""
# Just treat as location update
message = build_message({"desc": "unknown"}, REGION_GPS_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_location_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_event_entry_zone_loading_dash(hass: HomeAssistant) -> None:
"""Test the event for zone landing."""
# Make sure the leading - is ignored
# Owntracks uses this to switch on hold
message = build_message({"desc": "-inner"}, REGION_GPS_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner")
async def test_events_only_on(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test events_only config suppresses location updates."""
# Sending a location message that is not home
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
assert_location_state(hass, STATE_NOT_HOME)
context().events_only = True
# Enter and Leave messages
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER)
assert_location_state(hass, "outer")
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER)
assert_location_state(hass, STATE_NOT_HOME)
# Sending a location message that is inside outer zone
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# Ignored location update. Location remains at previous.
assert_location_state(hass, STATE_NOT_HOME)
async def test_events_only_off(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test when events_only is False."""
# Sending a location message that is not home
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
assert_location_state(hass, STATE_NOT_HOME)
context().events_only = False
# Enter and Leave messages
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE_OUTER)
assert_location_state(hass, "outer")
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER)
assert_location_state(hass, STATE_NOT_HOME)
# Sending a location message that is inside outer zone
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# Location update processed
assert_location_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_event_source_type_entry_exit(hass: HomeAssistant) -> None:
"""Test the entry and exit events of source type."""
# Entering the owntracks circular region named "inner"
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
# source_type should be gps when entering using gps.
assert_location_source_type(hass, "gps")
# owntracks shouldn't send beacon events with acc = 0
await send_message(
hass, EVENT_TOPIC, build_message({"acc": 1}, REGION_BEACON_ENTER_MESSAGE)
)
# We should be able to enter a beacon zone even inside a gps zone
assert_location_source_type(hass, "bluetooth_le")
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
# source_type should be gps when leaving using gps.
assert_location_source_type(hass, "gps")
# owntracks shouldn't send beacon events with acc = 0
await send_message(
hass, EVENT_TOPIC, build_message({"acc": 1}, REGION_BEACON_LEAVE_MESSAGE)
)
assert_location_source_type(hass, "bluetooth_le")
# Region Beacon based event entry / exit testing
async def test_event_region_entry_exit(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test the entry event."""
# Seeing a beacon named "inner"
await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
# Enter uses the zone's gps co-ords
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# Updates ignored when in a zone
# note that LOCATION_MESSAGE is actually pretty far
# from INNER_ZONE and has good accuracy. I haven't
# received a transition message though so I'm still
# associated with the inner zone regardless of GPS.
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
# Exit switches back to GPS but the beacon has no coords
# so I am still located at the center of the inner region
# until I receive a location update.
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
# Left clean zone state
assert not context().regions_entered[USER]
# Now sending a location update moves me again.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
assert_location_accuracy(hass, LOCATION_MESSAGE["acc"])
async def test_event_region_with_spaces(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test the entry event."""
message = build_message({"desc": "inner 2"}, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner 2")
message = build_message({"desc": "inner 2"}, REGION_BEACON_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
# Left clean zone state
assert not context().regions_entered[USER]
@pytest.mark.usefixtures("context")
async def test_event_region_entry_exit_right_order(hass: HomeAssistant) -> None:
"""Test the event for ordering."""
# Enter inner zone
# Set location to the outer zone.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# See 'inner' region beacon
await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
assert_location_state(hass, "inner")
# See 'inner_2' region beacon
message = build_message({"desc": "inner_2"}, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner_2")
# Exit inner_2 - should be in 'inner'
message = build_message({"desc": "inner_2"}, REGION_BEACON_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner")
# Exit inner - should be in 'outer'
await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
# I have not had an actual location update yet and my
# coordinates are set to the center of the last region I
# entered which puts me in the inner zone.
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner")
@pytest.mark.usefixtures("context")
async def test_event_region_entry_exit_wrong_order(hass: HomeAssistant) -> None:
"""Test the event for wrong order."""
# Enter inner zone
await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
assert_location_state(hass, "inner")
# Enter inner2 zone
message = build_message({"desc": "inner_2"}, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner_2")
# Exit inner - should still be in 'inner_2'
await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
assert_location_state(hass, "inner_2")
# Exit inner_2 - should be in 'outer'
message = build_message({"desc": "inner_2"}, REGION_BEACON_LEAVE_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
# I have not had an actual location update yet and my
# coordinates are set to the center of the last region I
# entered which puts me in the inner_2 zone.
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_accuracy(hass, INNER_ZONE["radius"])
assert_location_state(hass, "inner_2")
@pytest.mark.usefixtures("context")
async def test_event_beacon_unknown_zone_no_location(hass: HomeAssistant) -> None:
"""Test the event for unknown zone."""
# A beacon which does not match a HA zone is the
# definition of a mobile beacon. In this case, "unknown"
# will be turned into device_tracker.beacon_unknown and
# that will be tracked at my current location. Except
# in this case my Device hasn't had a location message
# yet so it's in an odd state where it has state.state
# None and no GPS coords to set the beacon to.
hass.states.async_set(DEVICE_TRACKER_STATE, None)
message = build_message({"desc": "unknown"}, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
# My current state is None because I haven't seen a
# location message or a GPS or Region # Beacon event
# message. None is the state the test harness set for
# the Device during test case setup.
assert_location_state(hass, "None")
# We have had no location yet, so the beacon status
# set to unknown.
assert_mobile_tracker_state(hass, "unknown", "unknown")
@pytest.mark.usefixtures("context")
async def test_event_beacon_unknown_zone(hass: HomeAssistant) -> None:
"""Test the event for unknown zone."""
# A beacon which does not match a HA zone is the
# definition of a mobile beacon. In this case, "unknown"
# will be turned into device_tracker.beacon_unknown and
# that will be tracked at my current location. First I
# set my location so that my state is 'outer'
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
assert_location_state(hass, "outer")
message = build_message({"desc": "unknown"}, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
# My state is still outer and now the unknown beacon
# has joined me at outer.
assert_location_state(hass, "outer")
assert_mobile_tracker_state(hass, "outer", "unknown")
@pytest.mark.usefixtures("context")
async def test_event_beacon_entry_zone_loading_dash(hass: HomeAssistant) -> None:
"""Test the event for beacon zone landing."""
# Make sure the leading - is ignored
# Owntracks uses this to switch on hold
message = build_message({"desc": "-inner"}, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner")
# ------------------------------------------------------------------------
# Mobile Beacon based event entry / exit testing
@pytest.mark.usefixtures("context")
async def test_mobile_enter_move_beacon(hass: HomeAssistant) -> None:
"""Test the movement of a beacon."""
# I am in the outer zone.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# I see the 'keys' beacon. I set the location of the
# beacon_keys tracker to my current device location.
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
assert_mobile_tracker_latitude(hass, LOCATION_MESSAGE["lat"])
assert_mobile_tracker_state(hass, "outer")
# Location update to outside of defined zones.
# I am now 'not home' and neither are my keys.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
assert_location_state(hass, STATE_NOT_HOME)
assert_mobile_tracker_state(hass, STATE_NOT_HOME)
not_home_lat = LOCATION_MESSAGE_NOT_HOME["lat"]
assert_location_latitude(hass, not_home_lat)
assert_mobile_tracker_latitude(hass, not_home_lat)
@pytest.mark.usefixtures("context")
async def test_mobile_enter_exit_region_beacon(hass: HomeAssistant) -> None:
"""Test the enter and the exit of a mobile beacon."""
# I am in the outer zone.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# I see a new mobile beacon
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
assert_mobile_tracker_latitude(hass, OUTER_ZONE["latitude"])
assert_mobile_tracker_state(hass, "outer")
# GPS enter message should move beacon
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
assert_mobile_tracker_state(hass, REGION_GPS_ENTER_MESSAGE["desc"])
# Exit inner zone to outer zone should move beacon to
# center of outer zone
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_mobile_tracker_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_mobile_exit_move_beacon(hass: HomeAssistant) -> None:
"""Test the exit move of a beacon."""
# I am in the outer zone.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
# I see a new mobile beacon
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
assert_mobile_tracker_latitude(hass, OUTER_ZONE["latitude"])
assert_mobile_tracker_state(hass, "outer")
# Exit mobile beacon, should set location
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
assert_mobile_tracker_latitude(hass, OUTER_ZONE["latitude"])
assert_mobile_tracker_state(hass, "outer")
# Move after exit should do nothing
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
assert_mobile_tracker_latitude(hass, OUTER_ZONE["latitude"])
assert_mobile_tracker_state(hass, "outer")
async def test_mobile_multiple_async_enter_exit(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test the multiple entering."""
# Test race condition
for _ in range(20):
async_fire_mqtt_message(
hass, EVENT_TOPIC, json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)
)
async_fire_mqtt_message(
hass, EVENT_TOPIC, json.dumps(MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
)
async_fire_mqtt_message(
hass, EVENT_TOPIC, json.dumps(MOBILE_BEACON_ENTER_EVENT_MESSAGE)
)
await hass.async_block_till_done()
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
assert len(context().mobile_beacons_active["greg_phone"]) == 0
async def test_mobile_multiple_enter_exit(
hass: HomeAssistant, context: OwnTracksContextFactory
) -> None:
"""Test the multiple entering."""
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
assert len(context().mobile_beacons_active["greg_phone"]) == 0
@pytest.mark.usefixtures("context")
async def test_complex_movement(hass: HomeAssistant) -> None:
"""Test a complex sequence representative of real-world use."""
# I am in the outer zone.
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
assert_location_state(hass, "outer")
# gps to inner location and event, as actually happens with OwnTracks
location_message = build_message(
{
"lat": REGION_GPS_ENTER_MESSAGE["lat"],
"lon": REGION_GPS_ENTER_MESSAGE["lon"],
},
LOCATION_MESSAGE,
)
await send_message(hass, LOCATION_TOPIC, location_message)
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
# region beacon enter inner event and location as actually happens
# with OwnTracks
location_message = build_message(
{
"lat": location_message["lat"] + FIVE_M,
"lon": location_message["lon"] + FIVE_M,
},
LOCATION_MESSAGE,
)
await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
# see keys mobile beacon and location message as actually happens
location_message = build_message(
{
"lat": location_message["lat"] + FIVE_M,
"lon": location_message["lon"] + FIVE_M,
},
LOCATION_MESSAGE,
)
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
# Slightly odd, I leave the location by gps before I lose
# sight of the region beacon. This is also a little odd in
# that my GPS coords are now in the 'outer' zone but I did not
# "enter" that zone when I started up so my location is not
# the center of OUTER_ZONE, but rather just my GPS location.
# gps out of inner event and location
location_message = build_message(
{
"lat": REGION_GPS_LEAVE_MESSAGE["lat"],
"lon": REGION_GPS_LEAVE_MESSAGE["lon"],
},
LOCATION_MESSAGE,
)
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_mobile_tracker_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_location_state(hass, "outer")
assert_mobile_tracker_state(hass, "outer")
# region beacon leave inner
location_message = build_message(
{
"lat": location_message["lat"] - FIVE_M,
"lon": location_message["lon"] - FIVE_M,
},
LOCATION_MESSAGE,
)
await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, location_message["lat"])
assert_mobile_tracker_latitude(hass, location_message["lat"])
assert_location_state(hass, "outer")
assert_mobile_tracker_state(hass, "outer")
# lose keys mobile beacon
lost_keys_location_message = build_message(
{
"lat": location_message["lat"] - FIVE_M,
"lon": location_message["lon"] - FIVE_M,
},
LOCATION_MESSAGE,
)
await send_message(hass, LOCATION_TOPIC, lost_keys_location_message)
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
assert_location_latitude(hass, lost_keys_location_message["lat"])
assert_mobile_tracker_latitude(hass, lost_keys_location_message["lat"])
assert_location_state(hass, "outer")
assert_mobile_tracker_state(hass, "outer")
# gps leave outer
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE_NOT_HOME)
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE_OUTER)
assert_location_latitude(hass, LOCATION_MESSAGE_NOT_HOME["lat"])
assert_mobile_tracker_latitude(hass, lost_keys_location_message["lat"])
assert_location_state(hass, "not_home")
assert_mobile_tracker_state(hass, "outer")
# location move not home
location_message = build_message(
{
"lat": LOCATION_MESSAGE_NOT_HOME["lat"] - FIVE_M,
"lon": LOCATION_MESSAGE_NOT_HOME["lon"] - FIVE_M,
},
LOCATION_MESSAGE_NOT_HOME,
)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, location_message["lat"])
assert_mobile_tracker_latitude(hass, lost_keys_location_message["lat"])
assert_location_state(hass, "not_home")
assert_mobile_tracker_state(hass, "outer")
@pytest.mark.usefixtures("context")
async def test_complex_movement_sticky_keys_beacon(hass: HomeAssistant) -> None:
"""Test a complex sequence which was previously broken."""
# I am not_home
await send_message(hass, LOCATION_TOPIC, LOCATION_MESSAGE)
assert_location_state(hass, "outer")
# gps to inner location and event, as actually happens with OwnTracks
location_message = build_message(
{
"lat": REGION_GPS_ENTER_MESSAGE["lat"],
"lon": REGION_GPS_ENTER_MESSAGE["lon"],
},
LOCATION_MESSAGE,
)
await send_message(hass, LOCATION_TOPIC, location_message)
await send_message(hass, EVENT_TOPIC, REGION_GPS_ENTER_MESSAGE)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
# see keys mobile beacon and location message as actually happens
location_message = build_message(
{
"lat": location_message["lat"] + FIVE_M,
"lon": location_message["lon"] + FIVE_M,
},
LOCATION_MESSAGE,
)
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
# region beacon enter inner event and location as actually happens
# with OwnTracks
location_message = build_message(
{
"lat": location_message["lat"] + FIVE_M,
"lon": location_message["lon"] + FIVE_M,
},
LOCATION_MESSAGE,
)
await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
# This sequence of moves would cause keys to follow
# greg_phone around even after the OwnTracks sent
# a mobile beacon 'leave' event for the keys.
# leave keys
await send_message(hass, LOCATION_TOPIC, location_message)
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
# leave inner region beacon
await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
# enter inner region beacon
await send_message(hass, EVENT_TOPIC, REGION_BEACON_ENTER_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_latitude(hass, INNER_ZONE["latitude"])
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
# enter keys
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_ENTER_EVENT_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
# leave keys
await send_message(hass, LOCATION_TOPIC, location_message)
await send_message(hass, EVENT_TOPIC, MOBILE_BEACON_LEAVE_EVENT_MESSAGE)
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
# leave inner region beacon
await send_message(hass, EVENT_TOPIC, REGION_BEACON_LEAVE_MESSAGE)
await send_message(hass, LOCATION_TOPIC, location_message)
assert_location_state(hass, "inner")
assert_mobile_tracker_state(hass, "inner")
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
# GPS leave inner region, I'm in the 'outer' region now
# but on GPS coords
leave_location_message = build_message(
{
"lat": REGION_GPS_LEAVE_MESSAGE["lat"],
"lon": REGION_GPS_LEAVE_MESSAGE["lon"],
},
LOCATION_MESSAGE,
)
await send_message(hass, EVENT_TOPIC, REGION_GPS_LEAVE_MESSAGE)
await send_message(hass, LOCATION_TOPIC, leave_location_message)
assert_location_state(hass, "outer")
assert_mobile_tracker_state(hass, "inner")
assert_location_latitude(hass, REGION_GPS_LEAVE_MESSAGE["lat"])
assert_mobile_tracker_latitude(hass, INNER_ZONE["latitude"])
@pytest.mark.usefixtures("context")
async def test_waypoint_import_simple(hass: HomeAssistant) -> None:
"""Test a simple import of list of waypoints."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
await send_message(hass, WAYPOINTS_TOPIC, waypoints_message)
# Check if it made it into states
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
assert wayp is not None
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[1])
assert wayp is not None
@pytest.mark.usefixtures("context")
async def test_waypoint_import_block(hass: HomeAssistant) -> None:
"""Test import of list of waypoints for blocked user."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message)
# Check if it made it into states
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2])
assert wayp is None
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3])
assert wayp is None
async def test_waypoint_import_no_whitelist(hass: HomeAssistant, setup_comp) -> None:
"""Test import of list of waypoints with no whitelist set."""
await setup_owntracks(
hass,
{
CONF_MAX_GPS_ACCURACY: 200,
CONF_WAYPOINT_IMPORT: True,
CONF_MQTT_TOPIC: "owntracks/#",
},
)
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
await send_message(hass, WAYPOINTS_TOPIC_BLOCKED, waypoints_message)
# Check if it made it into states
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2])
assert wayp is not None
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3])
assert wayp is not None
@pytest.mark.usefixtures("context")
async def test_waypoint_import_bad_json(hass: HomeAssistant) -> None:
"""Test importing a bad JSON payload."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
await send_message(hass, WAYPOINTS_TOPIC, waypoints_message, True)
# Check if it made it into states
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[2])
assert wayp is None
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[3])
assert wayp is None
@pytest.mark.usefixtures("context")
async def test_waypoint_import_existing(hass: HomeAssistant) -> None:
"""Test importing a zone that exists."""
waypoints_message = WAYPOINTS_EXPORTED_MESSAGE.copy()
await send_message(hass, WAYPOINTS_TOPIC, waypoints_message)
# Get the first waypoint exported
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
# Send an update
waypoints_message = WAYPOINTS_UPDATED_MESSAGE.copy()
await send_message(hass, WAYPOINTS_TOPIC, waypoints_message)
new_wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
assert wayp == new_wayp
@pytest.mark.usefixtures("context")
async def test_single_waypoint_import(hass: HomeAssistant) -> None:
"""Test single waypoint message."""
waypoint_message = WAYPOINT_MESSAGE.copy()
await send_message(hass, WAYPOINT_TOPIC, waypoint_message)
wayp = hass.states.get(WAYPOINT_ENTITY_NAMES[0])
assert wayp is not None
@pytest.mark.usefixtures("context")
async def test_not_implemented_message(hass: HomeAssistant) -> None:
"""Handle not implemented message type."""
patch_handler = patch(
"homeassistant.components.owntracks.messages.async_handle_not_impl_msg",
return_value=False,
)
patch_handler.start()
assert not await send_message(hass, LWT_TOPIC, LWT_MESSAGE)
patch_handler.stop()
@pytest.mark.usefixtures("context")
async def test_unsupported_message(hass: HomeAssistant) -> None:
"""Handle not implemented message type."""
patch_handler = patch(
"homeassistant.components.owntracks.messages.async_handle_unsupported_msg",
return_value=False,
)
patch_handler.start()
assert not await send_message(hass, BAD_TOPIC, BAD_MESSAGE)
patch_handler.stop()
def generate_ciphers(secret):
"""Generate test ciphers for the DEFAULT_LOCATION_MESSAGE."""
# PyNaCl ciphertext generation will fail if the module
# cannot be imported. However, the test for decryption
# also relies on this library and won't be run without it.
keylen = SecretBox.KEY_SIZE
key = secret.encode("utf-8")
key = key[:keylen]
key = key.ljust(keylen, b"\0")
msg = json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8")
ctxt = SecretBox(key).encrypt(msg, encoder=Base64Encoder).decode("utf-8")
mctxt = base64.b64encode(
pickle.dumps(
(
secret.encode("utf-8"),
json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8"),
)
)
).decode("utf-8")
return ctxt, mctxt
TEST_SECRET_KEY = "s3cretkey"
CIPHERTEXT, MOCK_CIPHERTEXT = generate_ciphers(TEST_SECRET_KEY)
ENCRYPTED_LOCATION_MESSAGE = {
# Encrypted version of LOCATION_MESSAGE using libsodium and TEST_SECRET_KEY
"_type": "encrypted",
"data": CIPHERTEXT,
}
MOCK_ENCRYPTED_LOCATION_MESSAGE = {
# Mock-encrypted version of LOCATION_MESSAGE using pickle
"_type": "encrypted",
"data": MOCK_CIPHERTEXT,
}
def mock_cipher():
"""Return a dummy pickle-based cipher."""
def mock_decrypt(ciphertext, key):
"""Decrypt/unpickle."""
(mkey, plaintext) = pickle.loads(base64.b64decode(ciphertext))
if key != mkey:
raise ValueError
return plaintext
return len(TEST_SECRET_KEY), mock_decrypt
@pytest.fixture
def config_context(setup_comp: None) -> Generator[None]:
"""Set up the mocked context."""
patch_load = patch(
"homeassistant.components.device_tracker.async_load_config",
return_value=[],
)
patch_load.start()
patch_save = patch(
"homeassistant.components.device_tracker.DeviceTracker.async_update_config"
)
patch_save.start()
yield
patch_load.stop()
patch_save.stop()
@pytest.fixture(name="not_supports_encryption")
def mock_not_supports_encryption():
"""Mock non successful nacl import."""
with patch(
"homeassistant.components.owntracks.messages.supports_encryption",
return_value=False,
):
yield
@pytest.fixture(name="get_cipher_error")
def mock_get_cipher_error():
"""Mock non successful cipher."""
with patch(
"homeassistant.components.owntracks.messages.get_cipher", side_effect=OSError()
):
yield
@patch("homeassistant.components.owntracks.messages.get_cipher", mock_cipher)
async def test_encrypted_payload(hass: HomeAssistant, setup_comp) -> None:
"""Test encrypted payload."""
await setup_owntracks(hass, {CONF_SECRET: TEST_SECRET_KEY})
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
@patch("homeassistant.components.owntracks.messages.get_cipher", mock_cipher)
async def test_encrypted_payload_topic_key(hass: HomeAssistant, setup_comp) -> None:
"""Test encrypted payload with a topic key."""
await setup_owntracks(hass, {CONF_SECRET: {LOCATION_TOPIC: TEST_SECRET_KEY}})
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
async def test_encrypted_payload_not_supports_encryption(
hass: HomeAssistant, setup_comp, not_supports_encryption
) -> None:
"""Test encrypted payload with no supported encryption."""
await setup_owntracks(hass, {CONF_SECRET: TEST_SECRET_KEY})
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert hass.states.get(DEVICE_TRACKER_STATE) is None
async def test_encrypted_payload_get_cipher_error(
hass: HomeAssistant, setup_comp, get_cipher_error
) -> None:
"""Test encrypted payload with no supported encryption."""
await setup_owntracks(hass, {CONF_SECRET: TEST_SECRET_KEY})
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert hass.states.get(DEVICE_TRACKER_STATE) is None
@patch("homeassistant.components.owntracks.messages.get_cipher", mock_cipher)
async def test_encrypted_payload_no_key(hass: HomeAssistant, setup_comp) -> None:
"""Test encrypted payload with no key, ."""
assert hass.states.get(DEVICE_TRACKER_STATE) is None
await setup_owntracks(hass, {CONF_SECRET: {}})
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert hass.states.get(DEVICE_TRACKER_STATE) is None
@patch("homeassistant.components.owntracks.messages.get_cipher", mock_cipher)
async def test_encrypted_payload_wrong_key(hass: HomeAssistant, setup_comp) -> None:
"""Test encrypted payload with wrong key."""
await setup_owntracks(hass, {CONF_SECRET: "wrong key"})
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert hass.states.get(DEVICE_TRACKER_STATE) is None
@patch("homeassistant.components.owntracks.messages.get_cipher", mock_cipher)
async def test_encrypted_payload_wrong_topic_key(
hass: HomeAssistant, setup_comp
) -> None:
"""Test encrypted payload with wrong topic key."""
await setup_owntracks(hass, {CONF_SECRET: {LOCATION_TOPIC: "wrong key"}})
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert hass.states.get(DEVICE_TRACKER_STATE) is None
@patch("homeassistant.components.owntracks.messages.get_cipher", mock_cipher)
async def test_encrypted_payload_no_topic_key(hass: HomeAssistant, setup_comp) -> None:
"""Test encrypted payload with no topic key."""
await setup_owntracks(
hass, {CONF_SECRET: {f"owntracks/{USER}/otherdevice": "foobar"}}
)
await send_message(hass, LOCATION_TOPIC, MOCK_ENCRYPTED_LOCATION_MESSAGE)
assert hass.states.get(DEVICE_TRACKER_STATE) is None
async def test_encrypted_payload_libsodium(hass: HomeAssistant, setup_comp) -> None:
"""Test sending encrypted message payload."""
await setup_owntracks(hass, {CONF_SECRET: TEST_SECRET_KEY})
await send_message(hass, LOCATION_TOPIC, ENCRYPTED_LOCATION_MESSAGE)
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
async def test_customized_mqtt_topic(hass: HomeAssistant, setup_comp) -> None:
"""Test subscribing to a custom mqtt topic."""
await setup_owntracks(hass, {CONF_MQTT_TOPIC: "mytracks/#"})
topic = f"mytracks/{USER}/{DEVICE}"
await send_message(hass, topic, LOCATION_MESSAGE)
assert_location_latitude(hass, LOCATION_MESSAGE["lat"])
async def test_region_mapping(hass: HomeAssistant, setup_comp) -> None:
"""Test region to zone mapping."""
await setup_owntracks(hass, {CONF_REGION_MAPPING: {"foo": "inner"}})
hass.states.async_set("zone.inner", "zoning", INNER_ZONE)
message = build_message({"desc": "foo"}, REGION_GPS_ENTER_MESSAGE)
assert message["desc"] == "foo"
await send_message(hass, EVENT_TOPIC, message)
assert_location_state(hass, "inner")
async def test_restore_state(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
"""Test that we can restore state."""
entry = MockConfigEntry(
domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"}
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
client = await hass_client()
resp = await client.post(
"/api/webhook/owntracks_test",
json=LOCATION_MESSAGE,
headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"},
)
assert resp.status == 200
await hass.async_block_till_done()
state_1 = hass.states.get("device_tracker.paulus_pixel")
assert state_1 is not None
await hass.config_entries.async_reload(entry.entry_id)
await hass.async_block_till_done()
state_2 = hass.states.get("device_tracker.paulus_pixel")
assert state_2 is not None
assert state_1 is not state_2
assert state_1.state == state_2.state
assert state_1.name == state_2.name
assert state_1.attributes["latitude"] == state_2.attributes["latitude"]
assert state_1.attributes["longitude"] == state_2.attributes["longitude"]
assert state_1.attributes["battery_level"] == state_2.attributes["battery_level"]
assert state_1.attributes["source_type"] == state_2.attributes["source_type"]
async def test_returns_empty_friends(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
"""Test that an empty list of persons' locations is returned."""
entry = MockConfigEntry(
domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"}
)
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
client = await hass_client()
resp = await client.post(
"/api/webhook/owntracks_test",
json=LOCATION_MESSAGE,
headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"},
)
assert resp.status == 200
assert await resp.text() == "[]"
async def test_returns_array_friends(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:
"""Test that a list of persons' current locations is returned."""
otracks = MockConfigEntry(
domain="owntracks", data={"webhook_id": "owntracks_test", "secret": "abcd"}
)
otracks.add_to_hass(hass)
await hass.config_entries.async_setup(otracks.entry_id)
await hass.async_block_till_done()
# Setup device_trackers
assert await async_setup_component(
hass,
"person",
{
"person": [
{
"name": "person 1",
"id": "person1",
"device_trackers": ["device_tracker.person_1_tracker_1"],
},
{
"name": "person2",
"id": "person2",
"device_trackers": ["device_tracker.person_2_tracker_1"],
},
]
},
)
hass.states.async_set(
"device_tracker.person_1_tracker_1", "home", {"latitude": 10, "longitude": 20}
)
client = await hass_client()
resp = await client.post(
"/api/webhook/owntracks_test",
json=LOCATION_MESSAGE,
headers={"X-Limit-u": "Paulus", "X-Limit-d": "Pixel"},
)
assert resp.status == 200
response_json = json.loads(await resp.text())
assert response_json[0]["lat"] == 10
assert response_json[0]["lon"] == 20
assert response_json[0]["tid"] == "p1"