fibioslocation 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- fibioslocation-1.0.0/PKG-INFO +50 -0
- fibioslocation-1.0.0/README.md +35 -0
- fibioslocation-1.0.0/fibioslocation.egg-info/PKG-INFO +50 -0
- fibioslocation-1.0.0/fibioslocation.egg-info/SOURCES.txt +9 -0
- fibioslocation-1.0.0/fibioslocation.egg-info/dependency_links.txt +1 -0
- fibioslocation-1.0.0/fibioslocation.egg-info/entry_points.txt +2 -0
- fibioslocation-1.0.0/fibioslocation.egg-info/requires.txt +3 -0
- fibioslocation-1.0.0/fibioslocation.egg-info/top_level.txt +1 -0
- fibioslocation-1.0.0/fibioslocation.py +424 -0
- fibioslocation-1.0.0/pyproject.toml +28 -0
- fibioslocation-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fibioslocation
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fetch iCloud Find My device locations and push to Fibaro HC3
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: fibaro,icloud,location,hc3,home-automation
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: Home Automation
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pyicloud
|
|
13
|
+
Requires-Dist: rich
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
|
|
16
|
+
# fibioslocation
|
|
17
|
+
|
|
18
|
+
Fetch iCloud Find My device locations (your own + family) and push them to a [Fibaro HC3](https://www.fibaro.com/) home automation hub.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install fibioslocation
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
fibioslocation
|
|
30
|
+
fibioslocation --email you@icloud.com
|
|
31
|
+
fibioslocation --interval 60 # poll every 60 s instead of 3 min
|
|
32
|
+
fibioslocation --once # single shot, then exit
|
|
33
|
+
fibioslocation --no-hc3 # display only, don't push to HC3
|
|
34
|
+
fibioslocation --debug # diagnose 2FA options
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Credentials
|
|
38
|
+
|
|
39
|
+
Create `~/.env` with:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
HC3_HOST=192.168.1.10
|
|
43
|
+
HC3_USER=admin
|
|
44
|
+
HC3_PASSWORD=secret
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- Python ≥ 3.10
|
|
50
|
+
- `pyicloud`, `rich`, `requests`
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# fibioslocation
|
|
2
|
+
|
|
3
|
+
Fetch iCloud Find My device locations (your own + family) and push them to a [Fibaro HC3](https://www.fibaro.com/) home automation hub.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pip install fibioslocation
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## Usage
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
fibioslocation
|
|
15
|
+
fibioslocation --email you@icloud.com
|
|
16
|
+
fibioslocation --interval 60 # poll every 60 s instead of 3 min
|
|
17
|
+
fibioslocation --once # single shot, then exit
|
|
18
|
+
fibioslocation --no-hc3 # display only, don't push to HC3
|
|
19
|
+
fibioslocation --debug # diagnose 2FA options
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
## Credentials
|
|
23
|
+
|
|
24
|
+
Create `~/.env` with:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
HC3_HOST=192.168.1.10
|
|
28
|
+
HC3_USER=admin
|
|
29
|
+
HC3_PASSWORD=secret
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Requirements
|
|
33
|
+
|
|
34
|
+
- Python ≥ 3.10
|
|
35
|
+
- `pyicloud`, `rich`, `requests`
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fibioslocation
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Fetch iCloud Find My device locations and push to Fibaro HC3
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Keywords: fibaro,icloud,location,hc3,home-automation
|
|
7
|
+
Classifier: Programming Language :: Python :: 3
|
|
8
|
+
Classifier: Operating System :: OS Independent
|
|
9
|
+
Classifier: Topic :: Home Automation
|
|
10
|
+
Requires-Python: >=3.10
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
Requires-Dist: pyicloud
|
|
13
|
+
Requires-Dist: rich
|
|
14
|
+
Requires-Dist: requests
|
|
15
|
+
|
|
16
|
+
# fibioslocation
|
|
17
|
+
|
|
18
|
+
Fetch iCloud Find My device locations (your own + family) and push them to a [Fibaro HC3](https://www.fibaro.com/) home automation hub.
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
pip install fibioslocation
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
## Usage
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
fibioslocation
|
|
30
|
+
fibioslocation --email you@icloud.com
|
|
31
|
+
fibioslocation --interval 60 # poll every 60 s instead of 3 min
|
|
32
|
+
fibioslocation --once # single shot, then exit
|
|
33
|
+
fibioslocation --no-hc3 # display only, don't push to HC3
|
|
34
|
+
fibioslocation --debug # diagnose 2FA options
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Credentials
|
|
38
|
+
|
|
39
|
+
Create `~/.env` with:
|
|
40
|
+
|
|
41
|
+
```
|
|
42
|
+
HC3_HOST=192.168.1.10
|
|
43
|
+
HC3_USER=admin
|
|
44
|
+
HC3_PASSWORD=secret
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Requirements
|
|
48
|
+
|
|
49
|
+
- Python ≥ 3.10
|
|
50
|
+
- `pyicloud`, `rich`, `requests`
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
fibioslocation.py
|
|
3
|
+
pyproject.toml
|
|
4
|
+
fibioslocation.egg-info/PKG-INFO
|
|
5
|
+
fibioslocation.egg-info/SOURCES.txt
|
|
6
|
+
fibioslocation.egg-info/dependency_links.txt
|
|
7
|
+
fibioslocation.egg-info/entry_points.txt
|
|
8
|
+
fibioslocation.egg-info/requires.txt
|
|
9
|
+
fibioslocation.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
fibioslocation
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
fibioslocation.py - Fetch iCloud Find My device locations and push to Fibaro HC3.
|
|
4
|
+
|
|
5
|
+
Every 3 minutes (configurable) it:
|
|
6
|
+
1. Fetches all device locations from iCloud (own + family)
|
|
7
|
+
2. Pushes a JSON payload to the HC3 via:
|
|
8
|
+
GET http://<hc3>/api/callAction?deviceID=<id>&name=iosLocation&arg1=<json>
|
|
9
|
+
|
|
10
|
+
Requirements:
|
|
11
|
+
pip install pyicloud rich
|
|
12
|
+
|
|
13
|
+
Usage:
|
|
14
|
+
python fibioslocation.py
|
|
15
|
+
python fibioslocation.py --email you@icloud.com
|
|
16
|
+
python fibioslocation.py --interval 60 # poll every 60s instead of 180s
|
|
17
|
+
python fibioslocation.py --no-hc3 # display only, don't push
|
|
18
|
+
python fibioslocation.py --debug # print raw 2FA options from Apple
|
|
19
|
+
|
|
20
|
+
Credentials are read from ~/.env (HC3_HOST, HC3_USER, HC3_PASSWORD).
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import getpass
|
|
25
|
+
import json
|
|
26
|
+
import os
|
|
27
|
+
import re
|
|
28
|
+
import sys
|
|
29
|
+
import time
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
import requests as _requests
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
from pyicloud import PyiCloudService
|
|
36
|
+
from pyicloud.exceptions import PyiCloudFailedLoginException, PyiCloudAPIResponseException
|
|
37
|
+
except ImportError:
|
|
38
|
+
print("ERROR: pyicloud not installed. Run: pip install pyicloud rich")
|
|
39
|
+
sys.exit(1)
|
|
40
|
+
|
|
41
|
+
try:
|
|
42
|
+
from rich.console import Console
|
|
43
|
+
from rich.table import Table
|
|
44
|
+
from rich import box
|
|
45
|
+
except ImportError:
|
|
46
|
+
print("ERROR: rich not installed. Run: pip install pyicloud rich")
|
|
47
|
+
sys.exit(1)
|
|
48
|
+
|
|
49
|
+
console = Console()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ── .env loader ──────────────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
def load_env(path: str = "~/.env") -> dict[str, str]:
|
|
55
|
+
"""Parse a simple KEY=value .env file, ignoring comments and blank lines."""
|
|
56
|
+
result: dict[str, str] = {}
|
|
57
|
+
try:
|
|
58
|
+
with open(os.path.expanduser(path)) as f:
|
|
59
|
+
for line in f:
|
|
60
|
+
line = line.strip()
|
|
61
|
+
if not line or line.startswith("#"):
|
|
62
|
+
continue
|
|
63
|
+
m = re.match(r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*"?([^"]*)"?$', line)
|
|
64
|
+
if m:
|
|
65
|
+
result[m.group(1)] = m.group(2)
|
|
66
|
+
except FileNotFoundError:
|
|
67
|
+
pass
|
|
68
|
+
return result
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
ENV = load_env()
|
|
72
|
+
|
|
73
|
+
# HC3 defaults (overridable via CLI args)
|
|
74
|
+
HC3_HOST = ENV.get("HC3_HOST", "hc3")
|
|
75
|
+
HC3_USER = ENV.get("HC3_USER", "admin")
|
|
76
|
+
HC3_PASS = ENV.get("HC3_PASSWORD", "")
|
|
77
|
+
HC3_DEVICE = 4200
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# ── helpers ──────────────────────────────────────────────────────────────────
|
|
81
|
+
|
|
82
|
+
def format_time(timestamp_ms: int | None) -> str:
|
|
83
|
+
"""Convert iCloud ms-epoch timestamp to readable local time."""
|
|
84
|
+
if not timestamp_ms:
|
|
85
|
+
return "?"
|
|
86
|
+
dt = datetime.fromtimestamp(timestamp_ms / 1000)
|
|
87
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def battery_bar(level: float | None) -> str:
|
|
91
|
+
"""Return a small visual bar for battery level (0.0-1.0 or 0-100)."""
|
|
92
|
+
if level is None:
|
|
93
|
+
return "?"
|
|
94
|
+
pct = int(level * 100) if level <= 1.0 else int(level)
|
|
95
|
+
filled = pct // 10
|
|
96
|
+
bar = "█" * filled + "░" * (10 - filled)
|
|
97
|
+
colour = "green" if pct > 40 else ("yellow" if pct > 15 else "red")
|
|
98
|
+
return f"[{colour}]{bar}[/{colour}] {pct}%"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def get_map_link(lat: float, lon: float) -> str:
|
|
102
|
+
"""Return an Apple Maps URL for the given coordinates."""
|
|
103
|
+
return f"https://maps.apple.com/?q={lat},{lon}"
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
# ── iCloud login ──────────────────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
def _request_sms_code(api: PyiCloudService) -> bool:
|
|
109
|
+
"""
|
|
110
|
+
Explicitly ask Apple to send a 2FA code via SMS to the user's trusted phone.
|
|
111
|
+
Uses Apple's internal auth endpoint (reverse-engineered, same as pyicloud uses).
|
|
112
|
+
"""
|
|
113
|
+
auth_data = api._auth_data
|
|
114
|
+
pnv = auth_data.get("phoneNumberVerification") or auth_data
|
|
115
|
+
phone = pnv.get("trustedPhoneNumber") or (
|
|
116
|
+
(pnv.get("trustedPhoneNumbers") or [None])[0]
|
|
117
|
+
)
|
|
118
|
+
if not phone:
|
|
119
|
+
return False
|
|
120
|
+
phone_id = phone.get("id")
|
|
121
|
+
non_fteu = phone.get("nonFTEU", False)
|
|
122
|
+
headers = api._get_auth_headers({"Accept": "application/json"})
|
|
123
|
+
try:
|
|
124
|
+
api.session.put(
|
|
125
|
+
f"{api._auth_endpoint}/verify/phone",
|
|
126
|
+
json={"phoneNumber": {"id": phone_id, "nonFTEU": non_fteu}, "mode": "sms"},
|
|
127
|
+
headers=headers,
|
|
128
|
+
)
|
|
129
|
+
# Patch auth_data so validate_2fa_code uses SMS path
|
|
130
|
+
api._auth_data["mode"] = "sms"
|
|
131
|
+
api._auth_data["trustedPhoneNumber"] = phone
|
|
132
|
+
return True
|
|
133
|
+
except Exception as exc:
|
|
134
|
+
console.print(f"[dim]SMS request failed: {exc}[/dim]")
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def login(email: str, password: str, debug: bool = False) -> PyiCloudService:
|
|
139
|
+
console.print(f"\n[bold cyan]Connecting to iCloud as[/bold cyan] [yellow]{email}[/yellow] …")
|
|
140
|
+
try:
|
|
141
|
+
api = PyiCloudService(apple_id=email, password=password, with_family=True)
|
|
142
|
+
except PyiCloudFailedLoginException as exc:
|
|
143
|
+
console.print(f"[bold red]Login failed:[/bold red] {exc}")
|
|
144
|
+
sys.exit(1)
|
|
145
|
+
|
|
146
|
+
if api.requires_2fa:
|
|
147
|
+
console.print("[bold yellow]Two-factor authentication required (HSA2).[/bold yellow]")
|
|
148
|
+
|
|
149
|
+
# _auth_data may be empty if pyicloud used a cached session token and
|
|
150
|
+
# skipped SRP authentication. Fetch fresh auth options from Apple now.
|
|
151
|
+
if not api._auth_data:
|
|
152
|
+
try:
|
|
153
|
+
api._auth_data = api._get_mfa_auth_options()
|
|
154
|
+
except Exception:
|
|
155
|
+
pass
|
|
156
|
+
|
|
157
|
+
auth_data = api._auth_data
|
|
158
|
+
|
|
159
|
+
if debug:
|
|
160
|
+
import json
|
|
161
|
+
console.print("[dim]Raw auth options from Apple:[/dim]")
|
|
162
|
+
console.print(json.dumps(auth_data, indent=2, default=str))
|
|
163
|
+
|
|
164
|
+
# Apple nests the useful fields under "phoneNumberVerification" in newer API responses
|
|
165
|
+
pnv = auth_data.get("phoneNumberVerification") or auth_data
|
|
166
|
+
mode = pnv.get("mode", auth_data.get("mode", "trusteddevice"))
|
|
167
|
+
phones = pnv.get("trustedPhoneNumbers") or (
|
|
168
|
+
[pnv["trustedPhoneNumber"]] if pnv.get("trustedPhoneNumber") else []
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
if debug:
|
|
172
|
+
console.print(f"[dim]mode={mode!r} trusted phones found: {len(phones)}[/dim]\n")
|
|
173
|
+
|
|
174
|
+
if mode == "sms":
|
|
175
|
+
# Apple already decided to send SMS
|
|
176
|
+
phone_display = phones[0].get("numberWithDialCode", "your phone") if phones else "your phone"
|
|
177
|
+
console.print(f"[green]A 6-digit code has been sent via SMS to {phone_display}.[/green]")
|
|
178
|
+
else:
|
|
179
|
+
# Default: trusted device push
|
|
180
|
+
console.print("[dim]Apple should pop up a 6-digit code on your trusted iPhone/Mac.[/dim]")
|
|
181
|
+
if phones:
|
|
182
|
+
phone_display = phones[0].get("numberWithDialCode", f'phone id={phones[0].get("id")}')
|
|
183
|
+
console.print(f"[dim]SMS fallback available: {phone_display}[/dim]\n")
|
|
184
|
+
choice = console.input(
|
|
185
|
+
"[bold]Press [green]Enter[/green] to wait for device push, "
|
|
186
|
+
"or type [yellow]sms[/yellow] to receive an SMS code instead: [/bold]"
|
|
187
|
+
).strip().lower()
|
|
188
|
+
if choice == "sms":
|
|
189
|
+
console.print("Requesting SMS code …")
|
|
190
|
+
if _request_sms_code(api):
|
|
191
|
+
console.print(f"[green]SMS sent to {phone_display}.[/green]")
|
|
192
|
+
else:
|
|
193
|
+
console.print("[yellow]SMS request failed — waiting for device push code.[/yellow]")
|
|
194
|
+
else:
|
|
195
|
+
console.print("[dim]No trusted phone numbers found — only device push is available.[/dim]")
|
|
196
|
+
console.print("[yellow]If no popup appears, make sure your Apple ID has a trusted phone number registered at appleid.apple.com[/yellow]\n")
|
|
197
|
+
|
|
198
|
+
code = console.input("Enter 6-digit code: ").strip()
|
|
199
|
+
result = api.validate_2fa_code(code)
|
|
200
|
+
if not result:
|
|
201
|
+
console.print("[bold red]Invalid 2FA code.[/bold red]")
|
|
202
|
+
sys.exit(1)
|
|
203
|
+
if not api.is_trusted_session:
|
|
204
|
+
console.print("Trusting this session …")
|
|
205
|
+
api.trust_session()
|
|
206
|
+
|
|
207
|
+
elif api.requires_2sa:
|
|
208
|
+
console.print("[bold yellow]Two-step verification required.[/bold yellow]")
|
|
209
|
+
devices = api.trusted_devices
|
|
210
|
+
for i, device in enumerate(devices):
|
|
211
|
+
name = device.get("deviceName") or f"SMS to {device.get('phoneNumber', '?')}"
|
|
212
|
+
console.print(f" [{i}] {name}")
|
|
213
|
+
idx = int(console.input("Choose device index: "))
|
|
214
|
+
device = devices[idx]
|
|
215
|
+
if not api.send_verification_code(device):
|
|
216
|
+
console.print("[bold red]Failed to send verification code.[/bold red]")
|
|
217
|
+
sys.exit(1)
|
|
218
|
+
code = console.input("Enter the verification code: ").strip()
|
|
219
|
+
if not api.validate_verification_code(device, code):
|
|
220
|
+
console.print("[bold red]Invalid verification code.[/bold red]")
|
|
221
|
+
sys.exit(1)
|
|
222
|
+
|
|
223
|
+
console.print("[bold green]✓ Logged in successfully.[/bold green]\n")
|
|
224
|
+
return api
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ── HC3 push ──────────────────────────────────────────────────────────────────
|
|
228
|
+
|
|
229
|
+
def push_to_hc3(payload: list[dict], host: str, user: str, password: str,
|
|
230
|
+
device_id: int = HC3_DEVICE) -> bool:
|
|
231
|
+
"""
|
|
232
|
+
Push device locations to HC3 via:
|
|
233
|
+
POST http://<host>/api/devices/<id>/action/iosLocation
|
|
234
|
+
{"args": [<json data>]}
|
|
235
|
+
Returns True on success.
|
|
236
|
+
"""
|
|
237
|
+
url = f"http://{host}/api/devices/{device_id}/action/iosLocation"
|
|
238
|
+
try:
|
|
239
|
+
resp = _requests.post(
|
|
240
|
+
url,
|
|
241
|
+
auth=(user, password),
|
|
242
|
+
json={"args": [payload]},
|
|
243
|
+
timeout=10,
|
|
244
|
+
)
|
|
245
|
+
resp.raise_for_status()
|
|
246
|
+
return True
|
|
247
|
+
except Exception as exc:
|
|
248
|
+
console.print(f"[bold red]HC3 push failed:[/bold red] {exc}")
|
|
249
|
+
return False
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── fetch + display ───────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
def fetch_device_data(api: PyiCloudService) -> list[dict]:
|
|
255
|
+
"""Fetch all devices and return a list of location dicts."""
|
|
256
|
+
try:
|
|
257
|
+
devices = api.devices
|
|
258
|
+
devices.refresh(locate=True)
|
|
259
|
+
except Exception as exc:
|
|
260
|
+
console.print(f"[bold red]Error fetching devices:[/bold red] {exc}")
|
|
261
|
+
return []
|
|
262
|
+
|
|
263
|
+
result = []
|
|
264
|
+
for device in devices:
|
|
265
|
+
raw_loc = device.location or {}
|
|
266
|
+
raw_data = device.data
|
|
267
|
+
battery = raw_data.get("batteryLevel") or (
|
|
268
|
+
(raw_data.get("location") or {}).get("batteryLevel")
|
|
269
|
+
)
|
|
270
|
+
lat = raw_loc.get("latitude")
|
|
271
|
+
lon = raw_loc.get("longitude")
|
|
272
|
+
ts = raw_loc.get("timeStamp") or raw_loc.get("timestamp")
|
|
273
|
+
result.append({
|
|
274
|
+
"name": device.name or "Unknown",
|
|
275
|
+
"model": device.model_name or device.model or "?",
|
|
276
|
+
"lat": lat,
|
|
277
|
+
"lon": lon,
|
|
278
|
+
"accuracy": raw_loc.get("horizontalAccuracy"),
|
|
279
|
+
"battery": round(battery, 3) if battery is not None else None,
|
|
280
|
+
"timestamp": ts,
|
|
281
|
+
"map": get_map_link(lat, lon) if lat and lon else None,
|
|
282
|
+
})
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def show_devices(data: list[dict]) -> None:
|
|
287
|
+
"""Render a Rich table from a list of device dicts."""
|
|
288
|
+
if not data:
|
|
289
|
+
console.print("[yellow]No devices found.[/yellow]")
|
|
290
|
+
return
|
|
291
|
+
|
|
292
|
+
table = Table(
|
|
293
|
+
title=f"[bold]Find My – All Devices[/bold] [dim]{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}[/dim]",
|
|
294
|
+
box=box.ROUNDED,
|
|
295
|
+
show_header=True,
|
|
296
|
+
header_style="bold magenta",
|
|
297
|
+
)
|
|
298
|
+
table.add_column("#", justify="right", style="dim", no_wrap=True)
|
|
299
|
+
table.add_column("Device", style="bold white", no_wrap=True)
|
|
300
|
+
table.add_column("Model", style="dim", no_wrap=True)
|
|
301
|
+
table.add_column("Latitude", justify="right", style="cyan")
|
|
302
|
+
table.add_column("Longitude", justify="right", style="cyan")
|
|
303
|
+
table.add_column("Accuracy (m)", justify="right")
|
|
304
|
+
table.add_column("Battery", justify="center")
|
|
305
|
+
table.add_column("Last Seen", style="dim")
|
|
306
|
+
table.add_column("Map", style="blue")
|
|
307
|
+
|
|
308
|
+
for idx, d in enumerate(data):
|
|
309
|
+
lat, lon = d["lat"], d["lon"]
|
|
310
|
+
table.add_row(
|
|
311
|
+
str(idx + 1),
|
|
312
|
+
d["name"],
|
|
313
|
+
d["model"],
|
|
314
|
+
f"{lat:.6f}" if lat is not None else "–",
|
|
315
|
+
f"{lon:.6f}" if lon is not None else "–",
|
|
316
|
+
f"{d['accuracy']:.0f}" if d["accuracy"] is not None else "–",
|
|
317
|
+
battery_bar(d["battery"]),
|
|
318
|
+
format_time(d["timestamp"]),
|
|
319
|
+
d["map"] or "–",
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
console.print(table)
|
|
323
|
+
console.print(
|
|
324
|
+
f"[dim]Total: {len(data)} device(s) — own + family members sharing location.[/dim]\n"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ── CLI ───────────────────────────────────────────────────────────────────────
|
|
329
|
+
|
|
330
|
+
DEFAULT_INTERVAL = 180 # 3 minutes
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def main() -> None:
|
|
334
|
+
parser = argparse.ArgumentParser(
|
|
335
|
+
prog="fibioslocation.py",
|
|
336
|
+
description=(
|
|
337
|
+
"Fetch iCloud Find My device locations (your own + family members)\n"
|
|
338
|
+
"and push them to a Fibaro HC3 home controller every N seconds.\n"
|
|
339
|
+
),
|
|
340
|
+
epilog=(
|
|
341
|
+
"Credentials / defaults are read from ~/.env:\n"
|
|
342
|
+
" HC3_HOST – HC3 hostname or IP (e.g. hc3 or 192.168.1.10)\n"
|
|
343
|
+
" HC3_USER – HC3 login user (default: admin)\n"
|
|
344
|
+
" HC3_PASSWORD – HC3 login password\n"
|
|
345
|
+
"\n"
|
|
346
|
+
"HC3 API call made on each poll:\n"
|
|
347
|
+
" POST http://<hc3-host>/api/devices/<hc3-device>/action/iosLocation\n"
|
|
348
|
+
" Body: {\"args\": [[ {name, model, lat, lon, accuracy, battery,\n"
|
|
349
|
+
" timestamp, map}, ... ]]}\n"
|
|
350
|
+
"\n"
|
|
351
|
+
"Examples:\n"
|
|
352
|
+
" python fibioslocation.py # poll every 3 min\n"
|
|
353
|
+
" python fibioslocation.py --once # single shot\n"
|
|
354
|
+
" python fibioslocation.py --no-hc3 # display only\n"
|
|
355
|
+
" python fibioslocation.py -i 60 # poll every 60s\n"
|
|
356
|
+
" python fibioslocation.py --hc3-host 192.168.1.10 --hc3-device 4200\n"
|
|
357
|
+
" python fibioslocation.py --debug # diagnose 2FA\n"
|
|
358
|
+
),
|
|
359
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
360
|
+
)
|
|
361
|
+
|
|
362
|
+
# iCloud
|
|
363
|
+
icloud = parser.add_argument_group("iCloud options")
|
|
364
|
+
icloud.add_argument("--email", "-e", metavar="EMAIL",
|
|
365
|
+
help="Apple ID e-mail (prompted if omitted)")
|
|
366
|
+
icloud.add_argument("--debug", "-d", action="store_true",
|
|
367
|
+
help="Print raw 2FA payload from Apple (helps diagnose login issues)")
|
|
368
|
+
|
|
369
|
+
# polling
|
|
370
|
+
poll = parser.add_argument_group("polling options")
|
|
371
|
+
poll.add_argument("--interval", "-i", type=int, default=DEFAULT_INTERVAL,
|
|
372
|
+
metavar="SECONDS",
|
|
373
|
+
help=f"Poll interval in seconds (default: {DEFAULT_INTERVAL})")
|
|
374
|
+
poll.add_argument("--once", action="store_true",
|
|
375
|
+
help="Fetch once and exit instead of looping")
|
|
376
|
+
|
|
377
|
+
# HC3
|
|
378
|
+
hc3 = parser.add_argument_group("HC3 options")
|
|
379
|
+
hc3.add_argument("--hc3-host", default=HC3_HOST, metavar="HOST",
|
|
380
|
+
help=f"HC3 hostname or IP (default from ~/.env: {HC3_HOST})")
|
|
381
|
+
hc3.add_argument("--hc3-user", default=HC3_USER, metavar="USER",
|
|
382
|
+
help=f"HC3 username (default from ~/.env: {HC3_USER})")
|
|
383
|
+
hc3.add_argument("--hc3-password", default=HC3_PASS, metavar="PASS",
|
|
384
|
+
help="HC3 password (default from ~/.env)")
|
|
385
|
+
hc3.add_argument("--hc3-device", type=int, default=HC3_DEVICE, metavar="ID",
|
|
386
|
+
help=f"HC3 QuickApp device ID (default: {HC3_DEVICE})")
|
|
387
|
+
hc3.add_argument("--no-hc3", action="store_true",
|
|
388
|
+
help="Display locations in terminal only, do not push to HC3")
|
|
389
|
+
|
|
390
|
+
args = parser.parse_args()
|
|
391
|
+
|
|
392
|
+
email = args.email or console.input("[bold]Apple ID (email):[/bold] ").strip()
|
|
393
|
+
password = getpass.getpass("Password: ")
|
|
394
|
+
|
|
395
|
+
api = login(email, password, debug=args.debug)
|
|
396
|
+
|
|
397
|
+
def cycle() -> None:
|
|
398
|
+
data = fetch_device_data(api)
|
|
399
|
+
show_devices(data)
|
|
400
|
+
if not args.no_hc3:
|
|
401
|
+
ok = push_to_hc3(data, host=args.hc3_host, user=args.hc3_user,
|
|
402
|
+
password=args.hc3_password, device_id=args.hc3_device)
|
|
403
|
+
if ok:
|
|
404
|
+
console.print(
|
|
405
|
+
f"[green]✓ Pushed {len(data)} device(s) to HC3 "
|
|
406
|
+
f"(device {args.hc3_device} @ {args.hc3_host})[/green] "
|
|
407
|
+
f"[dim]{datetime.now().strftime('%H:%M:%S')}[/dim]\n"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if args.once:
|
|
411
|
+
cycle()
|
|
412
|
+
else:
|
|
413
|
+
console.print(f"[dim]Polling every {args.interval}s — press Ctrl-C to stop.[/dim]\n")
|
|
414
|
+
try:
|
|
415
|
+
while True:
|
|
416
|
+
console.clear()
|
|
417
|
+
cycle()
|
|
418
|
+
time.sleep(args.interval)
|
|
419
|
+
except KeyboardInterrupt:
|
|
420
|
+
console.print("\n[dim]Stopped.[/dim]")
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
if __name__ == "__main__":
|
|
424
|
+
main()
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "fibioslocation"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "Fetch iCloud Find My device locations and push to Fibaro HC3"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
keywords = ["fibaro", "icloud", "location", "hc3", "home-automation"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Programming Language :: Python :: 3",
|
|
15
|
+
"Operating System :: OS Independent",
|
|
16
|
+
"Topic :: Home Automation",
|
|
17
|
+
]
|
|
18
|
+
dependencies = [
|
|
19
|
+
"pyicloud",
|
|
20
|
+
"rich",
|
|
21
|
+
"requests",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.scripts]
|
|
25
|
+
fibioslocation = "fibioslocation:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
py-modules = ["fibioslocation"]
|