Merge Home & Phone into a three-tab Home that knows your house
Home is now Overview | My Home | Phone. Overview leads with quick-action tiles (focus, Do Not Disturb, health, snapshots, storage), keeps the findings card — updates fold in, the reclaim-space prompt is gone on purpose — and adds glance cards, the next calendar event, and weather. My Home groups every light by Home Assistant area: the helper gained an `areas` command (one REST template render, no websocket), and the rooms degrade to a flat list on setups without areas. The favorites editor and connection card moved intact. Phone gains a vitals strip — battery and cell signal read from KDE Connect's plugin D-Bus objects, where absence is data, not an error — beside ring, clipboard, send-a-file, and the BlueBubbles handoff. The retired home-phone id resolves to my-home forever via a new alias map in SettingsRoutes (with a hasOwnProperty guard so prototype names cannot leak into settingsPage). Storage no longer claims 0 B free — the old page read a field the disks helper never emitted. Contracts updated alongside; per the new workflow, the full suite runs once at the end of the redesign (see the test backlog note). Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -193,6 +193,8 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
"paired": True,
|
||||
"reachable": False,
|
||||
"actions": ["clipboard", "ring", "share"],
|
||||
"battery": None,
|
||||
"signal": None,
|
||||
}
|
||||
],
|
||||
"error": "",
|
||||
@@ -269,7 +271,11 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
|
||||
return subprocess.CompletedProcess(command, 0, '{"type":"as","data":[]}\n', "")
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
|
||||
is_dbus_device = dbus_id in command[4]
|
||||
# The reachable CLI device gets its vitals read; nothing answers
|
||||
# for the plugin objects here.
|
||||
if not command[5].endswith(cli_id) and not command[5].endswith(dbus_id):
|
||||
return subprocess.CompletedProcess(command, 1, "", "No such object")
|
||||
is_dbus_device = command[5].endswith(dbus_id)
|
||||
values = {
|
||||
"name": '{"type":"s","data":"Fixture iPhone"}\n',
|
||||
"type": '{"type":"s","data":"phone"}\n' if is_dbus_device else '{"type":"s","data":"desktop"}\n',
|
||||
@@ -284,6 +290,190 @@ class KdeConnectBridgeTest(unittest.TestCase):
|
||||
|
||||
self.assertEqual({device["id"] for device in status["devices"]}, {cli_id, dbus_id})
|
||||
|
||||
def test_vitals_are_read_for_a_reachable_phone(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":82}\n',
|
||||
"isCharging": '{"type":"b","data":true}\n',
|
||||
"hasBattery": '{"type":"b","data":true}\n',
|
||||
"cellularNetworkType": '{"type":"s","data":"LTE"}\n',
|
||||
"cellularNetworkStrength": '{"type":"i","data":3}\n',
|
||||
},
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertEqual(device["battery"], {"charge": 82, "charging": True})
|
||||
self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3})
|
||||
|
||||
def test_absent_plugin_objects_are_no_data_not_an_error(self) -> None:
|
||||
"""A phone with the battery plugin off is not a broken status read.
|
||||
|
||||
kdeconnectd only publishes a plugin's object while the device is paired,
|
||||
reachable and the plugin is loaded, so busctl exiting non-zero here is
|
||||
the ordinary answer "nothing to report" -- the device still appears,
|
||||
with null vitals and no error on the envelope.
|
||||
"""
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{},
|
||||
)
|
||||
|
||||
status = bridge.collect_status(runner)
|
||||
|
||||
self.assertEqual(status["available"], True)
|
||||
self.assertEqual(status["error"], "")
|
||||
self.assertIsNone(status["devices"][0]["battery"])
|
||||
self.assertIsNone(status["devices"][0]["signal"])
|
||||
|
||||
def test_negative_charge_reports_no_battery(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":-1}\n',
|
||||
"isCharging": '{"type":"b","data":false}\n',
|
||||
"hasBattery": '{"type":"b","data":true}\n',
|
||||
"cellularNetworkType": '{"type":"s","data":"LTE"}\n',
|
||||
"cellularNetworkStrength": '{"type":"i","data":3}\n',
|
||||
},
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertIsNone(device["battery"])
|
||||
self.assertEqual(device["signal"], {"networkType": "LTE", "strength": 3})
|
||||
|
||||
def test_a_phone_without_a_battery_reports_none(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":0}\n',
|
||||
"isCharging": '{"type":"b","data":false}\n',
|
||||
"hasBattery": '{"type":"b","data":false}\n',
|
||||
},
|
||||
)
|
||||
|
||||
self.assertIsNone(bridge.collect_status(runner)["devices"][0]["battery"])
|
||||
|
||||
def test_strength_of_minus_one_reports_no_signal(self) -> None:
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired and reachable)\n",
|
||||
{
|
||||
"charge": '{"type":"i","data":82}\n',
|
||||
"isCharging": '{"type":"b","data":false}\n',
|
||||
"hasBattery": '{"type":"b","data":true}\n',
|
||||
"cellularNetworkType": '{"type":"s","data":"Unknown"}\n',
|
||||
"cellularNetworkStrength": '{"type":"i","data":-1}\n',
|
||||
},
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertEqual(device["battery"], {"charge": 82, "charging": False})
|
||||
self.assertIsNone(device["signal"])
|
||||
|
||||
def test_an_unreachable_device_is_never_asked_for_vitals(self) -> None:
|
||||
"""Plugin objects cannot exist for an absent phone, so asking is waste.
|
||||
|
||||
The runner fails the test outright if a plugin path is touched, which is
|
||||
what keeps a busctl call per device off the path taken every 30 seconds
|
||||
by the phone that is simply not home.
|
||||
"""
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
runner = vitals_runner(
|
||||
device_id,
|
||||
f"- Fixture iPhone: {device_id} (paired)\n",
|
||||
{},
|
||||
forbid_plugin_reads=True,
|
||||
)
|
||||
|
||||
device = bridge.collect_status(runner)["devices"][0]
|
||||
|
||||
self.assertIsNone(device["battery"])
|
||||
self.assertIsNone(device["signal"])
|
||||
|
||||
def test_an_action_does_not_wait_on_vitals(self) -> None:
|
||||
"""Ringing a phone reads identity only -- no charge between tap and ring."""
|
||||
device_id = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"
|
||||
listing = f"- Fixture iPhone: {device_id} (paired and reachable)\n"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
if command == ["kdeconnect-cli", "-d", device_id, "--ring"]:
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
'{"type":"as","data":["kdeconnect_findmyphone"]}\n',
|
||||
"",
|
||||
)
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
|
||||
if not command[5].endswith(device_id):
|
||||
raise AssertionError(f"vitals read on the action path: {command}")
|
||||
return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "")
|
||||
if command[:3] == ["busctl", "--user", "tree"]:
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
raise AssertionError(command)
|
||||
|
||||
self.assertEqual(
|
||||
bridge.invoke_action("ring", device_id, runner=runner),
|
||||
{"ok": True, "action": "ring", "error": ""},
|
||||
)
|
||||
|
||||
|
||||
def vitals_runner(
|
||||
device_id: str,
|
||||
listing: str,
|
||||
plugin_values: dict[str, str],
|
||||
*,
|
||||
forbid_plugin_reads: bool = False,
|
||||
) -> bridge.Runner:
|
||||
"""A fake busctl/kdeconnect-cli for one CLI-listed device.
|
||||
|
||||
Reads of the device object itself always answer; reads of a plugin object
|
||||
answer only from `plugin_values`, and exit non-zero for anything missing --
|
||||
which is exactly how busctl behaves when the object is not published.
|
||||
"""
|
||||
device_path = f"/modules/kdeconnect/devices/{device_id}"
|
||||
|
||||
def runner(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]:
|
||||
if command == ["kdeconnect-cli", "--list-devices"]:
|
||||
return subprocess.CompletedProcess(command, 0, listing, "")
|
||||
if command[:3] == ["busctl", "--user", "tree"]:
|
||||
return subprocess.CompletedProcess(command, 0, "", "")
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "call"]:
|
||||
return subprocess.CompletedProcess(
|
||||
command,
|
||||
0,
|
||||
'{"type":"as","data":["kdeconnect_findmyphone"]}\n',
|
||||
"",
|
||||
)
|
||||
if command[:4] == ["busctl", "--user", "--json=short", "get-property"]:
|
||||
if command[5] == device_path:
|
||||
return subprocess.CompletedProcess(command, 0, '{"type":"s","data":"phone"}\n', "")
|
||||
if forbid_plugin_reads:
|
||||
raise AssertionError(f"unexpected plugin read: {command}")
|
||||
member = command[-1]
|
||||
if member not in plugin_values:
|
||||
return subprocess.CompletedProcess(command, 1, "", "No such object")
|
||||
return subprocess.CompletedProcess(command, 0, plugin_values[member], "")
|
||||
raise AssertionError(command)
|
||||
|
||||
return runner
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
Reference in New Issue
Block a user